diff --git a/.gitignore b/.gitignore index 9408328..e711413 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ app-example # generated native folders /ios /android + +# IDE specific files +.vscode/ +.idea/ +*.swp diff --git a/.vscode/extensions.json b/.vscode/extensions.json deleted file mode 100644 index b7ed837..0000000 --- a/.vscode/extensions.json +++ /dev/null @@ -1 +0,0 @@ -{ "recommendations": ["expo.vscode-expo-tools"] } diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index ae7681a..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "editor.codeActionsOnSave": { - "source.fixAll": "explicit", - "source.organizeImports": "explicit", - "source.sortMembers": "explicit" - }, - "typescript.tsdk": "node_modules/typescript/lib", - "cSpell.words": [ - "persistor", - "scriptian" - ] -} diff --git a/src/__tests__/utils/testUtils.tsx b/__tests__/utils/testUtils.tsx similarity index 88% rename from src/__tests__/utils/testUtils.tsx rename to __tests__/utils/testUtils.tsx index 70c34b0..7c13558 100644 --- a/src/__tests__/utils/testUtils.tsx +++ b/__tests__/utils/testUtils.tsx @@ -6,17 +6,17 @@ import { import { ReactNode } from "react"; import { Provider } from "react-redux"; -import browserReducer from "../../features/browser/browserSlice"; -import { BrowserState, BrowserTab } from "../../features/browser/types"; -import scriptsReducer from "../../features/scripts/scriptsSlice"; +import browserReducer from "@/src/features/browser/browserSlice"; +import { BrowserState, BrowserTab } from "@/src/features/browser/types"; +import scriptsReducer from "@/src/features/scripts/scriptsSlice"; import { ScriptExecution, ScriptsState, UserScript, -} from "../../features/scripts/types"; -import settingsReducer from "../../features/settings/settingsSlice"; -import { SettingsState } from "../../features/settings/types"; -import { RootState } from "../../store"; +} from "@/src/features/scripts/types"; +import settingsReducer from "@/src/features/settings/settingsSlice"; +import { SettingsState } from "@/src/features/settings/types"; +import { RootState } from "@/src/store"; // Default states for each slice export const defaultBrowserState: BrowserState = { diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index 58a5ff5..f8105be 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,3 +1,5 @@ +import { ThemeProvider } from "@/src/theme/ThemeContext"; +import { useTheme } from "@/src/theme/useTheme"; import { Stack } from "expo-router"; import { StatusBar } from "expo-status-bar"; import React from "react"; @@ -17,16 +19,29 @@ const LoadingComponent = () => ( ); +const ThemedStatusBar = () => { + const theme = useTheme(); + return ; +}; + +const AppContent = () => { + return ( + + + + + + + ); +}; + export default function RootLayout() { return ( } persistor={persistor}> - - - - - - + + + ); diff --git a/src/app/index.tsx b/src/app/index.tsx index f017d30..9bcf09d 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -1,15 +1,59 @@ -import { Text, View } from "react-native"; +import { useSettings } from "@/src/features/settings/hooks/useSettings"; +import { useTheme } from "@/src/theme/useTheme"; +import { Pressable, Text, View } from "react-native"; export default function Index() { + const theme = useTheme(); + const settings = useSettings(); + return ( - Scriptian Browser + + The current theme color is {theme.name}.{"\n"} + The current theme mode is {settings.theme}. + + settings.switchTheme("system")}> + + System Theme + + + settings.switchTheme("light")}> + + Light Theme + + + settings.switchTheme("dark")}> + + Dark Theme + + ); } diff --git a/src/constants/Icons.ts b/src/constants/Icons.ts new file mode 100644 index 0000000..9799262 --- /dev/null +++ b/src/constants/Icons.ts @@ -0,0 +1,80 @@ +/** + * A collection of icon name constants used throughout the application UI. + * + * @remarks + * - All icon names are marked as `const` to ensure type safety and prevent accidental modification. + * - The keys are grouped by their usage context for clarity. + * + * @example + * ```typescript + * import { Icons } from './constants/Icons'; + * + * // Use an icon for a back button + * const backIcon = Icons.back; // "chevron-back" + * ``` + */ +export const Icons = { + // Bottom toolbar navigation + back: "chevron-back" as const, + forward: "chevron-forward" as const, + share: "share-outline" as const, + bookmarks: "book-outline" as const, + tabs: "copy-outline" as const, + + // Address bar icons + refresh: "refresh" as const, + stop: "close" as const, + secure: "lock-closed" as const, + insecure: "warning" as const, + search: "search" as const, + + // Tab management + closeTab: "close" as const, + addTab: "add" as const, + tabOverview: "albums-outline" as const, + + // Menu and settings + menu: "ellipsis-horizontal" as const, + settings: "settings-outline" as const, + more: "ellipsis-vertical" as const, + + // Browser actions + reload: "refresh" as const, + home: "home-outline" as const, + downloads: "download-outline" as const, + history: "time-outline" as const, + + // Script management + scripts: "code-outline" as const, + scriptActive: "code" as const, + scriptDisabled: "code-slash-outline" as const, + + // Reader and accessibility + reader: "reader-outline" as const, + textSize: "text" as const, + + // Bookmarks and favorites + bookmark: "bookmark-outline" as const, + bookmarkFilled: "bookmark" as const, + star: "star-outline" as const, + starFilled: "star" as const, + + // General UI + check: "checkmark" as const, + error: "alert-circle" as const, + info: "information-circle" as const, + edit: "create-outline" as const, + delete: "trash-outline" as const, + copy: "copy-outline" as const, +} as const; + +export type Icon = (typeof Icons)[keyof typeof Icons]; + +export const IconSizes = { + small: 16, + medium: 20, + large: 24, + xlarge: 28, + toolbar: 24, + tab: 20, +} as const; diff --git a/src/features/browser/hooks/__tests__/useBrowser-test.ts b/src/features/browser/hooks/__tests__/useBrowser-test.ts index 293e5d0..9cbcc0f 100644 --- a/src/features/browser/hooks/__tests__/useBrowser-test.ts +++ b/src/features/browser/hooks/__tests__/useBrowser-test.ts @@ -1,9 +1,9 @@ -import { act } from "@testing-library/react-native"; import { createMockTab, defaultBrowserState, renderHookWithStore, -} from "../../../../__tests__/utils/testUtils"; +} from "@/__tests__/utils/testUtils"; +import { act } from "@testing-library/react-native"; import { useBrowser } from "../useBrowser"; describe("useBrowser Hook", () => { diff --git a/src/features/scripts/hooks/__tests__/useScripts-test.ts b/src/features/scripts/hooks/__tests__/useScripts-test.ts index 9c2818e..b5f7363 100644 --- a/src/features/scripts/hooks/__tests__/useScripts-test.ts +++ b/src/features/scripts/hooks/__tests__/useScripts-test.ts @@ -1,11 +1,11 @@ -import { act } from "@testing-library/react-native"; import { createMockExecution, createMockScript, defaultScriptsState, defaultSettingsState, renderHookWithStore, -} from "../../../../__tests__/utils/testUtils"; +} from "@/__tests__/utils/testUtils"; +import { act } from "@testing-library/react-native"; import { useScripts } from "../useScripts"; describe("useScripts Hook", () => { diff --git a/src/features/settings/hooks/useSettings.ts b/src/features/settings/hooks/useSettings.ts index 7de49ad..1d303da 100644 --- a/src/features/settings/hooks/useSettings.ts +++ b/src/features/settings/hooks/useSettings.ts @@ -8,7 +8,7 @@ import { toggleScriptsEnabled, updateSettings, } from "../settingsSlice"; -import { SettingsState } from "../types"; +import { SettingsState, ThemeMode } from "../types"; export const useSettings = () => { const dispatch = useAppDispatch(); @@ -36,8 +36,12 @@ export const useSettings = () => { dispatch(toggleAutoUpdateScripts()); }; - // Theme settings - const switchTheme = (theme: "light" | "dark" | "system") => { + /** + * Switches the application's theme to the specified mode. + * + * @param theme - The desired theme mode to apply (e.g., 'system', 'light', 'dark'). + */ + const switchTheme = (theme: ThemeMode) => { dispatch(setTheme(theme)); }; @@ -56,9 +60,6 @@ export const useSettings = () => { }; // Computed values - const isDarkMode = settings.theme === "dark"; - const isLightMode = settings.theme === "light"; - const isSystemMode = settings.theme === "system"; const executionTimeInSeconds = settings.maxExecutionTime / 1000; return { @@ -66,9 +67,6 @@ export const useSettings = () => { ...settings, // Computed values - isDarkMode, - isLightMode, - isSystemMode, executionTimeInSeconds, // Actions diff --git a/src/features/settings/settingsSlice.ts b/src/features/settings/settingsSlice.ts index cf88f49..bd0ff79 100644 --- a/src/features/settings/settingsSlice.ts +++ b/src/features/settings/settingsSlice.ts @@ -1,5 +1,5 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { SettingsState } from "./types"; +import { SettingsState, ThemeMode } from "./types"; const initialState: SettingsState = { scriptsEnabled: true, @@ -29,7 +29,7 @@ const settingsSlice = createSlice({ state.logExecutions = !state.logExecutions; }, - setTheme: (state, action: PayloadAction<"light" | "dark" | "system">) => { + setTheme: (state, action: PayloadAction) => { state.theme = action.payload; }, diff --git a/src/features/settings/types.ts b/src/features/settings/types.ts index a06eeb1..6229460 100644 --- a/src/features/settings/types.ts +++ b/src/features/settings/types.ts @@ -1,7 +1,9 @@ +export type ThemeMode = "light" | "dark" | "system"; + export interface SettingsState { scriptsEnabled: boolean; maxExecutionTime: number; logExecutions: boolean; - theme: "light" | "dark" | "system"; + theme: ThemeMode; autoUpdateScripts: boolean; } diff --git a/src/theme/ThemeContext.tsx b/src/theme/ThemeContext.tsx new file mode 100644 index 0000000..c7488ba --- /dev/null +++ b/src/theme/ThemeContext.tsx @@ -0,0 +1,35 @@ +import { useAppSelector } from "@/src/store"; +import { createContext, useMemo } from "react"; +import { useColorScheme } from "react-native"; +import { themes } from "./themes"; +import { Theme } from "./types/theme"; + +export const ThemeContext = createContext(themes.default); + +function getTheme(theme: string): Theme { + if (theme in themes) { + return themes[theme]; + } + return themes.default; +} + +export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const themeMode = useAppSelector((state) => state.settings.theme); + const colorScheme = useColorScheme(); + + const themeName: string = useMemo(() => { + if (themeMode === "system") { + return colorScheme === "light" ? "light" : "dark"; + } else { + return themeMode; + } + }, [themeMode, colorScheme]); + + const value = useMemo(() => getTheme(themeName), [themeName]); + + return ( + {children} + ); +}; diff --git a/src/theme/__tests__/ThemeContext-test.tsx b/src/theme/__tests__/ThemeContext-test.tsx new file mode 100644 index 0000000..f631de0 --- /dev/null +++ b/src/theme/__tests__/ThemeContext-test.tsx @@ -0,0 +1,52 @@ +import { createMockStore } from "@/__tests__/utils/testUtils"; +import type { ThemeMode } from "@/src/features/settings/types"; +import { render } from "@testing-library/react-native"; +import React from "react"; +import { Text } from "react-native"; +import { Provider } from "react-redux"; +import { ThemeProvider } from "../ThemeContext"; +import { themes } from "../themes"; +import { useTheme } from "../useTheme"; + +const renderWithStore = ( + ui: React.ReactElement, + { theme = "light" }: { theme?: ThemeMode } = {} +) => { + const store = createMockStore({ + settings: { + theme, + scriptsEnabled: true, + maxExecutionTime: 10000, + logExecutions: false, + autoUpdateScripts: false, + }, + }); + return render({ui}); +}; + +const ThemeNameConsumer = () => { + const theme = useTheme(); + return {theme.name}; +}; + +describe("ThemeProvider", () => { + it("provides the light theme when settings.theme is 'light'", () => { + const { getByText } = renderWithStore( + + + , + { theme: "light" } + ); + expect(getByText(themes.light.name)).toBeTruthy(); + }); + + it("provides the dark theme when settings.theme is 'dark'", () => { + const { getByText } = renderWithStore( + + + , + { theme: "dark" } + ); + expect(getByText(themes.dark.name)).toBeTruthy(); + }); +}); diff --git a/src/theme/themes/darkTheme.ts b/src/theme/themes/darkTheme.ts new file mode 100644 index 0000000..3482403 --- /dev/null +++ b/src/theme/themes/darkTheme.ts @@ -0,0 +1,58 @@ +import { colors } from "../tokens/colors"; +import { spacing } from "../tokens/spacing"; +import { typography } from "../tokens/typography"; +import { type Theme } from "../types/theme"; + +export const darkTheme: Theme = { + name: "dark", + statusBarStyle: "light", + typography: { + ...typography, + }, + spacing: { + ...spacing, + }, + colors: { + ...colors, + // Brand colors + primary: "#0A84FF", + primaryDark: "#409CFF", + secondary: "#FF9F0A", + secondaryDark: "#FF9F0A", + + // Semantic colors + success: "#30D158", + warning: "#FF9F0A", + error: "#FF453A", + info: "#0A84FF", + + // Neutral palette + white: "#1C1C1E", + gray100: "#000000", + gray200: "#3A3A3C", + gray300: "#38383A", + gray400: "#48484A", + gray500: "#8E8E93", + gray600: "#48484A", + gray700: "#2C2C2E", + gray800: "rgba(255, 255, 255, 0.05)", + gray900: "rgba(0, 0, 0, 0.3)", + + // Text colors + textPrimary: "#FFFFFF", + textSecondary: "#8E8E93", + textInverse: "#1C1C1E", + + // Backgrounds + background: "#000000", + backgroundAlt: "#1C1C1E", + backgroundInverse: "#FFFFFF", + + // Borders + border: "#48484A", + borderStrong: "#38383A", + + // Overlay + overlay: "rgba(0, 0, 0, 0.3)", + }, +}; diff --git a/src/theme/themes/index.ts b/src/theme/themes/index.ts new file mode 100644 index 0000000..0546df3 --- /dev/null +++ b/src/theme/themes/index.ts @@ -0,0 +1,10 @@ +import { darkTheme } from "./darkTheme"; +import { lightTheme } from "./lightTheme"; + +import type { Theme } from "../types/theme"; + +export const themes: Record = { + default: lightTheme, + light: lightTheme, + dark: darkTheme, +}; diff --git a/src/theme/themes/lightTheme.ts b/src/theme/themes/lightTheme.ts new file mode 100644 index 0000000..5a9a586 --- /dev/null +++ b/src/theme/themes/lightTheme.ts @@ -0,0 +1,18 @@ +import { colors } from "../tokens/colors"; +import { spacing } from "../tokens/spacing"; +import { typography } from "../tokens/typography"; +import { type Theme } from "../types/theme"; + +export const lightTheme: Theme = { + name: "light", + statusBarStyle: "dark", + typography: { + ...typography, + }, + spacing: { + ...spacing, + }, + colors: { + ...colors, + }, +}; diff --git a/src/theme/tokens/colors.ts b/src/theme/tokens/colors.ts new file mode 100644 index 0000000..7d96da3 --- /dev/null +++ b/src/theme/tokens/colors.ts @@ -0,0 +1,43 @@ +export const colors = { + // Brand colors + primary: "#007AFF", + primaryDark: "#0051D5", + secondary: "#FF9500", + secondaryDark: "#FF9500", + + // Semantic colors + success: "#34C759", + warning: "#FF9500", + error: "#FF3B30", + info: "#007AFF", + + // Neutral palette + white: "#FFFFFF", + black: "#000000", + gray100: "#F2F2F7", + gray200: "#E5E5EA", + gray300: "#D1D1D6", + gray400: "#C6C6C8", + gray500: "#8E8E93", + gray600: "#C7C7CC", + gray700: "#F9F9F9", + gray800: "rgba(0, 0, 0, 0.05)", + gray900: "rgba(0, 0, 0, 0.1)", + + // Text colors + textPrimary: "#000000", + textSecondary: "#8E8E93", + textInverse: "#FFFFFF", + + // Backgrounds + background: "#F2F2F7", + backgroundAlt: "#FFFFFF", + backgroundInverse: "#1C1C1E", + + // Borders + border: "#D1D1D6", + borderStrong: "#C6C6C8", + + // Overlay + overlay: "rgba(0, 0, 0, 0.1)", +}; diff --git a/src/theme/tokens/spacing.ts b/src/theme/tokens/spacing.ts new file mode 100644 index 0000000..b56ba7a --- /dev/null +++ b/src/theme/tokens/spacing.ts @@ -0,0 +1,13 @@ +export const spacing = { + none: 0, + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, + xxl: 40, + xxxl: 64, + gutter: 16, + section: 24, + item: 8, +}; diff --git a/src/theme/tokens/typography.ts b/src/theme/tokens/typography.ts new file mode 100644 index 0000000..ff1db0b --- /dev/null +++ b/src/theme/tokens/typography.ts @@ -0,0 +1,69 @@ +import { Platform } from "react-native"; +import { FontWeight } from "../types/typography"; + +export const typography = { + fontFamily: Platform.OS === "ios" ? "San Francisco" : "Roboto", + fontSize: { + xs: 12, + sm: 14, + md: 16, + lg: 20, + xl: 24, + xxl: 32, + display: 40, + }, + fontWeight: { + thin: "100" as FontWeight, + light: "300" as FontWeight, + regular: "400" as FontWeight, + medium: "500" as FontWeight, + bold: "700" as FontWeight, + black: "900" as FontWeight, + }, + lineHeight: { + tight: 1.1, + normal: 1.4, + relaxed: 1.7, + }, + letterSpacing: { + normal: 0, + wide: 0.5, + wider: 1, + }, + h1: { + fontSize: 32, + fontWeight: "700" as FontWeight, + }, + h2: { + fontSize: 28, + fontWeight: "700" as FontWeight, + }, + h3: { + fontSize: 24, + fontWeight: "600" as FontWeight, + }, + h4: { + fontSize: 20, + fontWeight: "600" as FontWeight, + }, + h5: { + fontSize: 18, + fontWeight: "500" as FontWeight, + }, + h6: { + fontSize: 16, + fontWeight: "500" as FontWeight, + }, + body: { + fontSize: 16, + fontWeight: "400" as FontWeight, + }, + caption: { + fontSize: 12, + fontWeight: "400" as FontWeight, + }, + button: { + fontSize: 16, + fontWeight: "600" as FontWeight, + }, +}; diff --git a/src/theme/types/colorPalette.ts b/src/theme/types/colorPalette.ts new file mode 100644 index 0000000..4cb55e5 --- /dev/null +++ b/src/theme/types/colorPalette.ts @@ -0,0 +1,31 @@ +export type ColorPalette = { + primary: string; + primaryDark: string; + secondary: string; + secondaryDark: string; + success: string; + warning: string; + error: string; + info: string; + white: string; + black: string; + gray100: string; + gray200: string; + gray300: string; + gray400: string; + gray500: string; + gray600: string; + gray700: string; + gray800: string; + gray900: string; + textPrimary: string; + textSecondary: string; + textInverse: string; + background: string; + backgroundAlt: string; + backgroundInverse: string; + border: string; + borderStrong: string; + overlay: string; + [key: string]: string; +}; diff --git a/src/theme/types/spacing.ts b/src/theme/types/spacing.ts new file mode 100644 index 0000000..e2f13c6 --- /dev/null +++ b/src/theme/types/spacing.ts @@ -0,0 +1,14 @@ +export type Spacing = { + none: number; + xs: number; + sm: number; + md: number; + lg: number; + xl: number; + xxl: number; + xxxl: number; + gutter: number; + section: number; + item: number; + [key: string]: number; +}; diff --git a/src/theme/types/theme.ts b/src/theme/types/theme.ts new file mode 100644 index 0000000..8caf638 --- /dev/null +++ b/src/theme/types/theme.ts @@ -0,0 +1,11 @@ +import { ColorPalette } from "./colorPalette"; +import { Spacing } from "./spacing"; +import { Typography } from "./typography"; + +export type Theme = { + name: string; + statusBarStyle: "light" | "dark" | "auto"; + colors: ColorPalette; + typography: Typography; + spacing: Spacing; +}; diff --git a/src/theme/types/typography.ts b/src/theme/types/typography.ts new file mode 100644 index 0000000..eca824a --- /dev/null +++ b/src/theme/types/typography.ts @@ -0,0 +1,44 @@ +import { type TextStyle } from "react-native"; + +export type FontWeight = TextStyle["fontWeight"]; + +export type Typography = { + fontFamily: string; + fontSize: { + xs: number; + sm: number; + md: number; + lg: number; + xl: number; + xxl: number; + display: number; + }; + fontWeight: { + thin: FontWeight; + light: FontWeight; + regular: FontWeight; + medium: FontWeight; + bold: FontWeight; + black: FontWeight; + }; + lineHeight: { + tight: number; + normal: number; + relaxed: number; + }; + letterSpacing: { + normal: number; + wide: number; + wider: number; + }; + h1: TextStyle; + h2: TextStyle; + h3: TextStyle; + h4: TextStyle; + h5: TextStyle; + h6: TextStyle; + body: TextStyle; + caption: TextStyle; + button: TextStyle; + [key: string]: any; +}; diff --git a/src/theme/useTheme.ts b/src/theme/useTheme.ts new file mode 100644 index 0000000..404b464 --- /dev/null +++ b/src/theme/useTheme.ts @@ -0,0 +1,4 @@ +import { useContext } from "react"; +import { ThemeContext } from "./ThemeContext"; + +export const useTheme = () => useContext(ThemeContext);