[ 'name' => 'Grayscale', 'primaryColor' => '#000000', 'secondaryColor' => '#757575', ], 'grayscale2' => [ 'name' => 'Grayscale Reverse', 'primaryColor' => '#d1d1d1', 'secondaryColor' => '#000000', ], 'themeDefault' => 'themeDefault', 'companyColors' => 'companyColors', ]; /** * Get color scheme values adjusted for current color mode. * Ensures WCAG 2.1 AA contrast compliance in both light and dark modes. * * @param string $schemeName The identifier of the color scheme * @param array $scheme The original color scheme definition * @return array The color scheme with mode-appropriate values * * @api */ private function getColorSchemeForMode(string $schemeName, array $scheme): array { $colorMode = $this->getColorMode(); // Adjust grayscale schemes for dark mode to ensure readability if ($colorMode === 'dark') { if ($schemeName === 'grayscale1') { return [ 'name' => $scheme['name'], 'primaryColor' => '#e8e8e8', // Light gray - 8.5:1 contrast on #292929 'secondaryColor' => '#b0b0b0', // Medium gray - 4.2:1 contrast ]; } if ($schemeName === 'grayscale2') { return [ 'name' => $scheme['name'], 'primaryColor' => '#a0a0a0', // Light gray - 5.0:1 contrast on #292929 'secondaryColor' => '#d1d1d1', // Light gray - 9.1:1 contrast ]; } } // Light mode: use original values return $scheme; } /** * possible font choices */ public array $fonts = [ 'roboto' => 'Roboto', 'atkinson' => 'Atkinson Hyperlegible', 'shantell' => 'Shantell Sans', ]; /** * possible font choices */ public array $fontTooltips = [ 'roboto' => 'Designed to be easy to read on a variety of devices.', 'atkinson' => 'Atkinson was specifically developed for readers with low vision.', 'shantell' => 'The shape of the letters and increased spacing makes words less crowded and easier to read.', ]; /** * __construct - Constructor */ public function __construct( Environment $config, Setting $settingsRepo, Language $language, AppSettings $appSettings, FileManager $fileManager ) { $this->config = $config; $this->settingsRepo = $settingsRepo; $this->iniData = []; $this->language = $language; $this->appSettings = $appSettings; $this->fileManager = $fileManager; } /** * Batch-preload all user theme settings into the SettingCache in a single query. * Called once on first page load after login to avoid 8-11 individual getSetting() calls * as each theme getter (getActive, getColorMode, getColorScheme, getFont, etc.) fires. * * The preloaded values are cached by SettingCache's in-memory tier, so subsequent * individual getSetting() calls within the same request are instant. */ public function preloadUserSettings(): void { if (! Auth::isLoggedIn()) { return; } $userId = session('userdata.id'); if (! $userId) { return; } // If session already has theme data, skip preloading if (session()->exists('usersettings.theme')) { return; } $keys = [ "usersettings.$userId.theme", "usersettings.$userId.colorMode", "usersettings.$userId.colorScheme", "usersettings.$userId.themeFont", "usersettings.$userId.backgroundType", "usersettings.$userId.backgroundImage", 'companysettings.primarycolor', 'companysettings.secondarycolor', 'companysettings.sitename', 'companysettings.logoPath', ]; // Single batch query -- results are stored in SettingCache's in-memory tier $this->settingsRepo->getSettingsForKeys($keys); } /** * Retrieves the available color schemes. * * @return array The available color schemes. */ public function getAvailableColorSchemes(): array { $this->readIniData(); // Apply color mode adjustments to predefined schemes $parsedColorSchemes = []; foreach ($this->colorSchemes as $key => $scheme) { if (is_array($scheme)) { $parsedColorSchemes[$key] = $this->getColorSchemeForMode($key, $scheme); } else { $parsedColorSchemes[$key] = $scheme; } } $parsedColorSchemes['themeDefault'] = [ 'name' => 'Leantime', 'primaryColor' => $this->iniData['general']['primaryColor'] ?? $this->colorSchemes['themeDefault']['primaryColor'], 'secondaryColor' => $this->iniData['general']['secondaryColor'] ?? $this->colorSchemes['themeDefault']['secondaryColor'], ]; $primaryColor = $this->settingsRepo->getSetting('companysettings.primarycolor') ? $this->settingsRepo->getSetting('companysettings.primarycolor') : null; $secondaryColor = $this->settingsRepo->getSetting('companysettings.secondarycolor') ? $this->settingsRepo->getSetting('companysettings.secondarycolor') : null; $parsedColorSchemes['companyColors'] = [ 'name' => 'label.companyColors', 'primaryColor' => $primaryColor ?? $this->config->primarycolor ?? $parsedColorSchemes['themeDefault']['primaryColor'], 'secondaryColor' => $secondaryColor ?? $this->config->secondarycolor ?? $parsedColorSchemes['themeDefault']['secondaryColor'], ]; if ( $parsedColorSchemes['companyColors']['primaryColor'] == $parsedColorSchemes['themeDefault']['primaryColor'] && $parsedColorSchemes['companyColors']['secondaryColor'] == $parsedColorSchemes['themeDefault']['secondaryColor']) { unset($parsedColorSchemes['companyColors']); } $colorschemes = self::dispatchFilter('colorschemes', $parsedColorSchemes); return $colorschemes; } public function getAvailableFonts() { return self::dispatchFilter('fonts', $this->fonts); } /** * Retrieves the user's background image URL. * Caches the result in session to avoid a DB query on every page load. * * @return string|null The background image URL, or null if not logged in. */ public function getBackgroundImage(): ?string { if (Auth::isLoggedIn()) { if (session()->exists('usersettings.backgroundImage')) { return session('usersettings.backgroundImage') ?: null; } $image = $this->settingsRepo->getSetting('usersettings.'.session('userdata.id').'.backgroundImage'); session(['usersettings.backgroundImage' => $image ?: '']); return $image ?: null; } return null; } public function setBackgroundImage(string $url): void { if (Auth::isLoggedIn()) { $this->settingsRepo->saveSetting('usersettings.'.session('userdata.id').'.backgroundType', 'image'); $this->settingsRepo->saveSetting('usersettings.'.session('userdata.id').'.backgroundImage', $url); session(['usersettings.backgroundType' => 'image']); session(['usersettings.backgroundImage' => $url]); } } /** * Retrieves the user's background type (gradient or image). * Caches the result in session to avoid a DB query on every page load. * * @return string The background type. */ public function getBackgroundType(): string { if (Auth::isLoggedIn()) { if (session()->exists('usersettings.backgroundType')) { return session('usersettings.backgroundType') ?: 'gradient'; } $type = $this->settingsRepo->getSetting('usersettings.'.session('userdata.id').'.backgroundType'); session(['usersettings.backgroundType' => $type ?: 'gradient']); return $type ?: 'gradient'; } return 'gradient'; } public function setBackgroundType(string $type): void { if (Auth::isLoggedIn()) { $this->settingsRepo->saveSetting('usersettings.'.session('userdata.id').'.backgroundType', $type); session(['usersettings.backgroundType' => $type]); if ($type == 'gradient') { $this->settingsRepo->deleteSetting('usersettings.'.session('userdata.id').'.backgroundImage'); session(['usersettings.backgroundImage' => '']); } } } /** * getActive - Return active theme id * * @return string Active theme identifier */ public function getActive(): string { // Reset .ini data $this->iniData = []; if (session()->exists('usersettings.theme') && Auth::isLoggedIn()) { return session('usersettings.theme'); } // Return user specific theme, if active // This is an active logged in session. if (Auth::isLoggedIn()) { // User is logged in, we don't have a theme yet, check settings $theme = $this->settingsRepo->getSetting('usersettings.'.session('userdata.id').'.theme'); if ($theme !== false) { $this->setActive($theme); return $theme; } } // No generic theme set. Check if cookie is set if (isset($_COOKIE['theme'])) { $this->setActive($_COOKIE['theme']); return $_COOKIE['theme']; } // Return configured // Nothing set, get default theme from config if (isset($this->config->defaultTheme) && ! empty($this->config->defaultTheme)) { $this->setActive($this->config->defaultTheme); return $this->config->defaultTheme; } // Return default return static::DEFAULT; } /** * getColorMode - Return active color mode * * @return string Active theme identifier */ public function getColorMode() { // Return generic theme if (session()->exists('usersettings.colorMode') && Auth::isLoggedIn()) { return session('usersettings.colorMode'); } if (Auth::isLoggedIn()) { // User is logged in, we don't have a theme yet, check settings $colorMode = $this->settingsRepo->getSetting('usersettings.'.session('userdata.id').'.colorMode'); if ($colorMode !== false) { $this->setColorMode($colorMode); return $colorMode; } } // No generic theme set. Check if cookie is set if (isset($_COOKIE['colorMode'])) { $this->setColorMode($_COOKIE['colorMode']); return $_COOKIE['colorMode']; } // Return default session(['usersettings.colorMode' => 'light']); return 'light'; } /** * getColorScheme - Return the active color scheme * Color schemes can be chosen by the user and can be themedefault, company colors or other predefined schemes * The colors that change are accent1 and accent2 * * @return string Active theme identifier */ public function getColorScheme() { // Return generic theme if (session()->exists('usersettings.colorScheme') && Auth::isLoggedIn()) { $this->setAccentColors(session('usersettings.colorScheme')); return session('usersettings.colorScheme'); } if (Auth::isLoggedIn()) { // User is logged in, we don't have a theme yet, check settings $colorScheme = $this->settingsRepo->getSetting('usersettings.'.session('userdata.id').'.colorScheme'); if ($colorScheme !== false) { $this->setColorScheme($colorScheme); return $colorScheme; } } if (isset($_COOKIE['colorScheme'])) { $this->setColorScheme($_COOKIE['colorScheme']); return $_COOKIE['colorScheme']; } if (! empty($this->config->primarycolor) && ! empty($this->config->secondarycolor)) { // Return default $this->setColorScheme('companyColors'); return 'companyColors'; } else { // Return default $this->setColorScheme('themeDefault'); return 'themeDefault'; } } /** * getFont - Return active font * * @return string Active theme identifier */ public function getFont() { // Return generic theme if (session()->exists('usersettings.themeFont') && Auth::isLoggedIn()) { $this->setFont(session('usersettings.themeFont')); return session('usersettings.themeFont'); } if (Auth::isLoggedIn()) { // User is logged in, we don't have a theme yet, check settings $themeFont = $this->settingsRepo->getSetting('usersettings.'.session('userdata.id').'.themeFont'); if ($themeFont !== false) { $this->setFont($themeFont); return $themeFont; } } if (isset($_COOKIE['themeFont'])) { $this->setFont($_COOKIE['themeFont']); return $_COOKIE['themeFont']; } // Return default $this->setFont('roboto'); return 'roboto'; } /** * setActive - Set active theme * * Note: After setActive, the language settings need to be reloaded/reset, because languages are theme specific * * @param string $id Active theme identifier. * * @throws Exception Exception if theme does not exist. */ public function setActive(string $id): void { if ($id == '') { $id = static::DEFAULT; } // not a valid theme. Use default if (! is_dir(ROOT.'/theme/'.$id) || ! file_exists(ROOT.'/theme/'.$id.'/'.static::DEFAULT_INI.'.ini')) { $id = static::DEFAULT; } // Only set if user is logged in if (Auth::isLoggedIn()) { session(['usersettings.theme' => $id]); } EventDispatcher::addFilterListener( 'leantime.core.http.httpkernel.handle.beforeSendResponse', fn ($response) => tap($response, fn (Response $response) => $response->headers->setCookie( Cookie::create('theme') ->withValue($id) ->withExpires(time() + 60 * 60 * 24 * 30) ->withPath(Str::finish($this->config->appDir, '/')) ->withSameSite('strict') )) ); } /** * setColorModel - Set active theme * * * @param string $colorMode color mode of theme (light, dark). */ public function setColorMode(string $colorMode): void { if ($colorMode == '') { $colorMode = 'light'; } // Only store colors in session for logged in users if (Auth::isLoggedIn()) { session(['usersettings.colorMode' => $colorMode]); // Clear cached color values to force reload with new mode-aware values session()->forget('usersettings.colors.primaryColor'); session()->forget('usersettings.colors.secondaryColor'); // Refresh accent colors for current color scheme with new mode $currentScheme = session('usersettings.colorScheme'); if ($currentScheme) { $this->setAccentColors($currentScheme); } } EventDispatcher::addFilterListener( 'leantime.core.http.httpkernel.handle.beforeSendResponse', fn ($response) => tap($response, fn (Response $response) => $response->headers->setCookie( Cookie::create('colorMode') ->withValue($colorMode) ->withExpires(time() + 60 * 60 * 24 * 30) ->withPath(Str::finish($this->config->appDir, '/')) ->withSameSite('strict') )) ); } /** * setFont - Set active font * * * @param string $font font name key (roboto, atkinson). */ public function setFont(string $font): void { if ($font == '') { $font = 'roboto'; } if (Auth::isLoggedIn()) { session(['usersettings.themeFont' => $font]); } EventDispatcher::addFilterListener( 'leantime.core.http.httpkernel.handle.beforeSendResponse', fn ($response) => tap($response, fn (Response $response) => $response->headers->setCookie( Cookie::create('themeFont') ->withValue($font) ->withExpires(time() + 60 * 60 * 24 * 30) ->withPath(Str::finish($this->config->appDir, '/')) ->withSameSite('strict') )) ); } /** * setColorScheme - Set active theme * * * @param string $colorScheme color scheme of theme (themeDefault, companyColors). */ public function setColorScheme(string $colorScheme): void { if ($colorScheme == '') { $colorScheme = 'themeDefault'; } if (Auth::isLoggedIn()) { session(['usersettings.colorScheme' => $colorScheme]); $this->setAccentColors($colorScheme); } EventDispatcher::addFilterListener( 'leantime.core.http.httpkernel.handle.beforeSendResponse', fn ($response) => tap($response, fn (Response $response) => $response->headers->setCookie( Cookie::create('colorScheme') ->withValue($colorScheme) ->withExpires(time() + 60 * 60 * 24 * 30) ->withPath(Str::finish($this->config->appDir, '/')) ->withSameSite('strict') )) ); } /** * getAll - Return an array of all themes * * @return array return an array of all themes * * @throws BindingResolutionException */ public function getAll(): array { $theme = $this->getActive(); $themes = []; $handle = opendir(ROOT.'/theme'); if ($handle === false) { return $themes; } while (false !== ($themeDir = readdir($handle))) { if ($themeDir == '.' || $themeDir == '..') { continue; } // Ready theme ini $themeIni = ROOT .'/theme/' .$themeDir .'/theme.ini'; if (file_exists($themeIni)) { $iniData = parse_ini_file( $themeIni, true, INI_SCANNER_RAW ); if (isset($iniData['general']['name']) && $iniData['general']['name'] !== null) { $themes[$themeDir] = $iniData['general']; } } } return $themes; } /** * getDir - Return the root directory of the currently active theme * * @return string Root directory of currently active theme */ public function getDir(): string { return ROOT.'/theme/'.$this->getActive(); } /** * getDir - Return the root directory of the default theme * * @return string Root directory of default theme */ public function getDefaultDir(): string { return ROOT.'/theme/'.static::DEFAULT; } /** * getUrl() - Return an URL pointing to the root directory of the currently active theme * * @return string Root URL currently active theme */ public function getUrl(): string { return $this->config->appUrl.'/theme/'.$this->getActive(); } /** * getDefaultUrl() - Return an URL pointing to the root directory of the default theme * * @return string Root URL default theme */ public function getDefaultUrl(): string { return ROOT.'/theme/'.static::DEFAULT; } /** * getStyleUrl - Return URL that allows loading the style file of the theme * * @return string|false URL to the css style file of the current theme or false, if it does not exist */ public function getStyleUrl(): string|false { return $this->getAssetPath($this->getColorMode(), 'css'); } /** * getCustomStyleUrl - Return URL that allows loading the customized part of the style file of the theme * * @return string|false URL to the customized part of the css style file of the current theme or false, if it does not exist */ public function getCustomStyleUrl(): string|false { return $this->getAssetPath(static::CUSTOM_CSS, 'css'); } /** * getJsUrl - Return URL that allows loading the JavaScript file of the theme * * @return string|false URL to the JavaScript file of the current theme or false, if it does not exist */ public function getJsUrl(): string|false { return $this->getAssetPath(static::DEFAULT_JS, 'js'); } /** * getCustomJsUrl - Return URL that allows loading the customized part of the JavaScript file of the theme * * @return string|false URL to the customized part of the JavaScript file of the current theme or false, if it does not exist */ public function getCustomJsUrl(): string|false { return $this->getAssetPath(static::CUSTOM_JS, 'js'); } /** * getAssetPath - Get localized name of theme * * @param string $fileName Filename of asset without extension. * @param string $assetType Asset type either js or css. * @return string|bool returns file path to asset. false if file does not exist */ private function getAssetPath(string $fileName, string $assetType): string|bool { if ($fileName == '' || ($assetType != 'css' && $assetType != 'js')) { return false; } if (file_exists($this->getDir().'/'.$assetType.'/'.$fileName.'.min.'.$assetType)) { return $this->getUrl().'/'.$assetType.'/'.$fileName.'.min.'.$assetType.'?v='.$this->appSettings->appVersion; } if (file_exists($this->getDir().'/'.$assetType.'/'.$fileName.'.'.$assetType)) { return $this->getUrl().'/'.$assetType.'/'.$fileName.'.'.$assetType.'?v='.$this->appSettings->appVersion; } return false; } /** * Retrieves the name of the theme. * * First, it checks if the INI data is empty. If it is, the method tries to read the INI data. * If an exception occurs during the reading process, it is logged in the error log and the method returns * the language translation of the active theme name using the "__" method of the $language object. * * If the INI data contains a 'name' key, it returns the corresponding value. * * If none of the above conditions are met, it returns the language translation of the active theme name * using the "__" method of the $language object. * * @return string The name of the theme. */ public function getName(): string { if (empty($this->iniData)) { try { $this->readIniData(); } catch (Exception $e) { report($e); return $this->language->__('theme.'.$this->getActive().'name'); } } if (isset($this->iniData['name'])) { return $this->iniData['name']; } return $this->language->__('theme.'.$this->getActive().'name'); } /** * Retrieves the version number from the initialization data or returns an empty string if not available. * * @return string The version number. */ public function getVersion(): string { if (empty($this->iniData)) { try { $this->readIniData(); } catch (Exception $e) { report($e); return ''; } } if (isset($this->iniData['general']['version'])) { return $this->iniData['general']['version']; } return ''; } /** * Retrieves the URL of the company logo from the user's settings or the default logo path. * * @return string|false The URL of the company logo, or false if the company doesn't have a logo. */ public function getLogoUrl(): string|false { // Session Logo Path needs to be set here // Logo will be in there. Session will be renewed when new logo is updated or theme is changed $logoPath = false; if (session()->exists('companysettings.logoPath') === false || session('companysettings.logoPath') == '') { $logoPath = $this->settingsRepo->getSetting('companysettings.logoPath'); if ($logoPath === false) { session(['companysettings.logoPath' => false]); return false; } // File comes from config if (str_starts_with($logoPath, 'http')) { session(['companysettings.logoPath' => $logoPath]); return session('companysettings.logoPath'); } // File was uploaded. Check if we can find it $fileUrl = $this->fileManager->getFileUrl($logoPath, 'public', (60 * 24)); if ($fileUrl) { session(['companysettings.logoPath' => $fileUrl]); return session('companysettings.logoPath'); } // If we can't find a logo in the db, the company doesn't have a logo. Stop trying session(['companysettings.logoPath' => false]); } return session('companysettings.logoPath'); } /** * Sets the accent colors for the specified color scheme. * * @param string $colorScheme The name of the color scheme. * @return void */ public function setAccentColors(string $colorScheme) { if ($colorScheme == 'themeDefault') { $this->setThemeDefaultColors(); } elseif ($colorScheme == 'companyColors') { $this->setCompanyColors(); } else { $this->setSchemeColors($colorScheme); } } /** * Sets the default theme colors in the user's settings. * * This method sets the primary and secondary colors of the theme to the default values by assigning false to the corresponding session variables. */ public function setThemeDefaultColors() { // Using default css values session(['usersettings.colors.primaryColor' => false]); session(['usersettings.colors.secondaryColor' => false]); } /** * Sets the company colors in the user's settings. * * If the primary color setting is not already set in the user's settings, * it retrieves the primary color setting from the company settings. If the * primary color setting is not found in the company settings, it sets the * primary and secondary colors to the values specified in the application's * config file. * * If the secondary color setting is found in the company settings, it * sets the secondary color in the user's settings as well. * * @return void */ public function setCompanyColors() { if (! session()->exists('usersettings.colors.primaryColor')) { $primaryColor = $this->settingsRepo->getSetting('companysettings.primarycolor'); if ($primaryColor !== false) { session(['usersettings.colors.primaryColor' => $primaryColor]); session(['usersettings.colors.secondaryColor' => $primaryColor]); } else { session(['usersettings.colors.primaryColor' => $this->config->primaryColor]); session(['usersettings.colors.secondaryColor' => $this->config->secondaryColor]); } $secondaryColor = $this->settingsRepo->getSetting('companysettings.secondarycolor'); if ($secondaryColor !== false) { session(['usersettings.colors.secondaryColor' => $secondaryColor]); } } } /** * Sets the primary and secondary colors for the user's color scheme. * * @param string $colorscheme The color scheme to set. Should be a valid key in the available color schemes array. * @return void */ public function setSchemeColors($colorscheme) { $colorSchemes = $this->getAvailableColorSchemes(); if (isset($colorSchemes[$colorscheme]['primaryColor'])) { $primary = $colorSchemes[$colorscheme]['primaryColor']; session(['usersettings.colors.primaryColor' => $primary]); } if (isset($colorSchemes[$colorscheme]['secondaryColor'])) { $secondary = $colorSchemes[$colorscheme]['secondaryColor']; session(['usersettings.colors.secondaryColor' => $secondary]); } } /** * Retrieves the primary color from the user's settings or the default color scheme. * * @return string The primary color. */ public function getPrimaryColor() { if ( session()->exists('usersettings.colors.primaryColor') && session('usersettings.colors.primaryColor') != '' && Auth::isLoggedIn() ) { return session('usersettings.colors.primaryColor'); } $currentColorScheme = $this->getColorScheme(); $colorSchemes = $this->getAvailableColorSchemes(); $pickedScheme = $colorSchemes[$currentColorScheme] ?? $colorSchemes['themeDefault']; if (Auth::isLoggedIn()) { session(['usersettings.colors.primaryColor' => $pickedScheme['primaryColor']]); } return $pickedScheme['primaryColor']; } /** * getSecondaryColor - Retrieves the secondary color for the current user's color scheme * * This method returns the secondary color based on the following conditions: * - If the secondary color is set in the user's session and is not empty, and the user is logged in, * it will return the value stored in the session. * - If the user is logged in, it will set the secondary color from the available color schemes based on the current color scheme in use. * - If none of the above conditions are met, it will return the secondary color from the available color schemes based on the current color scheme in use. * * @return string The secondary color for the current user's color scheme */ public function getSecondaryColor() { if ( session()->exists('usersettings.colors.secondaryColor') && session('usersettings.colors.secondaryColor') != '' && Auth::isLoggedIn() ) { return session('usersettings.colors.secondaryColor'); } $colorSchemes = $this->getAvailableColorSchemes(); $currentColorScheme = $this->getColorScheme(); $pickedScheme = $colorSchemes[$currentColorScheme] ?? $colorSchemes['themeDefault']; if (Auth::isLoggedIn()) { session(['usersettings.colors.secondaryColor' => $pickedScheme['secondaryColor']]); } return $pickedScheme['secondaryColor']; } /** * readIniData - Read theme.ini configuration data * * @throws Exception */ private function readIniData(): void { if (! file_exists(ROOT.'/theme/'.$this->getActive().'/'.static::DEFAULT_INI.'.ini')) { report('Configuration file for theme '.$this->getActive().' not found'); $this->clearCache(); $this->setActive('default'); } $this->iniData = parse_ini_file( ROOT.'/theme/'.$this->getActive().'/'.static::DEFAULT_INI.'.ini', true, INI_SCANNER_TYPED ); if ($this->iniData === false) { $this->iniData = []; } } public static function clearCache(): void { session()->forget('usersettings.colors.primaryColor'); session()->forget('usersettings.colors.secondarycolor'); session()->forget('usersettings.colorMode'); session()->forget('usersettings.colorScheme'); session()->forget('usersettings.themeFont'); session()->forget('usersettings.theme'); session()->forget('usersettings.backgroundType'); session()->forget('usersettings.backgroundImage'); } }