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:
Johannes Millan 2026-01-12 17:04:02 +01:00
parent 1f06a54f11
commit 02da3c283a
7 changed files with 257 additions and 3 deletions

View file

@ -49,6 +49,7 @@ export enum BodyClass {
// iOS-specific classes
isIOS = 'isIOS',
isIPad = 'isIPad',
isNativeMobile = 'isNativeMobile',
isKeyboardVisible = 'isKeyboardVisible',
}

View file

@ -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,

View file

@ -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
*/

View file

@ -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,

View file

@ -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);
}
}
}

View file

@ -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 });
}
}
}

View file

@ -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;
}
}
}