diff --git a/assets/images/moon.svg b/assets/images/moon.svg
new file mode 100644
index 000000000000..edd130c0a25b
--- /dev/null
+++ b/assets/images/moon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index c4b8f0f4677b..c5f67959a48a 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -8433,6 +8433,9 @@ const CONST = {
BILLING_BANNER: {
RIGHT_ICON: 'BillingBanner-RightIcon',
},
+ HIGH_CONTRAST_MODE_SWITCHER: {
+ TOGGLE: 'HighContrastModeSwitcher-Toggle',
+ },
AGENTS_WORKFLOWS_BANNER: {
DISMISS: 'AgentsWorkflowsBanner-Dismiss',
},
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 451db5291bb9..c91789c28b6f 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -448,6 +448,10 @@ const ONYXKEYS = {
// This can be either "light", "dark", "system", "light-contrast", "dark-contrast" or "system-contrast"
PREFERRED_THEME: 'nvp_preferredTheme',
+ // Client-only flag set when a logged-out user enables high contrast on the sign-in page.
+ // It is reconciled with the server's base theme right after sign-in, then cleared.
+ SIGN_IN_HIGH_CONTRAST_INTENT: 'signInHighContrastIntent',
+
// Information about the onyx updates IDs that were received from the server
ONYX_UPDATES_FROM_SERVER: 'onyxUpdatesFromServer',
@@ -1553,6 +1557,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[];
[ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string;
[ONYXKEYS.PREFERRED_THEME]: ValueOf;
+ [ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT]: boolean;
[ONYXKEYS.MAPBOX_ACCESS_TOKEN]: OnyxTypes.MapboxAccessToken;
[ONYXKEYS.ONYX_UPDATES_FROM_SERVER]: OnyxTypes.AnyOnyxUpdatesFromServer;
[ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT]: number;
diff --git a/src/components/HighContrastModeSwitcher.tsx b/src/components/HighContrastModeSwitcher.tsx
new file mode 100644
index 000000000000..22332e1b220e
--- /dev/null
+++ b/src/components/HighContrastModeSwitcher.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import {getBaseTheme, getContrastTheme, isHighContrastTheme} from '@styles/theme/utils';
+import variables from '@styles/variables';
+import {setHighContrastIntent, updateTheme} from '@userActions/User';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import Icon from './Icon';
+import PressableWithFeedback from './Pressable/PressableWithFeedback';
+import Text from './Text';
+
+function HighContrastModeSwitcher() {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME);
+ const icons = useMemoizedLazyExpensifyIcons(['Moon']);
+
+ const currentTheme = preferredTheme ?? CONST.THEME.DEFAULT;
+ const isHighContrast = isHighContrastTheme(currentTheme);
+ const label = translate(isHighContrast ? 'themePage.disableHighContrast' : 'themePage.enableHighContrast');
+
+ const toggleHighContrast = () => {
+ const baseTheme = getBaseTheme(currentTheme);
+ updateTheme(isHighContrast ? baseTheme : getContrastTheme(baseTheme), false);
+ setHighContrastIntent(!isHighContrast);
+ };
+
+ return (
+
+
+ {label}
+
+ );
+}
+
+export default HighContrastModeSwitcher;
diff --git a/src/components/Icon/chunks/expensify-icons.chunk.ts b/src/components/Icon/chunks/expensify-icons.chunk.ts
index 821fb37ef966..758f440b4962 100644
--- a/src/components/Icon/chunks/expensify-icons.chunk.ts
+++ b/src/components/Icon/chunks/expensify-icons.chunk.ts
@@ -175,6 +175,7 @@ import MoneyHourglass from '@assets/images/money-hourglass.svg';
import MoneySearch from '@assets/images/money-search.svg';
import MoneyWaving from '@assets/images/money-waving.svg';
import Monitor from '@assets/images/monitor.svg';
+import Moon from '@assets/images/moon.svg';
import MultiTag from '@assets/images/multi-tag.svg';
import Fingerprint from '@assets/images/multifactorAuthentication/fingerprint.svg';
import Mute from '@assets/images/mute.svg';
@@ -416,6 +417,7 @@ const Expensicons = {
MoneyWaving,
MoneyHourglass,
Monitor,
+ Moon,
Mute,
ExpensifyLogoNew,
NewWindow,
diff --git a/src/hooks/useReconcileHighContrastIntent.ts b/src/hooks/useReconcileHighContrastIntent.ts
new file mode 100644
index 000000000000..60730b2a7b91
--- /dev/null
+++ b/src/hooks/useReconcileHighContrastIntent.ts
@@ -0,0 +1,35 @@
+import {useEffect, useRef} from 'react';
+import {getBaseTheme, getContrastTheme} from '@styles/theme/utils';
+import {setHighContrastIntent, updateTheme} from '@userActions/User';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import useOnyx from './useOnyx';
+
+/**
+ * Reconcile a logged-out user's high contrast choice from the sign-in page with the base theme the server returns once they sign in.
+ * The intent is `true` when they enabled it, `false` when they disabled it, and cleared when there is nothing to reconcile.
+ */
+function useReconcileHighContrastIntent() {
+ const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME);
+ const [highContrastIntent] = useOnyx(ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT);
+ const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP);
+ const [session] = useOnyx(ONYXKEYS.SESSION);
+ const wasLoadingApp = useRef(undefined);
+
+ useEffect(() => {
+ const hasFinishedLoading = !!wasLoadingApp.current && !isLoadingApp;
+ wasLoadingApp.current = isLoadingApp;
+ if (!hasFinishedLoading || highContrastIntent === undefined || !session?.authToken) {
+ return;
+ }
+ const currentTheme = preferredTheme ?? CONST.THEME.DEFAULT;
+ const baseTheme = getBaseTheme(currentTheme);
+ const targetTheme = highContrastIntent ? getContrastTheme(baseTheme) : baseTheme;
+ if (currentTheme !== targetTheme) {
+ updateTheme(targetTheme, false);
+ }
+ setHighContrastIntent(null);
+ }, [isLoadingApp, highContrastIntent, preferredTheme, session?.authToken]);
+}
+
+export default useReconcileHighContrastIntent;
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 4aab5fbe808d..60a3816ec883 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -2930,6 +2930,8 @@ ${amount} für ${merchant} – ${date}`,
},
},
highContrastMode: 'Hoher Kontrast',
+ enableHighContrast: 'Hohen Kontrast aktivieren',
+ disableHighContrast: 'Hohen Kontrast deaktivieren',
chooseThemeBelowOrSync: 'Wählen Sie unten ein Design aus oder synchronisieren Sie es mit den Einstellungen Ihres Geräts.',
},
termsOfUse: {
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 156c2c6b3695..4bc8faebe702 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -3007,6 +3007,8 @@ const translations = {
},
},
highContrastMode: 'High contrast mode',
+ enableHighContrast: 'Enable high contrast',
+ disableHighContrast: 'Disable high contrast',
chooseThemeBelowOrSync: 'Choose a theme below, or sync with your device settings.',
},
termsOfUse: {
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 12fc795dbcd6..adf88b2e46b5 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -2805,6 +2805,8 @@ ${amount} para ${merchant} - ${date}`,
},
},
highContrastMode: 'Modo de alto contraste',
+ enableHighContrast: 'Activar alto contraste',
+ disableHighContrast: 'Desactivar alto contraste',
chooseThemeBelowOrSync: 'Elige un tema a continuación o sincronízalo con los ajustes de tu dispositivo.',
},
termsOfUse: {
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index e1924676e2c2..cdc80c78b951 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -2938,6 +2938,8 @@ ${amount} pour ${merchant} - ${date}`,
},
},
highContrastMode: 'Mode contraste élevé',
+ enableHighContrast: 'Activer le contraste élevé',
+ disableHighContrast: 'Désactiver le contraste élevé',
chooseThemeBelowOrSync: 'Choisissez un thème ci-dessous ou synchronisez avec les réglages de votre appareil.',
},
termsOfUse: {
diff --git a/src/languages/it.ts b/src/languages/it.ts
index b74810e139e7..b85253bb995d 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -2926,6 +2926,8 @@ ${amount} per ${merchant} - ${date}`,
},
},
highContrastMode: 'Modalità alto contrasto',
+ enableHighContrast: 'Attiva alto contrasto',
+ disableHighContrast: 'Disattiva alto contrasto',
chooseThemeBelowOrSync: 'Scegli un tema qui sotto o sincronizza con le impostazioni del tuo dispositivo.',
},
termsOfUse: {
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index b001b3c7cfd5..59c6194857ef 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -2898,6 +2898,8 @@ ${date} の ${merchant} への ${amount}`,
},
},
highContrastMode: 'ハイコントラストモード',
+ enableHighContrast: 'ハイコントラストを有効にする',
+ disableHighContrast: 'ハイコントラストを無効にする',
chooseThemeBelowOrSync: '以下からテーマを選択するか、デバイスの設定と同期してください。',
},
termsOfUse: {
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index ddd96d1d7e65..5b3014ffdcc5 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -2923,6 +2923,8 @@ ${amount} voor ${merchant} - ${date}`,
},
},
highContrastMode: 'Hoog contrast',
+ enableHighContrast: 'Hoog contrast inschakelen',
+ disableHighContrast: 'Hoog contrast uitschakelen',
chooseThemeBelowOrSync: 'Kies hieronder een thema, of synchroniseer met de instellingen van je apparaat.',
},
termsOfUse: {
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 4d73d8b074ee..12036c0a3c15 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -2917,6 +2917,8 @@ ${amount} dla ${merchant} - ${date}`,
},
},
highContrastMode: 'Tryb wysokiego kontrastu',
+ enableHighContrast: 'Włącz wysoki kontrast',
+ disableHighContrast: 'Wyłącz wysoki kontrast',
chooseThemeBelowOrSync: 'Wybierz motyw poniżej lub zsynchronizuj z ustawieniami urządzenia.',
},
termsOfUse: {
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index b1b7653c6e71..3585b9bffc20 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -2917,6 +2917,8 @@ ${amount} para ${merchant} - ${date}`,
},
},
highContrastMode: 'Modo de alto contraste',
+ enableHighContrast: 'Ativar alto contraste',
+ disableHighContrast: 'Desativar alto contraste',
chooseThemeBelowOrSync: 'Escolha um tema abaixo ou sincronize com as configurações do seu dispositivo.',
},
termsOfUse: {
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 4d017ea5a363..02c329822c50 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -2841,6 +2841,8 @@ ${amount},商户:${merchant} - 日期:${date}`,
},
},
highContrastMode: '高对比度模式',
+ enableHighContrast: '启用高对比度',
+ disableHighContrast: '关闭高对比度',
chooseThemeBelowOrSync: '请选择下方的主题,或与您的设备设置同步。',
},
termsOfUse: {
diff --git a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx
index ab7d2acd6208..f953488083a9 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx
+++ b/src/libs/Navigation/AppNavigator/AuthScreensInitHandler.tsx
@@ -7,6 +7,7 @@ import useHasActiveAdminPolicies from '@hooks/useHasActiveAdminPolicies';
import useLastWorkspaceNumber from '@hooks/useLastWorkspaceNumber';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
+import useReconcileHighContrastIntent from '@hooks/useReconcileHighContrastIntent';
import useReportAttributes from '@hooks/useReportAttributes';
import {init, isClientTheLeader} from '@libs/ActiveClientManager';
import Log from '@libs/Log';
@@ -73,6 +74,8 @@ function AuthScreensInitHandler() {
const reportAttributesRef = useRef(reportAttributes);
reportAttributesRef.current = reportAttributes;
+ useReconcileHighContrastIntent();
+
useEffect(() => {
if (!Navigation.isActiveRoute(ROUTES.SIGN_IN_MODAL)) {
return;
diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts
index a14e9aecdbcd..2e7785835e71 100644
--- a/src/libs/actions/App.ts
+++ b/src/libs/actions/App.ts
@@ -135,6 +135,7 @@ const KEYS_TO_PRESERVE: OnyxKey[] = [
ONYXKEYS.SESSION,
ONYXKEYS.NVP_TRY_FOCUS_MODE,
ONYXKEYS.PREFERRED_THEME,
+ ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT,
ONYXKEYS.NVP_PREFERRED_LOCALE,
ONYXKEYS.CREDENTIALS,
ONYXKEYS.PRESERVED_USER_SESSION,
diff --git a/src/libs/actions/User.ts b/src/libs/actions/User.ts
index faae7abbf801..42d1aadf8c1c 100644
--- a/src/libs/actions/User.ts
+++ b/src/libs/actions/User.ts
@@ -65,9 +65,18 @@ import applyOnyxUpdatesReliably from './applyOnyxUpdatesReliably';
import {getDeviceInfoWithID} from './Device';
import {openOldDotLink} from './Link';
import {showReportActionNotification} from './Report';
-import {resendValidateCode as sessionResendValidateCode} from './Session';
+import {isAnonymousUser, resendValidateCode as sessionResendValidateCode} from './Session';
import redirectToSignIn from './SignInRedirect';
+// `sessionAccountID` is only used in actions, not during render. So `Onyx.connectWithoutView` is appropriate.
+let sessionAccountID: number | undefined;
+Onyx.connectWithoutView({
+ key: ONYXKEYS.SESSION,
+ callback: (value) => {
+ sessionAccountID = value?.accountID;
+ },
+});
+
type DomainOnyxUpdate =
| OnyxUpdate<`${typeof ONYXKEYS.COLLECTION.DOMAIN}${string}`>
| OnyxUpdate<`${typeof ONYXKEYS.COLLECTION.DOMAIN_PENDING_ACTIONS}${string}`>
@@ -1214,6 +1223,12 @@ function setContactMethodAsDefault(
}
function updateTheme(theme: ValueOf, shouldGoBack = true) {
+ // When toggling high contrast from the sign-in page, the user is not signed in. So persist the preference locally only.
+ if (!sessionAccountID || isAnonymousUser()) {
+ Onyx.set(ONYXKEYS.PREFERRED_THEME, theme);
+ return;
+ }
+
const optimisticData: Array> = [
{
onyxMethod: Onyx.METHOD.SET,
@@ -1233,6 +1248,10 @@ function updateTheme(theme: ValueOf, shouldGoBack = true) {
}
}
+function setHighContrastIntent(hasIntent: boolean | null) {
+ Onyx.set(ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT, hasIntent);
+}
+
/**
* Sets a custom status
*/
@@ -1914,6 +1933,7 @@ export {
updateChatPriorityMode,
setContactMethodAsDefault,
updateTheme,
+ setHighContrastIntent,
resetContactMethodValidateCodeSentState,
updateCustomStatus,
clearCustomStatus,
diff --git a/src/pages/signin/Licenses.tsx b/src/pages/signin/Licenses.tsx
index 5a6227b3a966..cc1c92b3a95b 100644
--- a/src/pages/signin/Licenses.tsx
+++ b/src/pages/signin/Licenses.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import {View} from 'react-native';
+import HighContrastModeSwitcher from '@components/HighContrastModeSwitcher';
import LocalePicker from '@components/LocalePicker';
import RenderHTML from '@components/RenderHTML';
import Text from '@components/Text';
@@ -18,6 +19,9 @@ function Licenses() {
${translate('termsOfUse.license')}`} />
+
+
+
>
diff --git a/src/pages/signin/SignInPage.tsx b/src/pages/signin/SignInPage.tsx
index 9409ed2c94a7..7ddf5b6ce508 100644
--- a/src/pages/signin/SignInPage.tsx
+++ b/src/pages/signin/SignInPage.tsx
@@ -360,8 +360,10 @@ function SignInPageWrapper({ref}: SignInPageProps) {
function WithTheme(Component: React.ComponentType) {
function ThemedComponent({ref}: SignInPageProps) {
const [preferredTheme] = useOnyx(ONYXKEYS.PREFERRED_THEME);
+ const [highContrastIntent] = useOnyx(ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT);
const contrastThemes: string[] = [CONST.THEME.DARK_CONTRAST, CONST.THEME.LIGHT_CONTRAST, CONST.THEME.SYSTEM_CONTRAST];
- const signInTheme = contrastThemes.includes(preferredTheme ?? '') ? CONST.THEME.DARK_CONTRAST : CONST.THEME.DARK;
+ const isHighContrast = highContrastIntent ?? contrastThemes.includes(preferredTheme ?? '');
+ const signInTheme = isHighContrast ? CONST.THEME.DARK_CONTRAST : CONST.THEME.DARK;
return (