From f1701920082995e002946c8e803e58b489220f84 Mon Sep 17 00:00:00 2001 From: Patrick Fic Date: Thu, 23 Oct 2025 13:44:33 -0700 Subject: [PATCH] Base dark theme implementation --- app.json | 1 + app/_layout.tsx | 114 +++++++++------ app/jobs/_layout.tsx | 12 +- components/data-label/data-label.jsx | 5 +- components/error/error-display.jsx | 14 +- components/job-documents/job-documents.jsx | 3 +- components/job-notes/job-notes.jsx | 4 +- components/job-tombstone/job-tombstone.jsx | 21 +-- components/jobs-list/job-list-item.jsx | 4 +- components/settings/settings.jsx | 17 ++- components/theme-selector/theme-selector.tsx | 37 +++++ .../upload-progress/upload-progress.jsx | 3 +- hooks/index.ts | 1 + hooks/use-color-scheme.ts | 1 - hooks/use-color-scheme.web.ts | 21 --- hooks/use-theme-color.ts | 21 --- hooks/useTheme.ts | 57 ++++++++ redux/user/user.actions.js | 4 + redux/user/user.reducer.js | 3 + redux/user/user.selectors.js | 5 + redux/user/user.types.js | 1 + util/theme.js | 135 ++++++++++++------ 22 files changed, 308 insertions(+), 176 deletions(-) create mode 100644 components/theme-selector/theme-selector.tsx create mode 100644 hooks/index.ts delete mode 100644 hooks/use-color-scheme.ts delete mode 100644 hooks/use-color-scheme.web.ts delete mode 100644 hooks/use-theme-color.ts create mode 100644 hooks/useTheme.ts diff --git a/app.json b/app.json index 6e3296f..9cc4a86 100644 --- a/app.json +++ b/app.json @@ -4,6 +4,7 @@ "slug": "imexmobile", "version": "1.8.0", "scheme": "imex-mobile-scheme", + "userInterfaceStyle": "automatic", "extra": { "expover": "1", "eas": { diff --git a/app/_layout.tsx b/app/_layout.tsx index a826b32..7d1a577 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -2,6 +2,11 @@ import { checkUserSession } from "@/redux/user/user.actions"; import { selectBodyshop, selectCurrentUser } from "@/redux/user/user.selectors"; import { ApolloProvider } from "@apollo/client"; import MaterialIcons from "@expo/vector-icons/MaterialIcons"; +import { + DarkTheme, + DefaultTheme, + ThemeProvider, +} from "@react-navigation/native"; import { Stack } from "expo-router"; import { Icon, @@ -9,7 +14,7 @@ import { NativeTabs, VectorIcon, } from "expo-router/unstable-native-tabs"; -import { useEffect } from "react"; +import React, { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { ActivityIndicator, Platform, View } from "react-native"; import { Provider as PaperProvider } from "react-native-paper"; @@ -17,45 +22,55 @@ import { connect, Provider } from "react-redux"; import { PersistGate } from "redux-persist/integration/react"; import { createStructuredSelector } from "reselect"; import { client } from "../graphql/client"; +import { useTheme as usePaperTheme } from "../hooks/useTheme"; import { persistor, store } from "../redux/store"; import "../translations/i18n"; -import theme from "../util/theme"; function AuthenticatedLayout() { const { t } = useTranslation(); + const paperTheme = usePaperTheme(); return ( - - - + + + + - {Platform.select({ - ios: , - android: ( - } - /> - ), - })} - - - {Platform.select({ - ios: , - android: ( - } /> - ), - })} - - - - {Platform.select({ - //ios: , - android: ( - } /> - ), - })} - - - + {Platform.select({ + ios: , + android: ( + } + /> + ), + })} + + + {Platform.select({ + ios: , + android: ( + } + /> + ), + })} + + + + {Platform.select({ + //ios: , + android: ( + } /> + ), + })} + + + + ); } @@ -79,21 +94,38 @@ const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, currentUser: selectCurrentUser, }); -const mapDispatchToProps = (dispatch) => ({ +const mapDispatchToProps = (dispatch: any) => ({ checkUserSession: () => dispatch(checkUserSession()), }); -function AppContent({ currentUser, checkUserSession, bodyshop }) { +function AppContent({ currentUser, checkUserSession, bodyshop }: any) { useEffect(() => { checkUserSession(); - }, []); + }, [checkUserSession]); if (currentUser.authorized === null) { - return ; + return ( + + + + ); } if (currentUser.authorized) { - return ; + return ( + + + + ); } - return ; + return ( + + + + ); +} + +function ThemedLayout({ children }: { children: React.ReactNode }) { + const themeToApply = usePaperTheme(); + return {children}; } const ConnectedAppContent = connect( mapStateToProps, @@ -105,9 +137,7 @@ export default function AppLayout() { - - - + diff --git a/app/jobs/_layout.tsx b/app/jobs/_layout.tsx index 72168bf..3c3123b 100644 --- a/app/jobs/_layout.tsx +++ b/app/jobs/_layout.tsx @@ -1,8 +1,7 @@ -import { Stack, useRouter } from "expo-router"; +import { Stack } from "expo-router"; import { useTranslation } from "react-i18next"; function JobsStack() { - const router = useRouter(); const { t } = useTranslation(); return ( { - // router.setParams({ - // search: event?.nativeEvent?.text, - // }); - // }, - // }, }} /> - {label} + {label} {theContent} ); diff --git a/components/error/error-display.jsx b/components/error/error-display.jsx index e48076b..10563a6 100644 --- a/components/error/error-display.jsx +++ b/components/error/error-display.jsx @@ -1,6 +1,6 @@ import { useTranslation } from "react-i18next"; -import { Text } from "react-native"; -import { Button, Card } from "react-native-paper"; + +import { Button, Card, Text } from "react-native-paper"; export default function ErrorDisplay({ errorMessage, error, onDismiss }) { const { t } = useTranslation(); @@ -14,11 +14,11 @@ export default function ErrorDisplay({ errorMessage, error, onDismiss }) { error || "An unknown error has occured."} - {onDismiss ? ( - - - - ) : null} + {onDismiss ? ( + + + + ) : null} ); diff --git a/components/job-documents/job-documents.jsx b/components/job-documents/job-documents.jsx index 200f1f9..ced9544 100644 --- a/components/job-documents/job-documents.jsx +++ b/components/job-documents/job-documents.jsx @@ -6,12 +6,11 @@ import { FlatList, Image, RefreshControl, - Text, TouchableOpacity, View, } from "react-native"; import ImageView from "react-native-image-viewing"; -import { ActivityIndicator } from "react-native-paper"; +import { ActivityIndicator, Text } from "react-native-paper"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import env from "../../env"; diff --git a/components/job-notes/job-notes.jsx b/components/job-notes/job-notes.jsx index 85d1fd4..e33a53e 100644 --- a/components/job-notes/job-notes.jsx +++ b/components/job-notes/job-notes.jsx @@ -5,8 +5,8 @@ import { useGlobalSearchParams } from "expo-router"; import { DateTime } from "luxon"; import React from "react"; import { useTranslation } from "react-i18next"; -import { FlatList, RefreshControl, Text, View } from "react-native"; -import { ActivityIndicator, Card } from "react-native-paper"; +import { FlatList, RefreshControl, View } from "react-native"; +import { ActivityIndicator, Card, Text } from "react-native-paper"; import ErrorDisplay from "../error/error-display"; export default function JobNotes() { diff --git a/components/job-tombstone/job-tombstone.jsx b/components/job-tombstone/job-tombstone.jsx index 226e0ce..de097b2 100644 --- a/components/job-tombstone/job-tombstone.jsx +++ b/components/job-tombstone/job-tombstone.jsx @@ -3,14 +3,8 @@ import { useQuery } from "@apollo/client"; import { useLocalSearchParams } from "expo-router"; import React from "react"; import { useTranslation } from "react-i18next"; -import { - RefreshControl, - ScrollView, - StyleSheet, - Text, - View, -} from "react-native"; -import { ActivityIndicator, Card, useTheme } from "react-native-paper"; +import { RefreshControl, ScrollView, StyleSheet, View } from "react-native"; +import { ActivityIndicator, Card, Text, useTheme } from "react-native-paper"; import DataLabelComponent from "../data-label/data-label"; export default function JobTombstone() { @@ -82,14 +76,9 @@ export default function JobTombstone() { /> - {`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${ - job.v_model_desc || "" - }`} - {job.v_vin} - - } + content={`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${ + job.v_model_desc || "" + } - ${job.v_vin}`} /> diff --git a/components/jobs-list/job-list-item.jsx b/components/jobs-list/job-list-item.jsx index 68373ad..58ec802 100644 --- a/components/jobs-list/job-list-item.jsx +++ b/components/jobs-list/job-list-item.jsx @@ -58,9 +58,7 @@ function JobListItemComponent({ openImagePicker, item }) { style={[ styles.glassCard, { - backgroundColor: theme.dark - ? "rgba(30,30,30,0.55)" - : "rgba(255,255,255,0.55)", + backgroundColor: theme.colors.primaryContainer, borderColor: theme.colors.outlineVariant, }, ]} diff --git a/components/settings/settings.jsx b/components/settings/settings.jsx index d2d365e..8b6d3a2 100644 --- a/components/settings/settings.jsx +++ b/components/settings/settings.jsx @@ -11,6 +11,7 @@ import { Button, Card, Divider, List, Text } from "react-native-paper"; import { SafeAreaView } from "react-native-safe-area-context"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; +import { ThemeSelector } from "../theme-selector/theme-selector"; import UploadDeleteSwitch from "./upload-delete-switch"; const mapStateToProps = createStructuredSelector({ @@ -24,6 +25,7 @@ export default connect(mapStateToProps, mapDispatchToProps)(Tab); function Tab({ bodyshop, currentUser }) { const { t } = useTranslation(); + const handleClearStorage = () => { Alert.alert( "Clear Local Cache", @@ -47,7 +49,7 @@ function Tab({ bodyshop, currentUser }) { }; return ( - + Settings @@ -59,7 +61,6 @@ function Tab({ bodyshop, currentUser }) { - {" "} {t("mediabrowser.labels.deleteafterupload")} @@ -97,6 +98,18 @@ function Tab({ bodyshop, currentUser }) { + + + + + + + + + + + + diff --git a/components/theme-selector/theme-selector.tsx b/components/theme-selector/theme-selector.tsx new file mode 100644 index 0000000..da1d5b4 --- /dev/null +++ b/components/theme-selector/theme-selector.tsx @@ -0,0 +1,37 @@ +import { setTheme } from "@/redux/user/user.actions"; +import { selectTheme } from "@/redux/user/user.selectors"; +import React from "react"; +import { SegmentedButtons } from "react-native-paper"; +import { useDispatch, useSelector } from "react-redux"; + +/** + * Example component showing how to use the useTheme hook + * and how to dispatch theme changes + */ +export const ThemeSelector = () => { + const dispatch = useDispatch(); + + const currentTheme = useSelector(selectTheme); + + const handleThemeChange = (theme: "light" | "dark" | "system") => { + dispatch(setTheme(theme)); + }; + + return ( + + ); +}; diff --git a/components/upload-progress/upload-progress.jsx b/components/upload-progress/upload-progress.jsx index 33eaaec..e0ee54f 100644 --- a/components/upload-progress/upload-progress.jsx +++ b/components/upload-progress/upload-progress.jsx @@ -1,5 +1,4 @@ import { clearUploadError } from "@/redux/photos/photos.actions"; -import theme from "@/util/theme"; import { formatBytes } from "@/util/uploadUtils"; import { useTranslation } from "react-i18next"; import { StyleSheet, View } from "react-native"; @@ -92,7 +91,7 @@ const styles = StyleSheet.create({ display: "flex", marginLeft: 12, marginRight: 12, - backgroundColor: theme.colors.elevation.level3, + //backgroundColor: theme.colors.elevation.level3, borderRadius: 20, paddingTop: 12, shadowColor: "#000", diff --git a/hooks/index.ts b/hooks/index.ts new file mode 100644 index 0000000..e1fd4b3 --- /dev/null +++ b/hooks/index.ts @@ -0,0 +1 @@ +export { useTheme } from "./useTheme"; diff --git a/hooks/use-color-scheme.ts b/hooks/use-color-scheme.ts deleted file mode 100644 index 17e3c63..0000000 --- a/hooks/use-color-scheme.ts +++ /dev/null @@ -1 +0,0 @@ -export { useColorScheme } from 'react-native'; diff --git a/hooks/use-color-scheme.web.ts b/hooks/use-color-scheme.web.ts deleted file mode 100644 index 7eb1c1b..0000000 --- a/hooks/use-color-scheme.web.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useEffect, useState } from 'react'; -import { useColorScheme as useRNColorScheme } from 'react-native'; - -/** - * To support static rendering, this value needs to be re-calculated on the client side for web - */ -export function useColorScheme() { - const [hasHydrated, setHasHydrated] = useState(false); - - useEffect(() => { - setHasHydrated(true); - }, []); - - const colorScheme = useRNColorScheme(); - - if (hasHydrated) { - return colorScheme; - } - - return 'light'; -} diff --git a/hooks/use-theme-color.ts b/hooks/use-theme-color.ts deleted file mode 100644 index 0cbc3a6..0000000 --- a/hooks/use-theme-color.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Learn more about light and dark modes: - * https://docs.expo.dev/guides/color-schemes/ - */ - -import { Colors } from '@/constants/theme'; -import { useColorScheme } from '@/hooks/use-color-scheme'; - -export function useThemeColor( - props: { light?: string; dark?: string }, - colorName: keyof typeof Colors.light & keyof typeof Colors.dark -) { - const theme = useColorScheme() ?? 'light'; - const colorFromProps = props[theme]; - - if (colorFromProps) { - return colorFromProps; - } else { - return Colors[theme][colorName]; - } -} diff --git a/hooks/useTheme.ts b/hooks/useTheme.ts new file mode 100644 index 0000000..af1e28f --- /dev/null +++ b/hooks/useTheme.ts @@ -0,0 +1,57 @@ +import { selectTheme } from "@/redux/user/user.selectors"; +import { darkTheme, lightTheme } from "@/util/theme"; +import { useColorScheme } from "react-native"; +import { useSelector } from "react-redux"; + +type ThemePreference = "light" | "dark" | "system"; + +/** + * Custom hook that returns the appropriate theme based on user preference + * + * This hook automatically selects the correct theme (light or dark) based on: + * - User's explicit preference (light/dark) + * - System appearance when "system" is selected + * - Defaults to system theme if no preference is set + * + * @returns The theme object compatible with React Native Paper + * + * @example + * ```typescript + * import { useTheme } from "@/hooks/useTheme"; + * + * const MyComponent = () => { + * const theme = useTheme(); + * + * return ( + * + * + * Themed content + * + * + * ); + * }; + * ``` + */ +export const useTheme = () => { + const userThemePreference: ThemePreference = useSelector(selectTheme); + const systemColorScheme = useColorScheme(); + + // Determine which theme to use based on user preference + let selectedTheme; + + switch (userThemePreference) { + case "light": + selectedTheme = lightTheme; + break; + case "dark": + selectedTheme = darkTheme; + break; + case "system": + default: + // Use system preference when user selects "system" or as fallback + selectedTheme = systemColorScheme === "dark" ? darkTheme : lightTheme; + break; + } + + return selectedTheme; +}; \ No newline at end of file diff --git a/redux/user/user.actions.js b/redux/user/user.actions.js index ddd7c0f..b0b6e50 100644 --- a/redux/user/user.actions.js +++ b/redux/user/user.actions.js @@ -98,3 +98,7 @@ export const validatePasswordResetFailure = (error) => ({ type: UserActionTypes.VALIDATE_PASSWORD_RESET_FAILURE, payload: error, }); +export const setTheme = (theme) => ({ + type: UserActionTypes.SET_THEME, + payload: theme, +}); diff --git a/redux/user/user.reducer.js b/redux/user/user.reducer.js index a5cf187..7c38c7b 100644 --- a/redux/user/user.reducer.js +++ b/redux/user/user.reducer.js @@ -7,6 +7,7 @@ const INITIAL_STATE = { bodyshop: null, signingIn: false, error: null, + theme: "system" }; const userReducer = (state = INITIAL_STATE, action) => { @@ -48,6 +49,8 @@ const userReducer = (state = INITIAL_STATE, action) => { case UserActionTypes.SET_SHOP_DETAILS: return { ...state, bodyshop: action.payload }; + case UserActionTypes.SET_THEME: + return { ...state, theme: action.payload }; case UserActionTypes.SIGN_IN_FAILURE: case UserActionTypes.SIGN_OUT_FAILURE: case UserActionTypes.EMAIL_SIGN_UP_FAILURE: diff --git a/redux/user/user.selectors.js b/redux/user/user.selectors.js index b6b762a..fa7a705 100644 --- a/redux/user/user.selectors.js +++ b/redux/user/user.selectors.js @@ -31,3 +31,8 @@ export const selectSigningIn = createSelector( [selectUser], (user) => user.signingIn ); + +export const selectTheme = createSelector( + [selectUser], + (user) => user.theme +); \ No newline at end of file diff --git a/redux/user/user.types.js b/redux/user/user.types.js index 0f5773c..fa46823 100644 --- a/redux/user/user.types.js +++ b/redux/user/user.types.js @@ -26,5 +26,6 @@ const UserActionTypes = { VALIDATE_PASSWORD_RESET_START: "VALIDATE_PASSWORD_RESET_START", VALIDATE_PASSWORD_RESET_SUCCESS: "VALIDATE_PASSWORD_RESET_SUCCESS", VALIDATE_PASSWORD_RESET_FAILURE: "VALIDATE_PASSWORD_RESET_FAILURE", + SET_THEME: "SET_THEME", }; export default UserActionTypes; diff --git a/util/theme.js b/util/theme.js index 27e31b7..107c28b 100644 --- a/util/theme.js +++ b/util/theme.js @@ -1,45 +1,92 @@ -export default //Custom values were used as the overrides did not work. - { - colors: { - primary: "#1890ff", - onPrimary: "#ffffff", - primaryContainer: "#e1e1e1ff", - onPrimaryContainer: "#001c3a", - secondary: "#545f71", - onSecondary: "#ffffff", - secondaryContainer: "#d8e3f8", - onSecondaryContainer: "#111c2b", - tertiary: "#00658d", - onTertiary: "#ffffff", - tertiaryContainer: "#c6e7ff", - onTertiaryContainer: "#001e2d", - error: "#ba1a1a", - onError: "#ffffff", - errorContainer: "#ffdad6", - onErrorContainer: "#410002", - background: "#fdfcff", - onBackground: "#1a1c1e", - surface: "#fdfcff", - onSurface: "#1a1c1e", - surfaceVariant: "#dededeff", - onSurfaceVariant: "#43474e", - outline: "#74777f", - outlineVariant: "#c3c6cf", - shadow: "#000000", - scrim: "#000000", - inverseSurface: "#2f3033", - inverseOnSurface: "#f1f0f4", - inversePrimary: "#a5c8ff", - elevation: { - level0: "transparent", - level1: "#f1f1f1ff", - level2: "#e9eff9", - level3: "#e1ebf6", - level4: "#dfe9f5", - level5: "#dae6f4", - }, - surfaceDisabled: "rgba(26, 28, 30, 0.12)", - onSurfaceDisabled: "rgba(26, 28, 30, 0.38)", - backdrop: "rgba(45, 49, 56, 0.4)", +const lightTheme = { + theme: 'light', + colors: { + primary: "#1890ff", + onPrimary: "#ffffff", + primaryContainer: "#e1e1e1ff", + onPrimaryContainer: "#001c3a", + secondary: "#545f71", + onSecondary: "#ffffff", + secondaryContainer: "#d8e3f8", + onSecondaryContainer: "#111c2b", + tertiary: "#00658d", + onTertiary: "#ffffff", + tertiaryContainer: "#c6e7ff", + onTertiaryContainer: "#001e2d", + error: "#ba1a1a", + onError: "#ffffff", + errorContainer: "#ffdad6", + onErrorContainer: "#410002", + background: "#fdfcff", + onBackground: "#1a1c1e", + surface: "#fdfcff", + onSurface: "#1a1c1e", + surfaceVariant: "#dededeff", + onSurfaceVariant: "#43474e", + outline: "#74777f", + outlineVariant: "#c3c6cf", + shadow: "#000000", + scrim: "#000000", + inverseSurface: "#2f3033", + inverseOnSurface: "#f1f0f4", + inversePrimary: "#a5c8ff", + elevation: { + level0: "transparent", + level1: "#f1f1f1ff", + level2: "#e9eff9", + level3: "#e1ebf6", + level4: "#dfe9f5", + level5: "#dae6f4", }, - }; \ No newline at end of file + surfaceDisabled: "rgba(26, 28, 30, 0.12)", + onSurfaceDisabled: "rgba(26, 28, 30, 0.38)", + backdrop: "rgba(45, 49, 56, 0.4)", + }, +}; + +const darkTheme = { + theme: 'dark', + colors: { + primary: "#a5c8ff", + onPrimary: "#001c3a", + primaryContainer: "#2c3e50", + onPrimaryContainer: "#d6e8ff", + secondary: "#bcc6db", + onSecondary: "#262f40", + secondaryContainer: "#3d4756", + onSecondaryContainer: "#d8e3f8", + tertiary: "#7dd3fc", + onTertiary: "#001e2d", + tertiaryContainer: "#004d66", + onTertiaryContainer: "#c6e7ff", + error: "#ffb4ab", + onError: "#690005", + errorContainer: "#93000a", + onErrorContainer: "#ffdad6", + background: "#0f1419", + onBackground: "#e4e1e6", + surface: "#0f1419", + onSurface: "#e4e1e6", + surfaceVariant: "#43474e", + onSurfaceVariant: "#c3c6cf", + outline: "#8d9199", + outlineVariant: "#43474e", + shadow: "#000000", + scrim: "#000000", + inverseSurface: "#e4e1e6", + inverseOnSurface: "#2f3033", + inversePrimary: "#1890ff", + elevation: { + level0: "transparent", + level1: "#1a1f2e", + level2: "#212837", + level3: "#293141", + level4: "#2b3344", + level5: "#2e3748", + }, + surfaceDisabled: "rgba(228, 225, 230, 0.12)", + onSurfaceDisabled: "rgba(228, 225, 230, 0.38)", + backdrop: "rgba(45, 49, 56, 0.4)", + }, +}; +export { darkTheme, lightTheme };