mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-01-23 02:36:05 +00:00
feat(ios): add notification actions and iPad optimizations
iOS Notification Actions: - Register Snooze/Done action buttons for reminder notifications - Handle snooze action (reschedule reminder +10 minutes) - Handle done action (dismiss reminder only) iPad Optimizations: - Add isIPad() detection in platform service - Add isIPad body class for CSS targeting - Wider content area on iPad (900px, 1000px landscape) - Split-view support with narrow viewport detection - Wider dialogs in landscape mode
This commit is contained in:
parent
1f06a54f11
commit
02da3c283a
7 changed files with 257 additions and 3 deletions
|
|
@ -49,6 +49,7 @@ export enum BodyClass {
|
|||
|
||||
// iOS-specific classes
|
||||
isIOS = 'isIOS',
|
||||
isIPad = 'isIPad',
|
||||
isNativeMobile = 'isNativeMobile',
|
||||
isKeyboardVisible = 'isKeyboardVisible',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,25 @@
|
|||
import { inject, Injectable } from '@angular/core';
|
||||
import { LocalNotifications, ScheduleOptions } from '@capacitor/local-notifications';
|
||||
import {
|
||||
ActionPerformed,
|
||||
LocalNotifications,
|
||||
ScheduleOptions,
|
||||
} from '@capacitor/local-notifications';
|
||||
import { Log } from '../log';
|
||||
import { CapacitorPlatformService } from './capacitor-platform.service';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* Notification action IDs for reminder notifications
|
||||
*/
|
||||
export const NOTIFICATION_ACTION = {
|
||||
SNOOZE: 'snooze',
|
||||
DONE: 'done',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Action type ID for reminder notifications with action buttons
|
||||
*/
|
||||
export const REMINDER_ACTION_TYPE_ID = 'reminder-actions';
|
||||
|
||||
export interface ScheduleNotificationOptions {
|
||||
id: number;
|
||||
|
|
@ -19,6 +37,16 @@ export interface ScheduleNotificationOptions {
|
|||
* Whether to allow notification when device is idle (Android)
|
||||
*/
|
||||
allowWhileIdle?: boolean;
|
||||
/**
|
||||
* Action type ID for notification actions (iOS)
|
||||
*/
|
||||
actionTypeId?: string;
|
||||
}
|
||||
|
||||
export interface NotificationActionEvent {
|
||||
actionId: string;
|
||||
notificationId: number;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -32,6 +60,12 @@ export interface ScheduleNotificationOptions {
|
|||
})
|
||||
export class CapacitorNotificationService {
|
||||
private _platformService = inject(CapacitorPlatformService);
|
||||
private _actionsRegistered = false;
|
||||
|
||||
/**
|
||||
* Subject that emits when a notification action is performed
|
||||
*/
|
||||
readonly action$ = new Subject<NotificationActionEvent>();
|
||||
|
||||
/**
|
||||
* Check if notifications are available on this platform
|
||||
|
|
@ -40,6 +74,60 @@ export class CapacitorNotificationService {
|
|||
return this._platformService.capabilities.scheduledNotifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register action types for reminder notifications (iOS).
|
||||
* Call this once during app initialization.
|
||||
*/
|
||||
async registerReminderActions(): Promise<void> {
|
||||
if (!this.isAvailable || this._actionsRegistered) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Register action types with Snooze and Done buttons
|
||||
await LocalNotifications.registerActionTypes({
|
||||
types: [
|
||||
{
|
||||
id: REMINDER_ACTION_TYPE_ID,
|
||||
actions: [
|
||||
{
|
||||
id: NOTIFICATION_ACTION.SNOOZE,
|
||||
title: 'Snooze',
|
||||
},
|
||||
{
|
||||
id: NOTIFICATION_ACTION.DONE,
|
||||
title: 'Done',
|
||||
destructive: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Listen for action events
|
||||
await LocalNotifications.addListener(
|
||||
'localNotificationActionPerformed',
|
||||
(event: ActionPerformed) => {
|
||||
Log.log('CapacitorNotificationService: Action performed', {
|
||||
actionId: event.actionId,
|
||||
notificationId: event.notification.id,
|
||||
});
|
||||
|
||||
this.action$.next({
|
||||
actionId: event.actionId,
|
||||
notificationId: event.notification.id,
|
||||
extra: event.notification.extra,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
this._actionsRegistered = true;
|
||||
Log.log('CapacitorNotificationService: Reminder actions registered');
|
||||
} catch (error) {
|
||||
Log.err('CapacitorNotificationService: Failed to register actions', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request notification permissions from the user
|
||||
*/
|
||||
|
|
@ -110,6 +198,8 @@ export class CapacitorNotificationService {
|
|||
title: options.title,
|
||||
body: options.body,
|
||||
extra: options.extra,
|
||||
// Include action type for iOS notification actions
|
||||
actionTypeId: options.actionTypeId,
|
||||
schedule: options.scheduleAt
|
||||
? {
|
||||
at: options.scheduleAt,
|
||||
|
|
|
|||
|
|
@ -89,6 +89,25 @@ export class CapacitorPlatformService {
|
|||
return this.platform === 'web';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running on iPad (native or browser)
|
||||
*/
|
||||
isIPad(): boolean {
|
||||
if (this.platform !== 'ios') {
|
||||
return false;
|
||||
}
|
||||
// Check for iPad identifier in user agent
|
||||
const userAgent = navigator.userAgent;
|
||||
if (/iPad/.test(userAgent)) {
|
||||
return true;
|
||||
}
|
||||
// iPad on iOS 13+ reports as Mac with touch support
|
||||
if (userAgent.includes('Mac') && 'ontouchend' in document) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the current platform
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -2,9 +2,14 @@ import { inject, Injectable } from '@angular/core';
|
|||
import { LocalNotifications } from '@capacitor/local-notifications';
|
||||
import { Log } from '../log';
|
||||
import { CapacitorPlatformService } from './capacitor-platform.service';
|
||||
import { CapacitorNotificationService } from './capacitor-notification.service';
|
||||
import {
|
||||
CapacitorNotificationService,
|
||||
NotificationActionEvent,
|
||||
REMINDER_ACTION_TYPE_ID,
|
||||
} from './capacitor-notification.service';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
|
||||
import { androidInterface } from '../../features/android/android-interface';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export interface ScheduleReminderOptions {
|
||||
/**
|
||||
|
|
@ -46,6 +51,14 @@ export class CapacitorReminderService {
|
|||
private _platformService = inject(CapacitorPlatformService);
|
||||
private _notificationService = inject(CapacitorNotificationService);
|
||||
|
||||
/**
|
||||
* Observable that emits when a notification action is performed (iOS).
|
||||
* Use this to handle snooze/done button taps from notifications.
|
||||
*/
|
||||
get action$(): Observable<NotificationActionEvent> {
|
||||
return this._notificationService.action$;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if reminder scheduling is available on this platform
|
||||
*/
|
||||
|
|
@ -53,6 +66,16 @@ export class CapacitorReminderService {
|
|||
return this._platformService.capabilities.scheduledNotifications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the reminder service.
|
||||
* Registers notification action types on iOS.
|
||||
*/
|
||||
async initialize(): Promise<void> {
|
||||
if (this._platformService.isIOS()) {
|
||||
await this._notificationService.registerReminderActions();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reminder notification
|
||||
*/
|
||||
|
|
@ -103,6 +126,10 @@ export class CapacitorReminderService {
|
|||
id: options.notificationId,
|
||||
title: options.title,
|
||||
body: `Reminder: ${options.title}`,
|
||||
// Include action type for iOS notification actions (Snooze/Done buttons)
|
||||
actionTypeId: this._platformService.isIOS()
|
||||
? REMINDER_ACTION_TYPE_ID
|
||||
: undefined,
|
||||
schedule: {
|
||||
at: new Date(triggerAt),
|
||||
allowWhileIdle: true,
|
||||
|
|
|
|||
|
|
@ -291,6 +291,11 @@ export class GlobalThemeService {
|
|||
this.document.body.classList.add(BodyClass.isIOS);
|
||||
this._initIOSKeyboardHandling();
|
||||
this._initIOSStatusBar();
|
||||
|
||||
// Add iPad-specific class for tablet optimizations
|
||||
if (this._platformService.isIPad()) {
|
||||
this.document.body.classList.add(BodyClass.isIPad);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||
import { ReminderService } from './reminder.service';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
import { IS_NATIVE_PLATFORM } from '../../util/is-native-platform';
|
||||
import { IS_NATIVE_PLATFORM, IS_IOS_NATIVE } from '../../util/is-native-platform';
|
||||
import {
|
||||
concatMap,
|
||||
delay,
|
||||
|
|
@ -28,6 +28,15 @@ import { TaskWithReminderData } from '../tasks/task.model';
|
|||
import { Store } from '@ngrx/store';
|
||||
import { TaskSharedActions } from '../../root-store/meta/task-shared.actions';
|
||||
import { GlobalConfigService } from '../config/global-config.service';
|
||||
import { CapacitorReminderService } from '../../core/platform/capacitor-reminder.service';
|
||||
import {
|
||||
NOTIFICATION_ACTION,
|
||||
NotificationActionEvent,
|
||||
} from '../../core/platform/capacitor-notification.service';
|
||||
import { Log } from '../../core/log';
|
||||
|
||||
const IOS_SNOOZE_MINUTES = 10;
|
||||
const MINUTES_TO_MILLISECONDS = 60 * 1000;
|
||||
|
||||
@NgModule({
|
||||
declarations: [],
|
||||
|
|
@ -44,11 +53,15 @@ export class ReminderModule {
|
|||
private readonly _syncTriggerService = inject(SyncTriggerService);
|
||||
private readonly _store = inject(Store);
|
||||
private readonly _globalConfigService = inject(GlobalConfigService);
|
||||
private readonly _capacitorReminderService = inject(CapacitorReminderService);
|
||||
|
||||
constructor() {
|
||||
// Initialize reminder service (runs migration in background)
|
||||
this._reminderService.init();
|
||||
|
||||
// Initialize iOS notification actions
|
||||
this._initIOSNotificationActions();
|
||||
|
||||
this._syncTriggerService.afterInitialSyncDoneAndDataLoadedInitially$
|
||||
.pipe(
|
||||
first(),
|
||||
|
|
@ -162,4 +175,71 @@ export class ReminderModule {
|
|||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize iOS notification action handling.
|
||||
* Registers action types and subscribes to action events.
|
||||
*/
|
||||
private _initIOSNotificationActions(): void {
|
||||
if (!IS_IOS_NATIVE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize the Capacitor reminder service (registers action types)
|
||||
this._capacitorReminderService.initialize().then(() => {
|
||||
Log.log('ReminderModule: iOS notification actions initialized');
|
||||
});
|
||||
|
||||
// Handle notification action events
|
||||
this._capacitorReminderService.action$.subscribe((event: NotificationActionEvent) => {
|
||||
this._handleIOSNotificationAction(event);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle iOS notification action (Snooze or Done).
|
||||
*/
|
||||
private async _handleIOSNotificationAction(
|
||||
event: NotificationActionEvent,
|
||||
): Promise<void> {
|
||||
const taskId = event.extra?.['relatedId'] as string | undefined;
|
||||
if (!taskId) {
|
||||
Log.warn('ReminderModule: No task ID in notification action', event);
|
||||
return;
|
||||
}
|
||||
|
||||
Log.log('ReminderModule: Handling iOS notification action', {
|
||||
actionId: event.actionId,
|
||||
taskId,
|
||||
});
|
||||
|
||||
if (event.actionId === NOTIFICATION_ACTION.SNOOZE) {
|
||||
// Snooze: Reschedule the reminder for 10 minutes later
|
||||
const task = await this._taskService.getByIdOnce$(taskId).toPromise();
|
||||
if (task) {
|
||||
const snoozeMs = IOS_SNOOZE_MINUTES * MINUTES_TO_MILLISECONDS;
|
||||
const newRemindAt = Date.now() + snoozeMs;
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.reScheduleTaskWithTime({
|
||||
task,
|
||||
remindAt: newRemindAt,
|
||||
dueWithTime: task.dueWithTime ?? newRemindAt,
|
||||
isMoveToBacklog: false,
|
||||
}),
|
||||
);
|
||||
Log.log('ReminderModule: Task snoozed via iOS notification', {
|
||||
taskId,
|
||||
newRemindAt: new Date(newRemindAt).toISOString(),
|
||||
});
|
||||
}
|
||||
} else if (event.actionId === NOTIFICATION_ACTION.DONE) {
|
||||
// Done: Dismiss the reminder only (don't mark task as done)
|
||||
this._store.dispatch(
|
||||
TaskSharedActions.dismissReminderOnly({
|
||||
id: taskId,
|
||||
}),
|
||||
);
|
||||
Log.log('ReminderModule: Reminder dismissed via iOS notification', { taskId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,3 +74,35 @@ body.isKeyboardVisible {
|
|||
// Optionally adjust for keyboard height
|
||||
// Elements can use --keyboard-height to position above keyboard
|
||||
}
|
||||
|
||||
// ===============================
|
||||
// iPad-Specific Optimizations
|
||||
// ===============================
|
||||
|
||||
body.isIPad {
|
||||
// iPad has more screen real estate - use wider content
|
||||
--component-max-width: 900px;
|
||||
|
||||
// Larger touch targets are less necessary on iPad
|
||||
--touch-target-min: 40px;
|
||||
|
||||
// Better use of horizontal space in landscape
|
||||
@media (min-width: 1024px) {
|
||||
--component-max-width: 1000px;
|
||||
}
|
||||
|
||||
// Split-view support: when iPad is in split-view mode,
|
||||
// the viewport is narrower - detect and adjust
|
||||
@media (max-width: 500px) {
|
||||
// In narrow split-view, use phone-like layout
|
||||
--component-max-width: 100%;
|
||||
}
|
||||
|
||||
// iPad in landscape with enough space for side-by-side content
|
||||
@media (min-width: 1024px) and (orientation: landscape) {
|
||||
// Wider dialogs on iPad landscape
|
||||
.mat-mdc-dialog-container {
|
||||
max-width: 700px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue