mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat(android): add optional alarm-style notifications and fix reminder cancellation
- Fix bug where native Android alarms weren't cancelled when reminders were removed - Add cancelNativeReminderOnUnschedule$ effect to cancel alarms on unschedule/dismiss - Add useAlarmStyleReminders setting (default: false) for regular vs alarm notifications - Create separate notification channels for alarm-style and regular reminders - Add IS_ANDROID_WEB_VIEW_TOKEN injection token for testability - Pass useAlarmStyle parameter through JS bridge to native Kotlin layer
This commit is contained in:
parent
74d983a001
commit
566760bd4a
14 changed files with 242 additions and 25 deletions
|
|
@ -21,6 +21,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||
const val EXTRA_RELATED_ID = "related_id"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_REMINDER_TYPE = "reminder_type"
|
||||
const val EXTRA_USE_ALARM_STYLE = "use_alarm_style"
|
||||
|
||||
const val SNOOZE_DURATION_MS = 10 * 60 * 1000L // 10 minutes
|
||||
}
|
||||
|
|
@ -33,6 +34,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
|
||||
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
|
||||
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
|
||||
val useAlarmStyle = intent.getBooleanExtra(EXTRA_USE_ALARM_STYLE, false)
|
||||
|
||||
Log.d(TAG, "Snooze: notificationId=$notificationId, title=$title")
|
||||
|
||||
|
|
@ -50,7 +52,8 @@ class ReminderActionReceiver : BroadcastReceiver() {
|
|||
relatedId,
|
||||
title,
|
||||
reminderType,
|
||||
newTriggerTime
|
||||
newTriggerTime,
|
||||
useAlarmStyle
|
||||
)
|
||||
|
||||
Log.d(TAG, "Rescheduled reminder for ${SNOOZE_DURATION_MS / 60000} minutes from now")
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class ReminderAlarmReceiver : BroadcastReceiver() {
|
|||
const val EXTRA_RELATED_ID = "related_id"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_REMINDER_TYPE = "reminder_type"
|
||||
const val EXTRA_USE_ALARM_STYLE = "use_alarm_style"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
|
|
@ -29,8 +30,9 @@ class ReminderAlarmReceiver : BroadcastReceiver() {
|
|||
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
|
||||
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
|
||||
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
|
||||
val useAlarmStyle = intent.getBooleanExtra(EXTRA_USE_ALARM_STYLE, false)
|
||||
|
||||
Log.d(TAG, "Alarm triggered: id=$notificationId, title=$title")
|
||||
Log.d(TAG, "Alarm triggered: id=$notificationId, title=$title, useAlarmStyle=$useAlarmStyle")
|
||||
|
||||
ReminderNotificationHelper.showNotification(
|
||||
context,
|
||||
|
|
@ -38,7 +40,8 @@ class ReminderAlarmReceiver : BroadcastReceiver() {
|
|||
reminderId,
|
||||
relatedId,
|
||||
title,
|
||||
reminderType
|
||||
reminderType,
|
||||
useAlarmStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,31 +24,54 @@ import com.superproductivity.superproductivity.receiver.ReminderAlarmReceiver
|
|||
*/
|
||||
object ReminderNotificationHelper {
|
||||
const val TAG = "ReminderNotifHelper"
|
||||
const val CHANNEL_ID = "sp_reminders_channel"
|
||||
const val CHANNEL_ID_ALARM = "sp_reminders_channel"
|
||||
const val CHANNEL_ID_REGULAR = "sp_reminders_regular_channel"
|
||||
|
||||
fun createChannel(context: Context) {
|
||||
fun createChannels(context: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
|
||||
// Alarm-style channel (louder, more intrusive)
|
||||
val alarmSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
|
||||
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
val alarmAudioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_ALARM)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
val alarmChannel = NotificationChannel(
|
||||
CHANNEL_ID_ALARM,
|
||||
"Reminders (Alarm)",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Alarm-style task reminders with louder sound"
|
||||
setShowBadge(true)
|
||||
enableVibration(true)
|
||||
vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500)
|
||||
setSound(alarmSound, alarmAudioAttributes)
|
||||
}
|
||||
notificationManager.createNotificationChannel(alarmChannel)
|
||||
|
||||
// Regular notification channel (standard notification sound)
|
||||
val notificationSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
|
||||
|
||||
val notificationAudioAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
|
||||
val regularChannel = NotificationChannel(
|
||||
CHANNEL_ID_REGULAR,
|
||||
"Reminders",
|
||||
NotificationManager.IMPORTANCE_HIGH
|
||||
).apply {
|
||||
description = "Task and note reminders"
|
||||
setShowBadge(true)
|
||||
enableVibration(true)
|
||||
vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500)
|
||||
setSound(alarmSound, audioAttributes)
|
||||
setSound(notificationSound, notificationAudioAttributes)
|
||||
}
|
||||
val notificationManager = context.getSystemService(NotificationManager::class.java)
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
notificationManager.createNotificationChannel(regularChannel)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -59,9 +82,10 @@ object ReminderNotificationHelper {
|
|||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String,
|
||||
triggerAtMs: Long
|
||||
triggerAtMs: Long,
|
||||
useAlarmStyle: Boolean = false
|
||||
) {
|
||||
Log.d(TAG, "Scheduling reminder: id=$notificationId, title=$title")
|
||||
Log.d(TAG, "Scheduling reminder: id=$notificationId, title=$title, useAlarmStyle=$useAlarmStyle")
|
||||
|
||||
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
|
||||
|
||||
|
|
@ -72,6 +96,7 @@ object ReminderNotificationHelper {
|
|||
putExtra(ReminderAlarmReceiver.EXTRA_RELATED_ID, relatedId)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_TITLE, title)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_REMINDER_TYPE, reminderType)
|
||||
putExtra(ReminderAlarmReceiver.EXTRA_USE_ALARM_STYLE, useAlarmStyle)
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getBroadcast(
|
||||
|
|
@ -109,9 +134,12 @@ object ReminderNotificationHelper {
|
|||
reminderId: String,
|
||||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String
|
||||
reminderType: String,
|
||||
useAlarmStyle: Boolean = false
|
||||
) {
|
||||
createChannel(context)
|
||||
createChannels(context)
|
||||
|
||||
val channelId = if (useAlarmStyle) CHANNEL_ID_ALARM else CHANNEL_ID_REGULAR
|
||||
|
||||
// Tapping notification opens app
|
||||
val contentIntent = Intent(context, CapacitorMainActivity::class.java).apply {
|
||||
|
|
@ -130,13 +158,16 @@ object ReminderNotificationHelper {
|
|||
putExtra(ReminderActionReceiver.EXTRA_RELATED_ID, relatedId)
|
||||
putExtra(ReminderActionReceiver.EXTRA_TITLE, title)
|
||||
putExtra(ReminderActionReceiver.EXTRA_REMINDER_TYPE, reminderType)
|
||||
putExtra(ReminderActionReceiver.EXTRA_USE_ALARM_STYLE, useAlarmStyle)
|
||||
}
|
||||
val snoozePendingIntent = PendingIntent.getBroadcast(
|
||||
context, notificationId * 10 + 1, snoozeIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||
val category = if (useAlarmStyle) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_REMINDER
|
||||
|
||||
val notification = NotificationCompat.Builder(context, channelId)
|
||||
.setSmallIcon(R.drawable.ic_stat_sp)
|
||||
.setContentTitle(title)
|
||||
.setContentText(if (reminderType == "TASK") "Task reminder" else "Note reminder")
|
||||
|
|
@ -144,7 +175,7 @@ object ReminderNotificationHelper {
|
|||
.setAutoCancel(true)
|
||||
.addAction(0, "Snooze 10m", snoozePendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_ALARM)
|
||||
.setCategory(category)
|
||||
.build()
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -194,7 +194,8 @@ class JavaScriptInterface(
|
|||
relatedId: String,
|
||||
title: String,
|
||||
reminderType: String,
|
||||
triggerAtMs: Long
|
||||
triggerAtMs: Long,
|
||||
useAlarmStyle: Boolean
|
||||
) {
|
||||
safeCall("Failed to schedule native reminder") {
|
||||
ReminderNotificationHelper.scheduleReminder(
|
||||
|
|
@ -204,7 +205,8 @@ class JavaScriptInterface(
|
|||
relatedId,
|
||||
title,
|
||||
reminderType,
|
||||
triggerAtMs
|
||||
triggerAtMs,
|
||||
useAlarmStyle
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export interface AndroidInterface {
|
|||
title: string,
|
||||
reminderType: string,
|
||||
triggerAtMs: number,
|
||||
useAlarmStyle: boolean,
|
||||
): void;
|
||||
cancelNativeReminder?(notificationId: number): void;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { createEffect } from '@ngrx/effects';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
import { timer } from 'rxjs';
|
||||
import { combineLatest, timer } from 'rxjs';
|
||||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { SnackService } from '../../../core/snack/snack.service';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
|
|
@ -12,6 +12,7 @@ import { TaskService } from '../../tasks/task.service';
|
|||
import { TaskAttachmentService } from '../../tasks/task-attachment/task-attachment.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { selectAllTasksWithReminder } from '../../tasks/store/task.selectors';
|
||||
import { selectReminderConfig } from '../../config/store/global-config.reducer';
|
||||
|
||||
// TODO send message to electron when current task changes here
|
||||
|
||||
|
|
@ -71,8 +72,13 @@ export class AndroidEffects {
|
|||
createEffect(
|
||||
() =>
|
||||
timer(DELAY_SCHEDULE).pipe(
|
||||
switchMap(() => this._store.select(selectAllTasksWithReminder)),
|
||||
tap(async (tasksWithReminders) => {
|
||||
switchMap(() =>
|
||||
combineLatest([
|
||||
this._store.select(selectAllTasksWithReminder),
|
||||
this._store.select(selectReminderConfig),
|
||||
]),
|
||||
),
|
||||
tap(async ([tasksWithReminders, reminderConfig]) => {
|
||||
try {
|
||||
const currentReminderIds = new Set(
|
||||
(tasksWithReminders || []).map((t) => t.id),
|
||||
|
|
@ -111,6 +117,8 @@ export class AndroidEffects {
|
|||
}
|
||||
await this._ensureExactAlarmAccess();
|
||||
|
||||
const useAlarmStyle = reminderConfig.useAlarmStyleReminders ?? false;
|
||||
|
||||
// Schedule each reminder using native Android AlarmManager
|
||||
for (const task of tasksWithReminders) {
|
||||
const id = generateNotificationId(task.id);
|
||||
|
|
@ -124,6 +132,7 @@ export class AndroidEffects {
|
|||
task.title,
|
||||
'TASK',
|
||||
scheduleAt,
|
||||
useAlarmStyle,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
|
|||
countdownDuration: minute * 10,
|
||||
defaultTaskRemindOption: TaskReminderOptionId.AtStart, // The hard-coded default prior to this changeable setting
|
||||
isFocusWindow: false,
|
||||
useAlarmStyleReminders: false,
|
||||
},
|
||||
schedule: {
|
||||
isWorkStartEndEnabled: true,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ConfigFormSection, ReminderConfig } from '../global-config.model';
|
||||
import { TASK_REMINDER_OPTIONS } from '../../planner/dialog-schedule-task/task-reminder-options.const';
|
||||
import { T } from '../../../t.const';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
|
||||
export const REMINDER_FORM_CFG: ConfigFormSection<ReminderConfig> = {
|
||||
title: T.GCF.REMINDER.TITLE,
|
||||
|
|
@ -47,5 +48,17 @@ export const REMINDER_FORM_CFG: ConfigFormSection<ReminderConfig> = {
|
|||
label: T.GCF.REMINDER.IS_FOCUS_WINDOW,
|
||||
},
|
||||
},
|
||||
...(IS_ANDROID_WEB_VIEW
|
||||
? [
|
||||
{
|
||||
key: 'useAlarmStyleReminders' as const,
|
||||
type: 'checkbox',
|
||||
templateOptions: {
|
||||
label: T.GCF.REMINDER.USE_ALARM_STYLE_REMINDERS,
|
||||
description: T.GCF.REMINDER.USE_ALARM_STYLE_REMINDERS_DESCRIPTION,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -180,6 +180,8 @@ export type ReminderConfig = Readonly<{
|
|||
defaultTaskRemindOption?: TaskReminderOptionId;
|
||||
disableReminders?: boolean;
|
||||
isFocusWindow?: boolean;
|
||||
// Android only: use alarm-style notifications (louder, more intrusive)
|
||||
useAlarmStyleReminders?: boolean;
|
||||
}>;
|
||||
|
||||
export type TrackingReminderConfigOld = Readonly<{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'
|
|||
import { DEFAULT_TASK, Task } from '../task.model';
|
||||
import { TestScheduler } from 'rxjs/testing';
|
||||
import { T } from '../../../t.const';
|
||||
import { IS_ANDROID_WEB_VIEW_TOKEN } from '../../../util/is-android-web-view';
|
||||
|
||||
describe('TaskReminderEffects', () => {
|
||||
let actions$: Observable<Action>;
|
||||
|
|
@ -47,6 +48,7 @@ describe('TaskReminderEffects', () => {
|
|||
{ provide: TaskService, useValue: taskServiceSpy },
|
||||
{ provide: Store, useValue: storeSpy },
|
||||
{ provide: LocaleDatePipe, useValue: datePipeSpy },
|
||||
{ provide: IS_ANDROID_WEB_VIEW_TOKEN, useValue: false },
|
||||
],
|
||||
});
|
||||
|
||||
|
|
@ -298,3 +300,111 @@ describe('TaskReminderEffects', () => {
|
|||
// NOTE: Tests for removeTaskReminderTrigger1$ were removed because the effect no longer exists.
|
||||
// The reminder removal is now handled differently in the task-shared scheduling meta-reducer.
|
||||
});
|
||||
|
||||
// NOTE: Full Android integration tests for cancelNativeReminderOnUnschedule$ require
|
||||
// androidInterface to be initialized (only happens in Android WebView).
|
||||
// These tests verify the filter behavior with the injection token.
|
||||
|
||||
describe('TaskReminderEffects - cancelNativeReminderOnUnschedule$ filter', () => {
|
||||
let actions$: Observable<Action>;
|
||||
let effects: TaskReminderEffects;
|
||||
|
||||
describe('when IS_ANDROID_WEB_VIEW_TOKEN is true', () => {
|
||||
beforeEach(() => {
|
||||
const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
|
||||
const taskServiceSpy = jasmine.createSpyObj('TaskService', ['getByIdOnce$']);
|
||||
const storeSpy = jasmine.createSpyObj('Store', ['dispatch']);
|
||||
const datePipeSpy = jasmine.createSpyObj('LocaleDatePipe', ['transform']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TaskReminderEffects,
|
||||
provideMockActions(() => actions$),
|
||||
{ provide: SnackService, useValue: snackServiceSpy },
|
||||
{ provide: TaskService, useValue: taskServiceSpy },
|
||||
{ provide: Store, useValue: storeSpy },
|
||||
{ provide: LocaleDatePipe, useValue: datePipeSpy },
|
||||
{ provide: IS_ANDROID_WEB_VIEW_TOKEN, useValue: true },
|
||||
],
|
||||
});
|
||||
|
||||
effects = TestBed.inject(TaskReminderEffects);
|
||||
});
|
||||
|
||||
it('should pass through unscheduleTask action when on Android', (done) => {
|
||||
const action = TaskSharedActions.unscheduleTask({ id: 'task-1' });
|
||||
actions$ = of(action);
|
||||
|
||||
// Verify the effect emits (filter passes)
|
||||
effects.cancelNativeReminderOnUnschedule$.subscribe({
|
||||
next: () => {
|
||||
// Action passed through the filter - this is the expected behavior
|
||||
expect(true).toBe(true);
|
||||
done();
|
||||
},
|
||||
error: () => {
|
||||
// The tap may throw because androidInterface is undefined,
|
||||
// but the filter still passed, which is what we're testing
|
||||
expect(true).toBe(true);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass through dismissReminderOnly action when on Android', (done) => {
|
||||
const action = TaskSharedActions.dismissReminderOnly({ id: 'task-1' });
|
||||
actions$ = of(action);
|
||||
|
||||
effects.cancelNativeReminderOnUnschedule$.subscribe({
|
||||
next: () => {
|
||||
expect(true).toBe(true);
|
||||
done();
|
||||
},
|
||||
error: () => {
|
||||
expect(true).toBe(true);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when IS_ANDROID_WEB_VIEW_TOKEN is false', () => {
|
||||
beforeEach(() => {
|
||||
const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
|
||||
const taskServiceSpy = jasmine.createSpyObj('TaskService', ['getByIdOnce$']);
|
||||
const storeSpy = jasmine.createSpyObj('Store', ['dispatch']);
|
||||
const datePipeSpy = jasmine.createSpyObj('LocaleDatePipe', ['transform']);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
TaskReminderEffects,
|
||||
provideMockActions(() => actions$),
|
||||
{ provide: SnackService, useValue: snackServiceSpy },
|
||||
{ provide: TaskService, useValue: taskServiceSpy },
|
||||
{ provide: Store, useValue: storeSpy },
|
||||
{ provide: LocaleDatePipe, useValue: datePipeSpy },
|
||||
{ provide: IS_ANDROID_WEB_VIEW_TOKEN, useValue: false },
|
||||
],
|
||||
});
|
||||
|
||||
effects = TestBed.inject(TaskReminderEffects);
|
||||
});
|
||||
|
||||
it('should filter out actions when not on Android', (done) => {
|
||||
const action = TaskSharedActions.unscheduleTask({ id: 'task-1' });
|
||||
actions$ = of(action);
|
||||
|
||||
let emitted = false;
|
||||
effects.cancelNativeReminderOnUnschedule$.subscribe({
|
||||
next: () => {
|
||||
emitted = true;
|
||||
},
|
||||
complete: () => {
|
||||
// Filter should block the action, so nothing should emit
|
||||
expect(emitted).toBe(false);
|
||||
done();
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import { SnackService } from '../../../core/snack/snack.service';
|
|||
import { TaskService } from '../task.service';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import {
|
||||
IS_ANDROID_WEB_VIEW,
|
||||
IS_ANDROID_WEB_VIEW_TOKEN,
|
||||
} from '../../../util/is-android-web-view';
|
||||
import { androidInterface } from '../../android/android-interface';
|
||||
import { generateNotificationId } from '../../android/android-notification-id.util';
|
||||
|
||||
|
|
@ -20,6 +23,7 @@ export class TaskReminderEffects {
|
|||
private _taskService = inject(TaskService);
|
||||
private _store = inject(Store);
|
||||
private _datePipe = inject(LocaleDatePipe);
|
||||
private _isAndroidWebView = inject(IS_ANDROID_WEB_VIEW_TOKEN);
|
||||
|
||||
snack$ = createEffect(
|
||||
() =>
|
||||
|
|
@ -132,6 +136,25 @@ export class TaskReminderEffects {
|
|||
{ dispatch: false },
|
||||
);
|
||||
|
||||
// Cancel native Android reminders when reminder is removed or dismissed
|
||||
// Uses injection token with filter for testability (unlike other Android effects)
|
||||
cancelNativeReminderOnUnschedule$ = createEffect(
|
||||
() =>
|
||||
this._localActions$.pipe(
|
||||
ofType(TaskSharedActions.unscheduleTask, TaskSharedActions.dismissReminderOnly),
|
||||
filter(() => this._isAndroidWebView),
|
||||
tap(({ id }) => {
|
||||
try {
|
||||
const notificationId = generateNotificationId(id);
|
||||
androidInterface.cancelNativeReminder?.(notificationId);
|
||||
} catch (e) {
|
||||
console.error('Failed to cancel native reminder:', e);
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ dispatch: false },
|
||||
);
|
||||
|
||||
// Cancel native Android reminders when tasks are deleted
|
||||
cancelNativeRemindersOnDelete$ =
|
||||
IS_ANDROID_WEB_VIEW &&
|
||||
|
|
|
|||
|
|
@ -1865,6 +1865,9 @@ const T = {
|
|||
IS_COUNTDOWN_BANNER_ENABLED: 'GCF.REMINDER.IS_COUNTDOWN_BANNER_ENABLED',
|
||||
IS_FOCUS_WINDOW: 'GCF.REMINDER.IS_FOCUS_WINDOW',
|
||||
TITLE: 'GCF.REMINDER.TITLE',
|
||||
USE_ALARM_STYLE_REMINDERS: 'GCF.REMINDER.USE_ALARM_STYLE_REMINDERS',
|
||||
USE_ALARM_STYLE_REMINDERS_DESCRIPTION:
|
||||
'GCF.REMINDER.USE_ALARM_STYLE_REMINDERS_DESCRIPTION',
|
||||
},
|
||||
SCHEDULE: {
|
||||
HELP: 'GCF.SCHEDULE.HELP',
|
||||
|
|
|
|||
|
|
@ -1,2 +1,16 @@
|
|||
import { InjectionToken } from '@angular/core';
|
||||
|
||||
export const IS_ANDROID_WEB_VIEW = !!(window as any).SUPAndroid;
|
||||
export const IS_F_DROID_APP = !!(window as any).SUPFDroid;
|
||||
|
||||
/**
|
||||
* Injection token for IS_ANDROID_WEB_VIEW to enable testing.
|
||||
* Use this in effects/services that need to be unit tested.
|
||||
*/
|
||||
export const IS_ANDROID_WEB_VIEW_TOKEN = new InjectionToken<boolean>(
|
||||
'IS_ANDROID_WEB_VIEW',
|
||||
{
|
||||
providedIn: 'root',
|
||||
factory: () => IS_ANDROID_WEB_VIEW,
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1859,7 +1859,9 @@
|
|||
"DISABLE_REMINDERS": "Disable all reminders",
|
||||
"IS_COUNTDOWN_BANNER_ENABLED": "Show countdown banner before reminders are due",
|
||||
"IS_FOCUS_WINDOW": "Focus application window when reminder is triggered",
|
||||
"TITLE": "Reminders"
|
||||
"TITLE": "Reminders",
|
||||
"USE_ALARM_STYLE_REMINDERS": "Use alarm-style notifications (Android)",
|
||||
"USE_ALARM_STYLE_REMINDERS_DESCRIPTION": "When enabled, reminders use louder alarm sounds and more intrusive notifications. Recommended for critical reminders."
|
||||
},
|
||||
"SCHEDULE": {
|
||||
"HELP": "The schedule feature should provide you with a quick overview over how your planned tasks play out over time. You can find it in the left hand menu under <a href='#/schedule'>'Schedule'</a>. ",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue