diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt b/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt index a5d490ad7..c49a393e7 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/service/FocusModeForegroundService.kt @@ -86,10 +86,22 @@ class FocusModeForegroundService : Service() { } ACTION_UPDATE -> { + val wasPaused = isPaused + title = intent.getStringExtra(EXTRA_TITLE) ?: title remainingMs = intent.getLongExtra(EXTRA_REMAINING_MS, remainingMs) isPaused = intent.getBooleanExtra(EXTRA_IS_PAUSED, isPaused) + isBreak = intent.getBooleanExtra(EXTRA_IS_BREAK, isBreak) taskTitle = intent.getStringExtra(EXTRA_TASK_TITLE) ?: taskTitle lastUpdateTimestamp = System.currentTimeMillis() + + // Restart update runnable if resuming from paused state + if (wasPaused && !isPaused) { + handler.removeCallbacks(updateRunnable) + handler.post(updateRunnable) + } else if (!wasPaused && isPaused) { + handler.removeCallbacks(updateRunnable) + } + updateNotification() } diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt index 9330309b6..c8b1e4800 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt @@ -153,11 +153,13 @@ class JavaScriptInterface( @Suppress("unused") @JavascriptInterface - fun updateFocusModeService(remainingMs: Long, isPaused: Boolean, taskTitle: String?) { + fun updateFocusModeService(title: String, remainingMs: Long, isPaused: Boolean, isBreak: Boolean, taskTitle: String?) { val intent = Intent(activity, FocusModeForegroundService::class.java).apply { action = FocusModeForegroundService.ACTION_UPDATE + putExtra(FocusModeForegroundService.EXTRA_TITLE, title) putExtra(FocusModeForegroundService.EXTRA_REMAINING_MS, remainingMs) putExtra(FocusModeForegroundService.EXTRA_IS_PAUSED, isPaused) + putExtra(FocusModeForegroundService.EXTRA_IS_BREAK, isBreak) putExtra(FocusModeForegroundService.EXTRA_TASK_TITLE, taskTitle) } activity.startService(intent) diff --git a/src/app/features/android/android-interface.ts b/src/app/features/android/android-interface.ts index 4e67216f1..2d8fbe15e 100644 --- a/src/app/features/android/android-interface.ts +++ b/src/app/features/android/android-interface.ts @@ -55,8 +55,10 @@ export interface AndroidInterface { ): void; stopFocusModeService?(): void; updateFocusModeService?( + title: string, remainingMs: number, isPaused: boolean, + isBreak: boolean, taskTitle: string | null, ): void; diff --git a/src/app/features/android/store/android-focus-mode.effects.ts b/src/app/features/android/store/android-focus-mode.effects.ts index 23b151ae1..43c96c6d2 100644 --- a/src/app/features/android/store/android-focus-mode.effects.ts +++ b/src/app/features/android/store/android-focus-mode.effects.ts @@ -8,6 +8,7 @@ import { selectIsBreakActive, selectIsLongBreak, selectMode, + selectPausedTaskId, selectTimeRemaining, selectTimer, } from '../../focus-mode/store/focus-mode.selectors'; @@ -87,12 +88,16 @@ export class AndroidFocusModeEffects { } else if (this._hasStateChanged(prev?.timer, timer, taskTitle, curr)) { // Only update if something significant changed DroidLog.log('AndroidFocusModeEffects: Updating focus mode service', { + title, remaining: remainingMs, isPaused: !timer.isRunning, + isBreak: isBreakActive, }); androidInterface.updateFocusModeService?.( + title, remainingMs, !timer.isRunning, + isBreakActive, taskTitle, ); } @@ -133,7 +138,8 @@ export class AndroidFocusModeEffects { createEffect(() => androidInterface.onFocusSkip$.pipe( tap(() => DroidLog.log('AndroidFocusModeEffects: Skip action received')), - map(() => focusModeActions.skipBreak()), + withLatestFrom(this._store.select(selectPausedTaskId)), + map(([_, pausedTaskId]) => focusModeActions.skipBreak({ pausedTaskId })), ), ); diff --git a/src/app/features/android/store/android.effects.ts b/src/app/features/android/store/android.effects.ts index 13997b824..6806d1e90 100644 --- a/src/app/features/android/store/android.effects.ts +++ b/src/app/features/android/store/android.effects.ts @@ -23,9 +23,11 @@ export class AndroidEffects { private _snackService = inject(SnackService); private _taskService = inject(TaskService); private _taskAttachmentService = inject(TaskAttachmentService); - // Single-shot guard so we don’t spam the user with duplicate warnings. + // Single-shot guard so we don't spam the user with duplicate warnings. private _hasShownNotificationWarning = false; private _hasCheckedExactAlarm = false; + // Track scheduled reminder IDs to cancel removed ones + private _scheduledReminderIds = new Set(); askPermissionsIfNotGiven$ = IS_ANDROID_WEB_VIEW && @@ -64,7 +66,24 @@ export class AndroidEffects { switchMap(() => this._reminderService.reminders$), tap(async (reminders) => { try { + const currentReminderIds = new Set( + (reminders || []).map((r) => r.relatedId), + ); + + // Cancel reminders that are no longer in the list + for (const previousId of this._scheduledReminderIds) { + if (!currentReminderIds.has(previousId)) { + const notificationId = generateNotificationId(previousId); + DroidLog.log('AndroidEffects: cancelling removed reminder', { + relatedId: previousId, + notificationId, + }); + androidInterface.cancelNativeReminder?.(notificationId); + } + } + if (!reminders || reminders.length === 0) { + this._scheduledReminderIds.clear(); return; } DroidLog.log('AndroidEffects: scheduling reminders natively', { @@ -101,6 +120,9 @@ export class AndroidEffects { ); } + // Update tracked IDs + this._scheduledReminderIds = currentReminderIds; + DroidLog.log('AndroidEffects: scheduled native reminders', { reminderCount: reminders.length, }); diff --git a/src/app/features/focus-mode/focus-mode-break/focus-mode-break.component.spec.ts b/src/app/features/focus-mode/focus-mode-break/focus-mode-break.component.spec.ts index c36ce0022..f6ff779b7 100644 --- a/src/app/features/focus-mode/focus-mode-break/focus-mode-break.component.spec.ts +++ b/src/app/features/focus-mode/focus-mode-break/focus-mode-break.component.spec.ts @@ -10,6 +10,7 @@ import { Signal, } from '@angular/core'; import { T } from '../../../t.const'; +import { of } from 'rxjs'; describe('FocusModeBreakComponent', () => { let component: FocusModeBreakComponent; @@ -20,9 +21,11 @@ describe('FocusModeBreakComponent', () => { isBreakLong: Signal; }; let environmentInjector: EnvironmentInjector; + const mockPausedTaskId = 'test-task-id'; beforeEach(() => { - mockStore = jasmine.createSpyObj('Store', ['dispatch']); + mockStore = jasmine.createSpyObj('Store', ['dispatch', 'select']); + mockStore.select.and.returnValue(of(mockPausedTaskId)); mockFocusModeService = { timeRemaining: signal(300000), @@ -78,18 +81,22 @@ describe('FocusModeBreakComponent', () => { }); describe('skipBreak', () => { - it('should dispatch skipBreak action', () => { + it('should dispatch skipBreak action with pausedTaskId', () => { component.skipBreak(); - expect(mockStore.dispatch).toHaveBeenCalledWith(skipBreak()); + expect(mockStore.dispatch).toHaveBeenCalledWith( + skipBreak({ pausedTaskId: mockPausedTaskId }), + ); }); }); describe('completeBreak', () => { - it('should dispatch completeBreak action', () => { + it('should dispatch completeBreak action with pausedTaskId', () => { component.completeBreak(); - expect(mockStore.dispatch).toHaveBeenCalledWith(completeBreak()); + expect(mockStore.dispatch).toHaveBeenCalledWith( + completeBreak({ pausedTaskId: mockPausedTaskId }), + ); }); }); }); diff --git a/src/app/features/focus-mode/focus-mode-break/focus-mode-break.component.ts b/src/app/features/focus-mode/focus-mode-break/focus-mode-break.component.ts index e3655dbd7..c3a110310 100644 --- a/src/app/features/focus-mode/focus-mode-break/focus-mode-break.component.ts +++ b/src/app/features/focus-mode/focus-mode-break/focus-mode-break.component.ts @@ -5,10 +5,12 @@ import { FocusModeService } from '../focus-mode.service'; import { MsToClockStringPipe } from '../../../ui/duration/ms-to-clock-string.pipe'; import { Store } from '@ngrx/store'; import { completeBreak, skipBreak } from '../store/focus-mode.actions'; +import { selectPausedTaskId } from '../store/focus-mode.selectors'; import { MatIcon } from '@angular/material/icon'; import { T } from '../../../t.const'; import { TranslatePipe } from '@ngx-translate/core'; import { TaskTrackingInfoComponent } from '../task-tracking-info/task-tracking-info.component'; +import { toSignal } from '@angular/core/rxjs-interop'; @Component({ selector: 'focus-mode-break', @@ -30,6 +32,9 @@ export class FocusModeBreakComponent { private readonly _store = inject(Store); T: typeof T = T; + // Get pausedTaskId before break ends (passed in action to avoid race condition) + private readonly _pausedTaskId = toSignal(this._store.select(selectPausedTaskId)); + readonly remainingTime = computed(() => { return this.focusModeService.timeRemaining() || 0; }); @@ -45,10 +50,10 @@ export class FocusModeBreakComponent { ); skipBreak(): void { - this._store.dispatch(skipBreak()); + this._store.dispatch(skipBreak({ pausedTaskId: this._pausedTaskId() })); } completeBreak(): void { - this._store.dispatch(completeBreak()); + this._store.dispatch(completeBreak({ pausedTaskId: this._pausedTaskId() })); } } diff --git a/src/app/features/focus-mode/store/focus-mode.actions.ts b/src/app/features/focus-mode/store/focus-mode.actions.ts index aba65e768..d4b69ca5f 100644 --- a/src/app/features/focus-mode/store/focus-mode.actions.ts +++ b/src/app/features/focus-mode/store/focus-mode.actions.ts @@ -42,8 +42,14 @@ export const startBreak = createAction( '[FocusMode] Start Break', props<{ duration?: number; isLongBreak?: boolean; pausedTaskId?: string | null }>(), ); -export const skipBreak = createAction('[FocusMode] Skip Break'); -export const completeBreak = createAction('[FocusMode] Complete Break'); +export const skipBreak = createAction( + '[FocusMode] Skip Break', + props<{ pausedTaskId?: string | null }>(), +); +export const completeBreak = createAction( + '[FocusMode] Complete Break', + props<{ pausedTaskId?: string | null }>(), +); export const incrementCycle = createAction('[FocusMode] Next Cycle'); export const resetCycles = createAction('[FocusMode] Reset Cycles'); diff --git a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts index 54dc67f13..b0cbd98e9 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.spec.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.spec.ts @@ -12,7 +12,7 @@ import { FocusModeStorageService } from '../focus-mode-storage.service'; import * as actions from './focus-mode.actions'; import * as selectors from './focus-mode.selectors'; import { FocusModeMode, FocusScreen, TimerState } from '../focus-mode.model'; -import { unsetCurrentTask } from '../../tasks/store/task.actions'; +import { unsetCurrentTask, setCurrentTask } from '../../tasks/store/task.actions'; import { openIdleDialog } from '../../idle/store/idle.actions'; import { selectTaskById } from '../../tasks/store/task.selectors'; import { @@ -424,7 +424,7 @@ describe('FocusModeEffects', () => { describe('breakComplete$', () => { it('should dispatch startFocusSession when strategy.shouldAutoStartNextSession is true', (done) => { - actions$ = of(actions.completeBreak()); + actions$ = of(actions.completeBreak({ pausedTaskId: null })); store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro); store.refreshState(); @@ -435,7 +435,7 @@ describe('FocusModeEffects', () => { }); it('should NOT dispatch startFocusSession when shouldAutoStartNextSession is false', (done) => { - actions$ = of(actions.completeBreak()); + actions$ = of(actions.completeBreak({ pausedTaskId: null })); store.overrideSelector(selectors.selectMode, FocusModeMode.Countdown); store.refreshState(); @@ -455,11 +455,30 @@ describe('FocusModeEffects', () => { }, }); }); + + it('should dispatch setCurrentTask when pausedTaskId is provided', (done) => { + const pausedTaskId = 'test-paused-task-id'; + actions$ = of(actions.completeBreak({ pausedTaskId })); + store.overrideSelector(selectors.selectMode, FocusModeMode.Countdown); + store.refreshState(); + + strategyFactoryMock.getStrategy.and.returnValue({ + initialSessionDuration: 25 * 60 * 1000, + shouldStartBreakAfterSession: false, + shouldAutoStartNextSession: false, + getBreakDuration: () => null, + }); + + effects.breakComplete$.pipe(take(1)).subscribe((action) => { + expect(action).toEqual(setCurrentTask({ id: pausedTaskId })); + done(); + }); + }); }); describe('skipBreak$', () => { it('should dispatch startFocusSession when strategy.shouldAutoStartNextSession is true', (done) => { - actions$ = of(actions.skipBreak()); + actions$ = of(actions.skipBreak({ pausedTaskId: null })); store.overrideSelector(selectors.selectMode, FocusModeMode.Pomodoro); store.refreshState(); @@ -470,7 +489,7 @@ describe('FocusModeEffects', () => { }); it('should NOT dispatch startFocusSession when shouldAutoStartNextSession is false', (done) => { - actions$ = of(actions.skipBreak()); + actions$ = of(actions.skipBreak({ pausedTaskId: null })); store.overrideSelector(selectors.selectMode, FocusModeMode.Countdown); store.refreshState(); @@ -490,6 +509,25 @@ describe('FocusModeEffects', () => { }, }); }); + + it('should dispatch setCurrentTask when pausedTaskId is provided', (done) => { + const pausedTaskId = 'test-paused-task-id'; + actions$ = of(actions.skipBreak({ pausedTaskId })); + store.overrideSelector(selectors.selectMode, FocusModeMode.Countdown); + store.refreshState(); + + strategyFactoryMock.getStrategy.and.returnValue({ + initialSessionDuration: 25 * 60 * 1000, + shouldStartBreakAfterSession: false, + shouldAutoStartNextSession: false, + getBreakDuration: () => null, + }); + + effects.skipBreak$.pipe(take(1)).subscribe((action) => { + expect(action).toEqual(setCurrentTask({ id: pausedTaskId })); + done(); + }); + }); }); describe('cancelSession$', () => { diff --git a/src/app/features/focus-mode/store/focus-mode.effects.ts b/src/app/features/focus-mode/store/focus-mode.effects.ts index ddccf8782..b814db6d8 100644 --- a/src/app/features/focus-mode/store/focus-mode.effects.ts +++ b/src/app/features/focus-mode/store/focus-mode.effects.ts @@ -304,14 +304,13 @@ export class FocusModeEffects { ); // Handle break completion + // Note: pausedTaskId is passed in action payload to avoid race condition + // (reducer clears pausedTaskId before effect reads state) breakComplete$ = createEffect(() => this.actions$.pipe( ofType(actions.completeBreak), - withLatestFrom( - this.store.select(selectors.selectMode), - this.store.select(selectors.selectPausedTaskId), - ), - switchMap(([_, mode, pausedTaskId]) => { + withLatestFrom(this.store.select(selectors.selectMode)), + switchMap(([action, mode]) => { const strategy = this.strategyFactory.getStrategy(mode); const actionsToDispatch: any[] = []; @@ -319,8 +318,8 @@ export class FocusModeEffects { this._notifyUser(); // Resume task tracking if we paused it during break - if (pausedTaskId) { - actionsToDispatch.push(setCurrentTask({ id: pausedTaskId })); + if (action.pausedTaskId) { + actionsToDispatch.push(setCurrentTask({ id: action.pausedTaskId })); } // Auto-start next session if configured @@ -335,20 +334,18 @@ export class FocusModeEffects { ); // Handle skip break + // Note: pausedTaskId is passed in action payload to avoid race condition skipBreak$ = createEffect(() => this.actions$.pipe( ofType(actions.skipBreak), - withLatestFrom( - this.store.select(selectors.selectMode), - this.store.select(selectors.selectPausedTaskId), - ), - switchMap(([_, mode, pausedTaskId]) => { + withLatestFrom(this.store.select(selectors.selectMode)), + switchMap(([action, mode]) => { const strategy = this.strategyFactory.getStrategy(mode); const actionsToDispatch: any[] = []; // Resume task tracking if we paused it during break - if (pausedTaskId) { - actionsToDispatch.push(setCurrentTask({ id: pausedTaskId })); + if (action.pausedTaskId) { + actionsToDispatch.push(setCurrentTask({ id: action.pausedTaskId })); } // Auto-start next session if configured @@ -701,18 +698,39 @@ export class FocusModeEffects { label: T.F.FOCUS_MODE.B.START, icon: 'play_arrow', fn: () => { - // Start a new session using the current mode's strategy - this.store - .select(selectors.selectMode) - .pipe(take(1)) - .subscribe((mode) => { - const strategy = this.strategyFactory.getStrategy(mode); - this.store.dispatch( - actions.startFocusSession({ - duration: strategy.initialSessionDuration, - }), - ); - }); + // When starting from break completion, first properly complete/skip the break + // to resume task tracking and clean up state + if (isBreakTimeUp) { + combineLatest([ + this.store.select(selectors.selectMode), + this.store.select(selectors.selectPausedTaskId), + ]) + .pipe(take(1)) + .subscribe(([mode, pausedTaskId]) => { + // Skip break (with pausedTaskId to resume tracking) + this.store.dispatch(actions.skipBreak({ pausedTaskId })); + // Then start new session + const strategy = this.strategyFactory.getStrategy(mode); + this.store.dispatch( + actions.startFocusSession({ + duration: strategy.initialSessionDuration, + }), + ); + }); + } else { + // Start a new session using the current mode's strategy + this.store + .select(selectors.selectMode) + .pipe(take(1)) + .subscribe((mode) => { + const strategy = this.strategyFactory.getStrategy(mode); + this.store.dispatch( + actions.startFocusSession({ + duration: strategy.initialSessionDuration, + }), + ); + }); + } }, } : { diff --git a/src/app/features/focus-mode/store/focus-mode.reducer.spec.ts b/src/app/features/focus-mode/store/focus-mode.reducer.spec.ts index 283575940..aa2f67dc1 100644 --- a/src/app/features/focus-mode/store/focus-mode.reducer.spec.ts +++ b/src/app/features/focus-mode/store/focus-mode.reducer.spec.ts @@ -354,7 +354,7 @@ describe('FocusModeReducer', () => { currentScreen: FocusScreen.Break, }; - const action = a.skipBreak(); + const action = a.skipBreak({ pausedTaskId: null }); const result = focusModeReducer(breakState, action); expect(result.currentScreen).toBe(FocusScreen.Main); @@ -376,7 +376,7 @@ describe('FocusModeReducer', () => { currentScreen: FocusScreen.Break, }; - const action = a.completeBreak(); + const action = a.completeBreak({ pausedTaskId: null }); const result = focusModeReducer(breakState, action); expect(result.currentScreen).toBe(FocusScreen.Main); diff --git a/src/app/pages/config-page/config-page.component.ts b/src/app/pages/config-page/config-page.component.ts index fcca97aa7..5cdef6bf0 100644 --- a/src/app/pages/config-page/config-page.component.ts +++ b/src/app/pages/config-page/config-page.component.ts @@ -230,7 +230,7 @@ export class ConfigPageComponent implements OnInit, OnDestroy { ) { this._snackService.open({ type: 'ERROR', - msg: 'Please fill in all WebDAV fields first', + msg: T.F.SYNC.FORM.WEB_DAV.S_FILL_ALL_FIELDS, }); return; } diff --git a/src/app/t.const.ts b/src/app/t.const.ts index 1598eee94..ce7af588a 100644 --- a/src/app/t.const.ts +++ b/src/app/t.const.ts @@ -1166,6 +1166,7 @@ const T = { L_SYNC_FOLDER_PATH: 'F.SYNC.FORM.WEB_DAV.L_SYNC_FOLDER_PATH', L_TEST_CONNECTION: 'F.SYNC.FORM.WEB_DAV.L_TEST_CONNECTION', L_USER_NAME: 'F.SYNC.FORM.WEB_DAV.L_USER_NAME', + S_FILL_ALL_FIELDS: 'F.SYNC.FORM.WEB_DAV.S_FILL_ALL_FIELDS', S_TEST_FAIL: 'F.SYNC.FORM.WEB_DAV.S_TEST_FAIL', S_TEST_SUCCESS: 'F.SYNC.FORM.WEB_DAV.S_TEST_SUCCESS', }, diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 360d3f215..0b0534ee6 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1148,6 +1148,7 @@ "L_SYNC_FOLDER_PATH": "Sync Folder Path", "L_TEST_CONNECTION": "Test Connection", "L_USER_NAME": "Username", + "S_FILL_ALL_FIELDS": "Please fill in all WebDAV fields first", "S_TEST_FAIL": "Connection test failed: {{error}} - Target URL: {{url}}", "S_TEST_SUCCESS": "Connection test successful! Target URL: {{url}}" }