Base dark theme implementation

This commit is contained in:
Patrick Fic
2025-10-23 13:44:33 -07:00
parent b6271b8ef2
commit f170192008
22 changed files with 308 additions and 176 deletions

View File

@@ -4,6 +4,7 @@
"slug": "imexmobile",
"version": "1.8.0",
"scheme": "imex-mobile-scheme",
"userInterfaceStyle": "automatic",
"extra": {
"expover": "1",
"eas": {

View File

@@ -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,14 +22,21 @@ 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 (
<NativeTabs minimizeBehavior="onScrollDown" disableTransparentOnScrollEdge>
<ThemeProvider
value={paperTheme.theme === "dark" ? DarkTheme : DefaultTheme}
>
<NativeTabs
minimizeBehavior="onScrollDown"
disableTransparentOnScrollEdge
>
<NativeTabs.Trigger name="jobs">
<Label>{t("joblist.labels.activejobs")}</Label>
@@ -41,7 +53,9 @@ function AuthenticatedLayout() {
{Platform.select({
ios: <Icon sf="gear" drawable="custom_android_drawable" />,
android: (
<Icon src={<VectorIcon family={MaterialIcons} name="settings" />} />
<Icon
src={<VectorIcon family={MaterialIcons} name="settings" />}
/>
),
})}
<Label>{t("settings.titles.settings")}</Label>
@@ -56,6 +70,7 @@ function AuthenticatedLayout() {
<Label>Search</Label>
</NativeTabs.Trigger>
</NativeTabs>
</ThemeProvider>
);
}
@@ -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 <LoadingLayout />;
return (
<ThemedLayout>
<LoadingLayout />
</ThemedLayout>
);
}
if (currentUser.authorized) {
return <AuthenticatedLayout />;
return (
<ThemedLayout>
<AuthenticatedLayout />
</ThemedLayout>
);
}
return <UnauthenticatedLayout />;
return (
<ThemedLayout>
<UnauthenticatedLayout />
</ThemedLayout>
);
}
function ThemedLayout({ children }: { children: React.ReactNode }) {
const themeToApply = usePaperTheme();
return <PaperProvider theme={themeToApply}>{children}</PaperProvider>;
}
const ConnectedAppContent = connect(
mapStateToProps,
@@ -105,9 +137,7 @@ export default function AppLayout() {
<Provider store={store}>
<PersistGate persistor={persistor}>
<ApolloProvider client={client}>
<PaperProvider theme={theme}>
<ConnectedAppContent />
</PaperProvider>
</ApolloProvider>
</PersistGate>
</Provider>

View File

@@ -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 (
<Stack
@@ -17,15 +16,6 @@ function JobsStack() {
options={{
headerShown: false,
title: t("joblist.titles.jobtab"),
// headerSearchBarOptions: {
// placement: "automatic",
// placeholder: "Search",
// onChangeText: (event) => {
// router.setParams({
// search: event?.nativeEvent?.text,
// });
// },
// },
}}
/>
<Stack.Screen

View File

@@ -1,6 +1,7 @@
import { DateTime } from "luxon";
import React from "react";
import { Text, View } from "react-native";
import { View } from "react-native";
import { Text } from "react-native-paper";
export default function DataLabelComponent({
label,
@@ -17,7 +18,7 @@ export default function DataLabelComponent({
const { key, ...rest } = restProps;
return (
<View key={key} {...rest} style={{ margin: 4, ...restProps.style }}>
<Text style={{ color: "slategray" }}>{label}</Text>
<Text>{label}</Text>
<Text>{theContent}</Text>
</View>
);

View File

@@ -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();

View File

@@ -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";

View File

@@ -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() {

View File

@@ -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() {
/>
<DataLabelComponent
label={t("objects.jobs.fields.vehicle")}
content={
<View>
<Text>{`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${
content={`${job.v_model_yr || ""} ${job.v_make_desc || ""} ${
job.v_model_desc || ""
}`}</Text>
<Text>{job.v_vin}</Text>
</View>
}
} - ${job.v_vin}`}
/>
</View>
<View style={localStyles.twoColumnCardColumn}>

View File

@@ -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,
},
]}

View File

@@ -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 (
<SafeAreaView>
<SafeAreaView style={{ flex: 1 }}>
<ScrollView contentContainerStyle={styles.container}>
<Text variant="headlineMedium" style={styles.title}>
Settings
@@ -59,7 +61,6 @@ function Tab({ bodyshop, currentUser }) {
<List.Section>
<View style={styles.inlineRow}>
<Text style={styles.switchLabel}>
{" "}
{t("mediabrowser.labels.deleteafterupload")}
</Text>
<UploadDeleteSwitch />
@@ -97,6 +98,18 @@ function Tab({ bodyshop, currentUser }) {
</Card.Actions>
</Card>
<Card style={styles.section}>
<Card.Title title="Theme" />
<Card.Content>
<List.Section>
<View style={styles.inlineRow}>
<ThemeSelector />
</View>
</List.Section>
</Card.Content>
<Card.Actions></Card.Actions>
</Card>
<Card style={styles.section}>
<Card.Content>
<Text style={styles.paragraph}>

View File

@@ -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 (
<SegmentedButtons
value={currentTheme}
onValueChange={handleThemeChange}
buttons={[
{
value: "light",
label: "Light",
},
{
value: "dark",
label: "Dark",
},
{ value: "system", label: "System" },
]}
/>
);
};

View File

@@ -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",

1
hooks/index.ts Normal file
View File

@@ -0,0 +1 @@
export { useTheme } from "./useTheme";

View File

@@ -1 +0,0 @@
export { useColorScheme } from 'react-native';

View File

@@ -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';
}

View File

@@ -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];
}
}

57
hooks/useTheme.ts Normal file
View File

@@ -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 (
* <View style={{ backgroundColor: theme.colors.background }}>
* <Text style={{ color: theme.colors.onBackground }}>
* Themed content
* </Text>
* </View>
* );
* };
* ```
*/
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;
};

View File

@@ -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,
});

View File

@@ -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:

View File

@@ -31,3 +31,8 @@ export const selectSigningIn = createSelector(
[selectUser],
(user) => user.signingIn
);
export const selectTheme = createSelector(
[selectUser],
(user) => user.theme
);

View File

@@ -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;

View File

@@ -1,5 +1,5 @@
export default //Custom values were used as the overrides did not work.
{
const lightTheme = {
theme: 'light',
colors: {
primary: "#1890ff",
onPrimary: "#ffffff",
@@ -43,3 +43,50 @@ export default //Custom values were used as the overrides did not work.
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 };