diff --git a/electron/debug.ts b/electron/debug.ts index e63ed76e1..87377362b 100644 --- a/electron/debug.ts +++ b/electron/debug.ts @@ -93,6 +93,11 @@ export const initDebug = (opts: any, isAddReload: boolean): void => { } app.on('browser-window-created', (event, win) => { + // Skip dev tools for overlay window + if (win.title === 'Super Productivity Overlay') { + return; + } + if (opts.showDevTools) { win.webContents.once('devtools-opened', () => { // Workaround for https://github.com/electron/electron/issues/13095 diff --git a/electron/electronAPI.d.ts b/electron/electronAPI.d.ts index 2b3a7f6a0..3a0a84e58 100644 --- a/electron/electronAPI.d.ts +++ b/electron/electronAPI.d.ts @@ -103,6 +103,8 @@ export interface ElectronAPI { sendAppSettingsToElectron(globalCfg: GlobalConfigState): void; + sendSettingsUpdate(globalCfg: GlobalConfigState): void; + registerGlobalShortcuts(keyboardConfig: KeyboardConfig): void; showFullScreenBlocker(args: { msg?: string; takeABreakCfg: TakeABreakConfig }): void; @@ -123,6 +125,8 @@ export interface ElectronAPI { task: Task | null, isPomodoroEnabled: boolean, currentPomodoroSessionTime: number, + isFocusModeEnabled?: boolean, + currentFocusSessionTime?: number, ); exec(command: string): void; diff --git a/electron/indicator.ts b/electron/indicator.ts index dc2739d6a..a308b73b9 100644 --- a/electron/indicator.ts +++ b/electron/indicator.ts @@ -5,6 +5,11 @@ import { getWin } from './main-window'; import { GlobalConfigState } from '../src/app/features/config/global-config.model'; import { TaskCopy } from '../src/app/features/tasks/task.model'; import { release } from 'os'; +import { + initOverlayIndicator, + updateOverlayEnabled, + updateOverlayTask, +} from './overlay-indicator/overlay-indicator'; let tray: Tray; let DIR: string; @@ -43,6 +48,7 @@ export const initIndicator = ({ tray.on('click', () => { showApp(); }); + return tray; }; @@ -59,6 +65,23 @@ function initAppListeners(app: App): void { // eslint-disable-next-line prefer-arrow/prefer-arrow-functions function initListeners(): void { + let isOverlayEnabled = false; + // Listen for settings updates to handle overlay enable/disable + ipcMain.on(IPC.UPDATE_SETTINGS, (ev, settings: GlobalConfigState) => { + const isOverlayEnabledNew = settings?.misc?.isOverlayIndicatorEnabled || false; + if (isOverlayEnabledNew === isOverlayEnabled) { + return; + } + + isOverlayEnabled = isOverlayEnabledNew; + updateOverlayEnabled(isOverlayEnabled); + + // Initialize overlay without shortcut (overlay doesn't need shortcut, that's for focus mode) + if (isOverlayEnabled) { + initOverlayIndicator(isOverlayEnabled); + } + }); + ipcMain.on(IPC.SET_PROGRESS_BAR, (ev, { progress }) => { const suf = shouldUseDarkColors ? '-d' : '-l'; if (typeof progress === 'number' && progress > 0 && isFinite(progress)) { @@ -73,7 +96,22 @@ function initListeners(): void { ipcMain.on( IPC.CURRENT_TASK_UPDATED, - (ev, currentTask, isPomodoroEnabled, currentPomodoroSessionTime) => { + ( + ev, + currentTask, + isPomodoroEnabled, + currentPomodoroSessionTime, + isFocusModeEnabled, + currentFocusSessionTime, + ) => { + updateOverlayTask( + currentTask, + isPomodoroEnabled, + currentPomodoroSessionTime, + isFocusModeEnabled || false, + currentFocusSessionTime || 0, + ); + const mainWin = getWin(); getSettings(mainWin, (settings: GlobalConfigState) => { const isTrayShowCurrentTask = settings.misc.isTrayShowCurrentTask; diff --git a/electron/main-window.ts b/electron/main-window.ts index 991238f1a..8839ab9e0 100644 --- a/electron/main-window.ts +++ b/electron/main-window.ts @@ -18,6 +18,10 @@ import { readFileSync, stat } from 'fs'; import { error, log } from 'electron-log/main'; import { GlobalConfigState } from '../src/app/features/config/global-config.model'; import { IS_MAC } from './common.const'; +import { + showOverlayWindow, + hideOverlayWindow, +} from './overlay-indicator/overlay-indicator'; let mainWin: BrowserWindow; @@ -209,6 +213,24 @@ function initWinEventListeners(app: any): void { // TODO refactor quitting mess appCloseHandler(app); appMinimizeHandler(app); + + // Handle restore and show events to hide overlay + mainWin.on('restore', () => { + hideOverlayWindow(); + }); + + mainWin.on('show', () => { + hideOverlayWindow(); + }); + + mainWin.on('focus', () => { + hideOverlayWindow(); + }); + + // Handle hide event to show overlay + mainWin.on('hide', () => { + showOverlayWindow(); + }); } // eslint-disable-next-line prefer-arrow/prefer-arrow-functions @@ -283,6 +305,7 @@ const appCloseHandler = (app: App): void => { getSettings(mainWin, (appCfg: GlobalConfigState) => { if (appCfg && appCfg.misc.isMinimizeToTray && !(app as any).isQuiting) { mainWin.hide(); + showOverlayWindow(); return; } @@ -316,8 +339,13 @@ const appMinimizeHandler = (app: App): void => { if (appCfg.misc.isMinimizeToTray) { event.preventDefault(); mainWin.hide(); - } else if (IS_MAC) { - app.dock.show(); + showOverlayWindow(); + } else { + // For regular minimize (not to tray), also show overlay + showOverlayWindow(); + if (IS_MAC) { + app.dock.show(); + } } }); }); diff --git a/electron/overlay-indicator/overlay-api.d.ts b/electron/overlay-indicator/overlay-api.d.ts new file mode 100644 index 000000000..0d919d355 --- /dev/null +++ b/electron/overlay-indicator/overlay-api.d.ts @@ -0,0 +1,19 @@ +interface OverlayContentData { + title: string; + time: string; + mode: 'pomodoro' | 'focus' | 'task' | 'idle'; +} + +interface OverlayAPI { + setIgnoreMouseEvents: (ignore: boolean) => void; + showMainWindow: () => void; + onUpdateContent: (callback: (data: OverlayContentData) => void) => void; +} + +declare global { + interface Window { + overlayAPI: OverlayAPI; + } +} + +export {}; diff --git a/electron/overlay-indicator/overlay-indicator.ts b/electron/overlay-indicator/overlay-indicator.ts new file mode 100644 index 000000000..9318a64d2 --- /dev/null +++ b/electron/overlay-indicator/overlay-indicator.ts @@ -0,0 +1,293 @@ +import { BrowserWindow, ipcMain, screen, globalShortcut } from 'electron'; +import { join } from 'path'; +import { TaskCopy } from '../../src/app/features/tasks/task.model'; +import { info } from 'electron-log/main'; +import { IPC } from '../shared-with-frontend/ipc-events.const'; + +let overlayWindow: BrowserWindow | null = null; +let isOverlayEnabled = false; +let isOverlayVisible = true; +let currentTask: TaskCopy | null = null; +let isPomodoroEnabled = false; +let currentPomodoroSessionTime = 0; +let isFocusModeEnabled = false; +let currentFocusSessionTime = 0; + +export const initOverlayIndicator = (enabled: boolean, shortcut?: string): void => { + isOverlayEnabled = enabled; + + if (enabled) { + createOverlayWindow(); + if (shortcut) { + registerShortcut(shortcut); + } + initListeners(); + + // Show overlay and request current task state + setTimeout(() => { + const mainWindow = BrowserWindow.getAllWindows().find( + (win) => win !== overlayWindow, + ); + if (mainWindow) { + // Request current task state + mainWindow.webContents.send(IPC.REQUEST_CURRENT_TASK_FOR_OVERLAY); + } + // Always show overlay when initialized + showOverlayWindow(); + }, 100); + } +}; + +export const updateOverlayEnabled = (isEnabled: boolean): void => { + isOverlayEnabled = isEnabled; + + if (isEnabled && !overlayWindow) { + createOverlayWindow(); + initListeners(); + + // Show overlay and request current task state immediately when enabling + setTimeout(() => { + const mainWindow = BrowserWindow.getAllWindows().find( + (win) => win !== overlayWindow, + ); + if (mainWindow) { + mainWindow.webContents.send(IPC.REQUEST_CURRENT_TASK_FOR_OVERLAY); + } + // Always show overlay when enabled + showOverlayWindow(); + }, 100); + } else if (!isEnabled && overlayWindow) { + destroyOverlayWindow(); + } +}; + +export const updateOverlayTheme = (isDarkTheme: boolean): void => { + // No longer needed - using prefers-color-scheme CSS +}; + +export const destroyOverlayWindow = (): void => { + if (overlayWindow) { + overlayWindow.removeAllListeners('close'); + overlayWindow.destroy(); + overlayWindow = null; + } +}; + +const createOverlayWindow = (): void => { + if (overlayWindow) { + return; + } + + const primaryDisplay = screen.getPrimaryDisplay(); + const { width } = primaryDisplay.workAreaSize; + + overlayWindow = new BrowserWindow({ + width: 300, + height: 80, + x: width - 320, + y: 20, + title: 'Super Productivity Overlay', + frame: false, + transparent: true, + alwaysOnTop: true, + skipTaskbar: true, + // resizable: false, + minimizable: false, + maximizable: false, + hasShadow: false, // Disable shadow with transparent windows + autoHideMenuBar: true, + roundedCorners: false, // Disable rounded corners for better compatibility + webPreferences: { + preload: join(__dirname, 'overlay-preload.js'), + contextIsolation: true, + nodeIntegration: false, + disableDialogs: true, + webSecurity: true, + allowRunningInsecureContent: false, + }, + }); + + overlayWindow.loadFile(join(__dirname, 'overlay.html')); + + // Set visible on all workspaces immediately after creation + overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + + overlayWindow.on('closed', () => { + overlayWindow = null; + }); + + overlayWindow.on('ready-to-show', () => { + // Show the overlay window + isOverlayVisible = true; + overlayWindow.show(); + + // Ensure window stays on all workspaces + overlayWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true }); + + // Request current task state from main window + const mainWindow = BrowserWindow.getAllWindows().find((win) => win !== overlayWindow); + if (mainWindow) { + mainWindow.webContents.send(IPC.REQUEST_CURRENT_TASK_FOR_OVERLAY); + } + }); + + // Prevent context menu on right-click to avoid crashes + overlayWindow.webContents.on('context-menu', (e) => { + e.preventDefault(); + }); + + // Prevent any window system menu + overlayWindow.on('system-context-menu', (e) => { + e.preventDefault(); + }); + + // Prevent window close attempts that might cause issues + overlayWindow.on('close', (e) => { + if (isOverlayEnabled) { + e.preventDefault(); + overlayWindow.hide(); + isOverlayVisible = false; + } + }); + + // Don't make window click-through initially to allow dragging + // The renderer process will handle mouse events dynamically + + // Update initial state + updateOverlayContent(); +}; + +const registerShortcut = (shortcut: string): void => { + globalShortcut.register(shortcut, () => { + toggleOverlayVisibility(); + }); +}; + +const toggleOverlayVisibility = (): void => { + if (!overlayWindow) { + return; + } + + isOverlayVisible = !isOverlayVisible; + if (isOverlayVisible) { + overlayWindow.show(); + } else { + overlayWindow.hide(); + } +}; + +export const showOverlayWindow = (): void => { + if (!overlayWindow || !isOverlayEnabled) { + info( + 'Overlay show skipped: window=' + !!overlayWindow + ', enabled=' + isOverlayEnabled, + ); + return; + } + + // Only show if not already visible + if (!overlayWindow.isVisible()) { + info('Showing overlay window'); + isOverlayVisible = true; + overlayWindow.show(); + } else { + info('Overlay already visible'); + } +}; + +export const hideOverlayWindow = (): void => { + if (!overlayWindow || !isOverlayEnabled) { + info( + 'Overlay hide skipped: window=' + !!overlayWindow + ', enabled=' + isOverlayEnabled, + ); + return; + } + + // Only hide if currently visible + if (overlayWindow.isVisible()) { + info('Hiding overlay window'); + isOverlayVisible = false; + overlayWindow.hide(); + } else { + info('Overlay already hidden'); + } +}; + +const initListeners = (): void => { + // Listen for show main window request + ipcMain.on('overlay-show-main-window', () => { + const mainWindow = BrowserWindow.getAllWindows().find((win) => win !== overlayWindow); + if (mainWindow) { + mainWindow.show(); + mainWindow.focus(); + hideOverlayWindow(); + } + }); +}; + +export const updateOverlayTask = ( + task: TaskCopy | null, + pomodoroEnabled: boolean, + pomodoroTime: number, + focusModeEnabled: boolean, + focusTime: number, +): void => { + currentTask = task; + isPomodoroEnabled = pomodoroEnabled; + currentPomodoroSessionTime = pomodoroTime; + isFocusModeEnabled = focusModeEnabled; + currentFocusSessionTime = focusTime; + + updateOverlayContent(); +}; + +const updateOverlayContent = (): void => { + if (!overlayWindow || !isOverlayEnabled) { + return; + } + + let title = ''; + let timeStr = ''; + let mode: 'pomodoro' | 'focus' | 'task' | 'idle' = 'idle'; + + if (currentTask && currentTask.title) { + title = currentTask.title; + if (title.length > 40) { + title = title.substring(0, 37) + '...'; + } + + if (isPomodoroEnabled) { + mode = 'pomodoro'; + timeStr = formatTime(currentPomodoroSessionTime); + } else if (isFocusModeEnabled) { + mode = 'focus'; + timeStr = formatTime(currentFocusSessionTime); + } else if (currentTask.timeEstimate) { + mode = 'task'; + const remainingTime = Math.max(currentTask.timeEstimate - currentTask.timeSpent, 0); + timeStr = formatTime(remainingTime); + } else if (currentTask.timeSpent) { + mode = 'task'; + timeStr = formatTime(currentTask.timeSpent); + } + } + + overlayWindow.webContents.send('update-content', { + title, + time: timeStr, + mode, + }); +}; + +const formatTime = (timeMs: number): string => { + const totalSeconds = Math.floor(timeMs / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, '0')}:${seconds + .toString() + .padStart(2, '0')}`; + } + return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; +}; diff --git a/electron/overlay-indicator/overlay-preload.ts b/electron/overlay-indicator/overlay-preload.ts new file mode 100644 index 000000000..98b5dbe16 --- /dev/null +++ b/electron/overlay-indicator/overlay-preload.ts @@ -0,0 +1,13 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('overlayAPI', { + setIgnoreMouseEvents: (ignore: boolean) => { + ipcRenderer.send('overlay-set-ignore-mouse', ignore); + }, + showMainWindow: () => { + ipcRenderer.send('overlay-show-main-window'); + }, + onUpdateContent: (callback: (data: any) => void) => { + ipcRenderer.on('update-content', (event, data) => callback(data)); + }, +}); diff --git a/electron/overlay-indicator/overlay.css b/electron/overlay-indicator/overlay.css new file mode 100644 index 000000000..f800dc946 --- /dev/null +++ b/electron/overlay-indicator/overlay.css @@ -0,0 +1,158 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + overflow: hidden; + background: transparent; + user-select: none; +} + +/* Prevent all auxiliary button interactions */ +body, +body * { + pointer-events: auto; +} + +/* Disable right-click context menu globally */ +body { + -webkit-context-menu: none; +} + +#overlay-container { + display: flex; + align-items: center; + height: 100vh; + padding: 8px; + background-color: rgba(255, 255, 255, 0.95); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + transition: + background-color 0.3s, + color 0.3s; + -webkit-app-region: drag; +} + +@media (prefers-color-scheme: dark) { + #overlay-container { + background-color: rgba(32, 32, 32, 0.95); + color: #e0e0e0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); + } +} + +#content { + flex: 1; + padding: 0 16px; + min-width: 0; + display: flex; + justify-content: center; + align-content: center; +} + +#task-title { + font-size: 16px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 4px; + color: #333; +} + +@media (prefers-color-scheme: dark) { + #task-title { + color: #e0e0e0; + } +} + +#time-display { + font-size: 16px; + font-weight: 600; + font-variant-numeric: tabular-nums; + color: #666; + margin-left: auto; + padding-left: 16px; +} + +@media (prefers-color-scheme: dark) { + #time-display { + color: #aaa; + } +} + +/* Mode-specific colors */ +.mode-pomodoro #time-display { + color: #dc3545; +} + +.mode-focus #time-display { + color: #007bff; +} + +.mode-task #time-display { + color: #28a745; +} + +@media (prefers-color-scheme: dark) { + .mode-pomodoro #time-display { + color: #ff6b7a; + } + + .mode-focus #time-display { + color: #5cb3ff; + } + + .mode-task #time-display { + color: #5dd66f; + } +} + +#show-main { + width: 32px; + height: 32px; + border: none; + background: rgba(0, 0, 0, 0.05); + border-radius: 4px; + cursor: pointer; + color: #666; + font-size: 12px; + transition: all 0.2s; + -webkit-app-region: no-drag; +} + +@media (prefers-color-scheme: dark) { + #show-main { + background: rgba(255, 255, 255, 0.1); + color: #aaa; + } +} + +#show-main:hover { + background: rgba(0, 0, 0, 0.1); + transform: scale(1.1); +} + +@media (prefers-color-scheme: dark) { + #show-main:hover { + background: rgba(255, 255, 255, 0.2); + } +} + +#show-main:active { + transform: scale(0.95); +} + +/* Handle resize cursor on edges */ +body { + resize: both; +} + +/* Make sure content doesn't overflow on resize */ +#overlay-container { + width: 100%; + height: 100%; +} diff --git a/electron/overlay-indicator/overlay.html b/electron/overlay-indicator/overlay.html new file mode 100644 index 000000000..a5494224f --- /dev/null +++ b/electron/overlay-indicator/overlay.html @@ -0,0 +1,26 @@ + + + + + Super Productivity Overlay + + + +
+
+
No active task
+
--:--
+
+ +
+ + + diff --git a/electron/overlay-indicator/overlay.ts b/electron/overlay-indicator/overlay.ts new file mode 100644 index 000000000..d18b30f75 --- /dev/null +++ b/electron/overlay-indicator/overlay.ts @@ -0,0 +1,67 @@ +// Get elements +const showMainBtn = document.getElementById('show-main') as HTMLButtonElement; +const container = document.getElementById('overlay-container') as HTMLDivElement; +const taskTitle = document.getElementById('task-title') as HTMLDivElement; +const timeDisplay = document.getElementById('time-display') as HTMLDivElement; + +// Prevent all right-click events +document.addEventListener( + 'contextmenu', + (e) => { + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + return false; + }, + true, +); + +// Also prevent mousedown for right-click +document.addEventListener( + 'mousedown', + (e) => { + if (e.button === 2) { + // Right mouse button + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + return false; + } + }, + true, +); + +// Prevent mouseup for right-click +document.addEventListener( + 'mouseup', + (e) => { + if (e.button === 2) { + // Right mouse button + e.preventDefault(); + e.stopPropagation(); + e.stopImmediatePropagation(); + return false; + } + }, + true, +); + +// Handle show main button +showMainBtn.addEventListener('click', () => { + window.overlayAPI.showMainWindow(); +}); + +// Listen for content updates +window.overlayAPI.onUpdateContent((data) => { + // Clear existing mode classes + container.classList.remove('mode-pomodoro', 'mode-focus', 'mode-task', 'mode-idle'); + + // Update mode + if (data.mode) { + container.classList.add(`mode-${data.mode}`); + } + + // Update content + taskTitle.textContent = data.title || 'No active task'; + timeDisplay.textContent = data.time || '--:--'; +}); diff --git a/electron/preload.ts b/electron/preload.ts index 0738425b7..32b3a00af 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -83,6 +83,7 @@ const ea: ElectronAPI = { sendAppSettingsToElectron: (globalCfg) => _send('TRANSFER_SETTINGS_TO_ELECTRON', globalCfg), + sendSettingsUpdate: (globalCfg) => _send('UPDATE_SETTINGS', globalCfg), registerGlobalShortcuts: (keyboardCfg) => _send('REGISTER_GLOBAL_SHORTCUTS', keyboardCfg), showFullScreenBlocker: (args) => _send('FULL_SCREEN_BLOCKER', args), @@ -92,8 +93,21 @@ const ea: ElectronAPI = { backupAppData: (appData) => _send('BACKUP', appData), - updateCurrentTask: (task, isPomodoroEnabled, currentPomodoroSessionTime) => - _send('CURRENT_TASK_UPDATED', task, isPomodoroEnabled, currentPomodoroSessionTime), + updateCurrentTask: ( + task, + isPomodoroEnabled, + currentPomodoroSessionTime, + isFocusModeEnabled?, + currentFocusSessionTime?, + ) => + _send( + 'CURRENT_TASK_UPDATED', + task, + isPomodoroEnabled, + currentPomodoroSessionTime, + isFocusModeEnabled, + currentFocusSessionTime, + ), exec: (command: string) => _send('EXEC', command), diff --git a/electron/shared-with-frontend/ipc-events.const.ts b/electron/shared-with-frontend/ipc-events.const.ts index 4bf927c28..1a40a153d 100644 --- a/electron/shared-with-frontend/ipc-events.const.ts +++ b/electron/shared-with-frontend/ipc-events.const.ts @@ -18,6 +18,7 @@ export enum IPC { APP_READY = 'APP_READY', ERROR = 'ELECTRON_ERROR', CURRENT_TASK_UPDATED = 'CURRENT_TASK_UPDATED', + REQUEST_CURRENT_TASK_FOR_OVERLAY = 'REQUEST_CURRENT_TASK_FOR_OVERLAY', TASK_MARK_AS_DONE = 'TASK_MARK_AS_DONE', TASK_START = 'TASK_START', TASK_TOGGLE_START = 'TASK_TOGGLE_START', @@ -72,6 +73,8 @@ export enum IPC { // Plugin Node Execution PLUGIN_EXEC_NODE_SCRIPT = 'PLUGIN_EXEC_NODE_SCRIPT', + UPDATE_SETTINGS = 'UPDATE_SETTINGS', + // maybe_UPDATE_CURRENT_TASK = 'UPDATE_CURRENT_TASK', // maybe_IS_IDLE = 'IS_IDLE', // maybe_IS_BUSY = 'IS_BUSY', diff --git a/electron/start-app.ts b/electron/start-app.ts index 399e9adf8..c122c7b8d 100644 --- a/electron/start-app.ts +++ b/electron/start-app.ts @@ -21,6 +21,7 @@ import { initIndicator } from './indicator'; import { quitApp, showOrFocus } from './various-shared'; import { createWindow } from './main-window'; import { IdleTimeHandler } from './idle-time-handler'; +import { destroyOverlayWindow } from './overlay-indicator/overlay-indicator'; const ICONS_FOLDER = __dirname + '/assets/icons/'; const IS_MAC = process.platform === 'darwin'; @@ -253,6 +254,11 @@ export const startApp = (): void => { globalShortcut.unregisterAll(); }); + appIN.on('before-quit', () => { + // Clean up overlay window before quitting + destroyOverlayWindow(); + }); + appIN.on('window-all-closed', () => { log('Quit after all windows being closed'); // if (!IS_MAC) { diff --git a/electron/various-shared.ts b/electron/various-shared.ts index 60a9b3505..262ddc3d9 100644 --- a/electron/various-shared.ts +++ b/electron/various-shared.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow } from 'electron'; import { info } from 'electron-log/main'; import { getWin } from './main-window'; +import { hideOverlayWindow } from './overlay-indicator/overlay-indicator'; // eslint-disable-next-line prefer-arrow/prefer-arrow-functions export function quitApp(): void { @@ -28,6 +29,9 @@ export function showOrFocus(passedWin: BrowserWindow): void { win.show(); } + // Hide overlay when main window is shown + hideOverlayWindow(); + // focus window afterwards always setTimeout(() => { win.focus(); diff --git a/src/app/features/config/default-global-config.const.ts b/src/app/features/config/default-global-config.const.ts index b79faefc7..d0e345aec 100644 --- a/src/app/features/config/default-global-config.const.ts +++ b/src/app/features/config/default-global-config.const.ts @@ -31,6 +31,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = { **Why do I want it?** `, + isOverlayIndicatorEnabled: false, }, shortSyntax: { isEnableProject: true, diff --git a/src/app/features/config/form-cfgs/misc-settings-form.const.ts b/src/app/features/config/form-cfgs/misc-settings-form.const.ts index 9ee2b98d4..4fa80ea67 100644 --- a/src/app/features/config/form-cfgs/misc-settings-form.const.ts +++ b/src/app/features/config/form-cfgs/misc-settings-form.const.ts @@ -146,5 +146,13 @@ export const MISC_SETTINGS_FORM_CFG: ConfigFormSection = { label: T.GCF.MISC.IS_TRAY_SHOW_CURRENT_COUNTDOWN, }, }, + { + key: 'isOverlayIndicatorEnabled', + type: 'checkbox', + templateOptions: { + label: T.GCF.MISC.IS_OVERLAY_INDICATOR_ENABLED, + }, + hideExpression: (model: any) => !model?.isTrayShowCurrentTask, + }, ], }; diff --git a/src/app/features/config/global-config.model.ts b/src/app/features/config/global-config.model.ts index 630dca347..0ba8f2ac3 100644 --- a/src/app/features/config/global-config.model.ts +++ b/src/app/features/config/global-config.model.ts @@ -22,6 +22,7 @@ export type MiscConfig = Readonly<{ // optional because it was added later isShowTipLonger?: boolean; isTrayShowCurrentCountdown?: boolean; + isOverlayIndicatorEnabled?: boolean; }>; export type ShortSyntaxConfig = Readonly<{ diff --git a/src/app/features/config/store/global-config.effects.ts b/src/app/features/config/store/global-config.effects.ts index 3b88f74c9..7dd68b852 100644 --- a/src/app/features/config/store/global-config.effects.ts +++ b/src/app/features/config/store/global-config.effects.ts @@ -1,6 +1,6 @@ import { inject, Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; -import { filter, pairwise, tap } from 'rxjs/operators'; +import { filter, pairwise, tap, withLatestFrom } from 'rxjs/operators'; import { Store } from '@ngrx/store'; import { IS_ELECTRON, LanguageCode } from '../../../app.constants'; import { T } from '../../../t.const'; @@ -145,4 +145,34 @@ export class GlobalConfigEffects { ), { dispatch: false }, ); + + notifyElectronAboutCfgChange: any = + IS_ELECTRON && + createEffect( + () => + this._actions$.pipe( + ofType(updateGlobalConfigSection), + withLatestFrom(this._store.select('globalConfig')), + tap(([action, globalConfig]) => { + // Send the entire settings object to electron for overlay initialization + window.ea.sendSettingsUpdate(globalConfig); + }), + ), + { dispatch: false }, + ); + + notifyElectronAboutCfgChangeInitially: any = + IS_ELECTRON && + createEffect( + () => + this._actions$.pipe( + ofType(loadAllData), + tap(({ appDataComplete }) => { + const cfg = appDataComplete.globalConfig || DEFAULT_GLOBAL_CONFIG; + // Send initial settings to electron for overlay initialization + window.ea.sendSettingsUpdate(cfg); + }), + ), + { dispatch: false }, + ); } diff --git a/src/app/features/tasks/store/task-electron.effects.ts b/src/app/features/tasks/store/task-electron.effects.ts index aa4ac01e5..5fd9c1199 100644 --- a/src/app/features/tasks/store/task-electron.effects.ts +++ b/src/app/features/tasks/store/task-electron.effects.ts @@ -2,13 +2,23 @@ import { inject, Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { setCurrentTask, unsetCurrentTask } from './task.actions'; import { select, Store } from '@ngrx/store'; -import { filter, tap, withLatestFrom } from 'rxjs/operators'; +import { filter, startWith, take, tap, withLatestFrom } from 'rxjs/operators'; import { selectCurrentTask } from './task.selectors'; -import { IS_ELECTRON } from '../../../app.constants'; import { GlobalConfigService } from '../../config/global-config.service'; import { selectIsFocusOverlayShown } from '../../focus-mode/store/focus-mode.selectors'; import { PomodoroService } from '../../pomodoro/pomodoro.service'; import { TimeTrackingActions } from '../../time-tracking/store/time-tracking.actions'; +import { FocusModeService } from '../../focus-mode/focus-mode.service'; +import { + cancelFocusSession, + focusSessionDone, + hideFocusOverlay, + pauseFocusSession, + showFocusOverlay, + startFocusSession, + unPauseFocusSession, +} from '../../focus-mode/store/focus-mode.actions'; +import { IPC } from '../../../../../electron/shared-with-frontend/ipc-events.const'; // TODO send message to electron when current task changes here @@ -18,71 +28,129 @@ export class TaskElectronEffects { private _store$ = inject>(Store); private _configService = inject(GlobalConfigService); private _pomodoroService = inject(PomodoroService); + private _focusModeService = inject(FocusModeService); - taskChangeElectron$: any = createEffect( - () => - this._actions$.pipe( - ofType(setCurrentTask, unsetCurrentTask, TimeTrackingActions.addTimeSpent), - withLatestFrom(this._store$.pipe(select(selectCurrentTask))), - withLatestFrom( - this._store$.pipe(select(selectCurrentTask)), - this._pomodoroService.isEnabled$, - this._pomodoroService.currentSessionTime$, - ), - tap(([action, current, isPomodoroEnabled, currentPomodoroSessionTime]) => { - if (IS_ELECTRON) { + // ----------------------------------------------------------------------------------- + // NOTE: IS_ELECTRON checks not necessary, since we check before importing this module + // ----------------------------------------------------------------------------------- + + constructor() { + // Listen for overlay request and send current task state + window.ea.on(IPC.REQUEST_CURRENT_TASK_FOR_OVERLAY, () => { + this._store$ + .pipe( + select(selectCurrentTask), + withLatestFrom( + this._pomodoroService.isEnabled$, + this._pomodoroService.currentSessionTime$, + this._store$.pipe(select(selectIsFocusOverlayShown)), + this._focusModeService.currentSessionTime$, + ), + // Only take the first value and complete + take(1), + ) + .subscribe( + ([ + current, + isPomodoroEnabled, + currentPomodoroSessionTime, + isFocusModeEnabled, + currentFocusSessionTime, + ]) => { window.ea.updateCurrentTask( current, isPomodoroEnabled, currentPomodoroSessionTime, + isFocusModeEnabled, + currentFocusSessionTime, ); + }, + ); + }); + } + + taskChangeElectron$: any = createEffect( + () => + this._actions$.pipe( + ofType( + setCurrentTask, + unsetCurrentTask, + TimeTrackingActions.addTimeSpent, + showFocusOverlay, + hideFocusOverlay, + startFocusSession, + cancelFocusSession, + pauseFocusSession, + unPauseFocusSession, + focusSessionDone, + ), + + withLatestFrom( + this._store$.pipe(select(selectCurrentTask)), + this._pomodoroService.isEnabled$, + this._pomodoroService.currentSessionTime$, + this._store$.pipe(select(selectIsFocusOverlayShown)), + this._focusModeService.currentSessionTime$.pipe(startWith(0)), + ), + tap( + ([ + action, + current, + isPomodoroEnabled, + currentPomodoroSessionTime, + isFocusModeEnabled, + currentFocusSessionTime, + ]) => { + window.ea.updateCurrentTask( + current, + isPomodoroEnabled, + currentPomodoroSessionTime, + isFocusModeEnabled, + currentFocusSessionTime, + ); + }, + ), + ), + { dispatch: false }, + ); + + setTaskBarNoProgress$ = createEffect( + () => + this._actions$.pipe( + ofType(setCurrentTask), + tap(({ id }) => { + if (!id) { + window.ea.setProgressBar({ + progress: 0, + progressBarMode: 'pause', + }); } }), ), { dispatch: false }, ); - setTaskBarNoProgress$ = - IS_ELECTRON && - createEffect( - () => - this._actions$.pipe( - ofType(setCurrentTask), - tap(({ id }) => { - if (!id) { - window.ea.setProgressBar({ - progress: 0, - progressBarMode: 'pause', - }); - } - }), + setTaskBarProgress$: any = createEffect( + () => + this._actions$.pipe( + ofType(TimeTrackingActions.addTimeSpent), + withLatestFrom( + this._configService.cfg$, + this._store$.select(selectIsFocusOverlayShown), ), - { dispatch: false }, - ); - - setTaskBarProgress$: any = - IS_ELECTRON && - createEffect( - () => - this._actions$.pipe( - ofType(TimeTrackingActions.addTimeSpent), - withLatestFrom( - this._configService.cfg$, - this._store$.select(selectIsFocusOverlayShown), - ), - // we display pomodoro progress for pomodoro - filter( - ([a, cfg, isFocusSessionRunning]) => - !isFocusSessionRunning && (!cfg || !cfg.pomodoro.isEnabled), - ), - tap(([{ task }]) => { - const progress = task.timeSpent / task.timeEstimate; - window.ea.setProgressBar({ - progress, - progressBarMode: 'normal', - }); - }), + // we display pomodoro progress for pomodoro + filter( + ([a, cfg, isFocusSessionRunning]) => + !isFocusSessionRunning && (!cfg || !cfg.pomodoro.isEnabled), ), - { dispatch: false }, - ); + tap(([{ task }]) => { + const progress = task.timeSpent / task.timeEstimate; + window.ea.setProgressBar({ + progress, + progressBarMode: 'normal', + }); + }), + ), + { dispatch: false }, + ); } diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 5fd24d6be..c2d51bf9e 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1654,6 +1654,7 @@ const T = { IS_DISABLE_ANIMATIONS: 'GCF.MISC.IS_DISABLE_ANIMATIONS', IS_HIDE_NAV: 'GCF.MISC.IS_HIDE_NAV', IS_MINIMIZE_TO_TRAY: 'GCF.MISC.IS_MINIMIZE_TO_TRAY', + IS_OVERLAY_INDICATOR_ENABLED: 'GCF.MISC.IS_OVERLAY_INDICATOR_ENABLED', IS_SHOW_TIP_LONGER: 'GCF.MISC.IS_SHOW_TIP_LONGER', IS_TRAY_SHOW_CURRENT_COUNTDOWN: 'GCF.MISC.IS_TRAY_SHOW_CURRENT_COUNTDOWN', IS_TRAY_SHOW_CURRENT_TASK: 'GCF.MISC.IS_TRAY_SHOW_CURRENT_TASK', diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 46d4b8cbc..53da4aedf 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1631,6 +1631,7 @@ "IS_SHOW_TIP_LONGER": "Show productivity tip on app start a little longer", "IS_TRAY_SHOW_CURRENT_COUNTDOWN": "Show current countdown in the tray / status menu (desktop mac only)", "IS_TRAY_SHOW_CURRENT_TASK": "Show current task in the tray / status menu (desktop mac/windows only)", + "IS_OVERLAY_INDICATOR_ENABLED": "Enable overlay indicator window (desktop linux/gnome)", "IS_TURN_OFF_MARKDOWN": "Turn off markdown parsing for task notes", "IS_USE_MINIMAL_SIDE_NAV": "Use minimal navigation bar (show only icons)", "START_OF_NEXT_DAY": "Start time of the next day",