@rdlabo/ionic-angular-kit 0.0.3 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,797 @@
1
+ import * as i0 from '@angular/core';
2
+ import { inject, Injectable, InjectionToken, makeEnvironmentProviders, ElementRef, Directive } from '@angular/core';
3
+ import { Storage } from '@ionic/storage-angular';
4
+ import { ModalController, ToastController, AlertController, NavController } from '@ionic/angular/standalone';
5
+ import { Capacitor } from '@capacitor/core';
6
+ import { Keyboard } from '@capacitor/keyboard';
7
+ import { ImpactStyle, Haptics } from '@capacitor/haptics';
8
+ import { Router } from '@angular/router';
9
+ import { map, mergeMap, tap, catchError } from 'rxjs/operators';
10
+ import { HttpResponse } from '@angular/common/http';
11
+ import { Network } from '@capacitor/network';
12
+ import { from, retry, throwError, timer } from 'rxjs';
13
+
14
+ /**
15
+ * Thin, typed wrapper around `@ionic/storage-angular`.
16
+ *
17
+ * Starts `create()` exactly once and stores the resulting ready Promise. Every operation awaits
18
+ * that Promise before touching the underlying store, so calls made before initialization completes
19
+ * are queued rather than dropped.
20
+ *
21
+ * @remarks
22
+ * A naive wrapper that reads the store synchronously would silently no-op (or throw) when invoked
23
+ * before `create()` resolves, losing early writes. Awaiting the one-time ready Promise on every
24
+ * operation removes that race without forcing callers to coordinate initialization themselves.
25
+ *
26
+ * @example
27
+ * ```ts
28
+ * constructor(private readonly storage: KitStorageService) {}
29
+ *
30
+ * async ngOnInit(): Promise<void> {
31
+ * await this.storage.set('token', 'abc123');
32
+ * const token = await this.storage.get<string>('token');
33
+ * }
34
+ * ```
35
+ */
36
+ class KitStorageService {
37
+ /** One-time `create()` ready Promise; awaited before every operation so early calls are not lost. */
38
+ #ready = inject(Storage).create();
39
+ /**
40
+ * Persist a value under the given key.
41
+ *
42
+ * @typeParam T - type of the value being stored
43
+ * @param key - key to store the value under
44
+ * @param value - value to persist; overwrites any existing value for the key
45
+ * @returns a Promise that resolves once the value has been written
46
+ * @example
47
+ * ```ts
48
+ * await storage.set('user', { id: 1, name: 'Ada' });
49
+ * ```
50
+ */
51
+ async set(key, value) {
52
+ await (await this.#ready).set(key, value);
53
+ }
54
+ /**
55
+ * Read the value stored under the given key.
56
+ *
57
+ * @typeParam T - expected type of the stored value
58
+ * @param key - key to read
59
+ * @returns the stored value, or `null` when the key is absent
60
+ * @example
61
+ * ```ts
62
+ * const user = await storage.get<{ id: number }>('user');
63
+ * ```
64
+ */
65
+ async get(key) {
66
+ return (await (await this.#ready).get(key)) ?? null;
67
+ }
68
+ /**
69
+ * Remove the value stored under the given key.
70
+ *
71
+ * @param key - key to remove; a no-op when the key is absent
72
+ * @returns a Promise that resolves once the key has been removed
73
+ */
74
+ async remove(key) {
75
+ await (await this.#ready).remove(key);
76
+ }
77
+ /**
78
+ * Remove every key/value pair from the store.
79
+ *
80
+ * @returns a Promise that resolves once the store has been emptied
81
+ */
82
+ async clear() {
83
+ await (await this.#ready).clear();
84
+ }
85
+ /**
86
+ * List every key currently present in the store.
87
+ *
88
+ * @returns an array of all stored keys
89
+ */
90
+ async keys() {
91
+ return (await this.#ready).keys();
92
+ }
93
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitStorageService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
94
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitStorageService, providedIn: 'root' }); }
95
+ }
96
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitStorageService, decorators: [{
97
+ type: Injectable,
98
+ args: [{
99
+ providedIn: 'root',
100
+ }]
101
+ }] });
102
+
103
+ /**
104
+ * Injection token carrying the {@link KitOverlayConfig} for `KitOverlayController`.
105
+ *
106
+ * @remarks
107
+ * Provide it through {@link provideKitOverlay} rather than registering it directly.
108
+ */
109
+ const KIT_OVERLAY_CONFIG = new InjectionToken('@rdlabo/ionic-angular-kit:overlay');
110
+ /**
111
+ * Wire `KitOverlayController` into the application by providing its button labels.
112
+ *
113
+ * @param config - overlay configuration, including the button labels to inject
114
+ * @returns environment providers to add to the application's provider list
115
+ * @example
116
+ * ```ts
117
+ * bootstrapApplication(AppComponent, {
118
+ * providers: [
119
+ * provideKitOverlay({ labels: { close: $localize`Close`, cancel: $localize`Cancel` } }),
120
+ * ],
121
+ * });
122
+ * ```
123
+ */
124
+ const provideKitOverlay = (config) => makeEnvironmentProviders([{ provide: KIT_OVERLAY_CONFIG, useValue: config }]);
125
+
126
+ /**
127
+ * Trigger native haptic impact feedback.
128
+ *
129
+ * Delegates to `@capacitor/haptics` `Haptics.impact()` when running on a native platform. On the
130
+ * web (or any non-native platform) it is a no-op and resolves without doing anything.
131
+ *
132
+ * @remarks
133
+ * This haptic side effect was previously bundled implicitly into toast/modal presentation. It has
134
+ * been decoupled into this explicit function so callers opt in to the feedback deliberately, rather
135
+ * than receiving it as a hidden side effect of presenting UI.
136
+ *
137
+ * @param style - The impact intensity to play. Defaults to {@link ImpactStyle.Light}.
138
+ * @returns A promise that resolves once the feedback has been requested (immediately on the web).
139
+ * @example
140
+ * ```ts
141
+ * import { ImpactStyle } from '@capacitor/haptics';
142
+ *
143
+ * // Light tap (default)
144
+ * await kitImpact();
145
+ *
146
+ * // Stronger feedback, e.g. on a confirming action
147
+ * await kitImpact(ImpactStyle.Heavy);
148
+ * ```
149
+ */
150
+ const kitImpact = async (style = ImpactStyle.Light) => {
151
+ if (Capacitor.isNativePlatform()) {
152
+ await Haptics.impact({ style });
153
+ }
154
+ };
155
+
156
+ /**
157
+ * Attach a native keyboard listener that grows the modal to its maximum breakpoint when the
158
+ * keyboard appears.
159
+ *
160
+ * @param modal - the presented modal element to resize
161
+ * @returns a listener handle; on non-native platforms a no-op handle whose `remove()` does nothing
162
+ * @internal
163
+ */
164
+ const watchModalKeyboard = async (modal) => {
165
+ if (!Capacitor.isNativePlatform()) {
166
+ return { remove: async () => undefined };
167
+ }
168
+ return Keyboard.addListener('keyboardDidShow', () => modal.setCurrentBreakpoint(1));
169
+ };
170
+ /**
171
+ * Ergonomic wrapper that consolidates Ionic's overlay controllers (Modal / Toast / Alert).
172
+ *
173
+ * @remarks
174
+ * Folds the repetitive create → present → onDidDismiss sequence into single calls and returns the
175
+ * relevant result directly. It holds no application-specific policy such as navigation; compose
176
+ * those concerns on the consuming side.
177
+ *
178
+ * @example
179
+ * ```ts
180
+ * constructor(private readonly overlay: KitOverlayController) {}
181
+ *
182
+ * async edit(): Promise<void> {
183
+ * const result = await this.overlay.presentModal<EditResult>(EditPage, { id: 1 });
184
+ * if (result) {
185
+ * await this.overlay.presentToast({ message: 'Saved' });
186
+ * }
187
+ * }
188
+ * ```
189
+ */
190
+ class KitOverlayController {
191
+ #modalCtrl = inject(ModalController);
192
+ #toastCtrl = inject(ToastController);
193
+ #alertCtrl = inject(AlertController);
194
+ #labels = inject(KIT_OVERLAY_CONFIG).labels;
195
+ /**
196
+ * Present a modal and resolve with the data passed to its dismissal.
197
+ *
198
+ * @typeParam O - type of the data returned when the modal is dismissed
199
+ * @param component - the component to render inside the modal
200
+ * @param componentProps - props to pass to the modal component
201
+ * @param options - additional modal options, including {@link KitModalPresentOptions.watchKeyboard}
202
+ * @returns the dismiss data, or `undefined` when the modal is dismissed without data
203
+ * @example
204
+ * ```ts
205
+ * const data = await overlay.presentModal<{ saved: boolean }>(EditPage, { id: 1 }, { watchKeyboard: true });
206
+ * ```
207
+ */
208
+ async presentModal(component, componentProps, options = {}) {
209
+ const { watchKeyboard, ...modalOptions } = options;
210
+ const modal = await this.#modalCtrl.create({ component, componentProps, ...modalOptions });
211
+ await modal.present();
212
+ const handle = watchKeyboard ? await watchModalKeyboard(modal) : null;
213
+ const { data } = await modal.onDidDismiss();
214
+ await handle?.remove();
215
+ return data;
216
+ }
217
+ /**
218
+ * Present a toast using kit defaults that the caller may override.
219
+ *
220
+ * @remarks
221
+ * Defaults to a top position, a 2000ms duration, a vertical swipe gesture, and a close button
222
+ * from the configured labels; any of these can be overridden via `options`. Presenting a toast
223
+ * also triggers light native haptic feedback as an intentional kit UX choice.
224
+ *
225
+ * @param options - Ionic toast options that override the kit defaults
226
+ * @returns the presented toast element
227
+ * @example
228
+ * ```ts
229
+ * await overlay.presentToast({ message: 'Copied to clipboard' });
230
+ * ```
231
+ */
232
+ async presentToast(options) {
233
+ void kitImpact();
234
+ const toast = await this.#toastCtrl.create({
235
+ position: 'top',
236
+ duration: 2000,
237
+ buttons: [this.#labels.close],
238
+ swipeGesture: 'vertical',
239
+ ...options,
240
+ });
241
+ await toast.present();
242
+ return toast;
243
+ }
244
+ /**
245
+ * Present a notification alert with a single "close" button and wait for it to be dismissed.
246
+ *
247
+ * @param options - alert content (header, message, optional sub-header)
248
+ * @returns a Promise that resolves once the alert has been dismissed
249
+ * @example
250
+ * ```ts
251
+ * await overlay.alertClose({ header: 'Done', message: 'Your changes were saved.' });
252
+ * ```
253
+ */
254
+ async alertClose(options) {
255
+ const alert = await this.#alertCtrl.create({
256
+ header: options.header,
257
+ subHeader: options.subHeader,
258
+ message: options.message,
259
+ buttons: [this.#labels.close],
260
+ });
261
+ await alert.present();
262
+ await alert.onWillDismiss();
263
+ }
264
+ /**
265
+ * Present a confirmation alert with cancel and OK buttons.
266
+ *
267
+ * @param options - alert content plus the OK button text via {@link KitAlertConfirmOptions.okText}
268
+ * @returns `true` when the user presses OK, `false` otherwise (cancel or backdrop dismissal)
269
+ * @example
270
+ * ```ts
271
+ * const ok = await overlay.alertConfirm({
272
+ * header: 'Delete item?',
273
+ * message: 'This cannot be undone.',
274
+ * okText: 'Delete',
275
+ * });
276
+ * if (ok) {
277
+ * await remove();
278
+ * }
279
+ * ```
280
+ */
281
+ async alertConfirm(options) {
282
+ const alert = await this.#alertCtrl.create({
283
+ header: options.header,
284
+ subHeader: options.subHeader,
285
+ message: options.message,
286
+ buttons: [
287
+ { text: this.#labels.cancel, role: 'cancel' },
288
+ { text: options.okText, role: 'confirm' },
289
+ ],
290
+ });
291
+ await alert.present();
292
+ const { role } = await alert.onWillDismiss();
293
+ return role === 'confirm';
294
+ }
295
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitOverlayController, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
296
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitOverlayController, providedIn: 'root' }); }
297
+ }
298
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitOverlayController, decorators: [{
299
+ type: Injectable,
300
+ args: [{
301
+ providedIn: 'root',
302
+ }]
303
+ }] });
304
+
305
+ /**
306
+ * The fleet's canonical "network error → offer to reload" alert, as a stateful controller.
307
+ *
308
+ * @remarks
309
+ * Consolidates the good-UX variant that had drifted across the fleet into one behavior:
310
+ *
311
+ * - **De-dup** — never stacks; a second {@link present} while an alert is already shown is a no-op.
312
+ * - **Backdrop lock** — `backdropDismiss: false`, so a critical network error can't be dismissed by
313
+ * an accidental backdrop tap; the user consciously chooses cancel or reload.
314
+ * - **Auto-dismiss on reconnect** — the presented alert is tracked, so {@link dismiss} (called from a
315
+ * later successful response) clears a now-stale error alert instead of leaving it on screen.
316
+ * - **Reload on confirm** — the confirm button calls `location.reload()`.
317
+ *
318
+ * All user-facing text is supplied by the caller so the kit stays free of any hardcoded i18n; the
319
+ * cancel button reuses {@link KitOverlayConfig.labels}. Because it performs navigation
320
+ * (`location.reload()`) and holds state, it is a dedicated controller rather than part of
321
+ * {@link KitOverlayController}, which stays free of navigation policy.
322
+ *
323
+ * @example
324
+ * ```ts
325
+ * // In an HTTP interceptor:
326
+ * const reload = inject(KitReloadAlertController);
327
+ * // ...on a network-class error while connected:
328
+ * await reload.present({ header: 'ネットワークエラー', message: `…(${status})`, okText: 'リフレッシュ' });
329
+ * // ...on any later successful response:
330
+ * await reload.dismiss();
331
+ * ```
332
+ */
333
+ class KitReloadAlertController {
334
+ #alertCtrl = inject(AlertController);
335
+ #labels = inject(KIT_OVERLAY_CONFIG).labels;
336
+ #alert = null;
337
+ /**
338
+ * Present the reload alert, unless one is already on screen.
339
+ *
340
+ * @param options - alert content plus the reload-button text
341
+ * @returns a Promise that resolves once the alert has been presented (or immediately if suppressed)
342
+ */
343
+ async present(options) {
344
+ // この controller 経由でも直書き ion-alert でも、多重表示しない。
345
+ if (this.#alert || document.querySelector('ion-alert')) {
346
+ return;
347
+ }
348
+ const alert = await this.#alertCtrl.create({
349
+ header: options.header,
350
+ message: options.message,
351
+ backdropDismiss: false,
352
+ buttons: [
353
+ { text: this.#labels.cancel, role: 'cancel' },
354
+ {
355
+ text: options.okText,
356
+ handler: () => {
357
+ location.reload();
358
+ },
359
+ },
360
+ ],
361
+ });
362
+ this.#alert = alert;
363
+ void alert.onDidDismiss().then(() => {
364
+ // 別の present で置き換わっていない限り、追跡を解除する。
365
+ if (this.#alert === alert) {
366
+ this.#alert = null;
367
+ }
368
+ });
369
+ await alert.present();
370
+ }
371
+ /**
372
+ * Dismiss the tracked reload alert if one is showing.
373
+ *
374
+ * @remarks
375
+ * Typically called from a later successful response so a stale "network error" alert clears once
376
+ * connectivity is restored. A no-op when nothing is showing.
377
+ *
378
+ * @returns a Promise that resolves once the alert has been dismissed (or immediately if none)
379
+ */
380
+ async dismiss() {
381
+ const alert = this.#alert;
382
+ this.#alert = null;
383
+ await alert?.dismiss();
384
+ }
385
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitReloadAlertController, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
386
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitReloadAlertController, providedIn: 'root' }); }
387
+ }
388
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitReloadAlertController, decorators: [{
389
+ type: Injectable,
390
+ args: [{
391
+ providedIn: 'root',
392
+ }]
393
+ }] });
394
+
395
+ /**
396
+ * Work around iOS `ion-input` autofill values not propagating to the Angular form model.
397
+ *
398
+ * On iOS, when the browser autofills an `ion-input` (for example a saved password), the value
399
+ * is written to the underlying native `<input>` element but the corresponding `change` event is
400
+ * not forwarded to the host `ion-input`, so the Angular form control (and `ngModel`) never sees
401
+ * the autofilled value. This directive listens for the first `change` event on the inner input
402
+ * element and mirrors its value back onto the host element, restoring two-way binding.
403
+ *
404
+ * Apply it to any `ion-input` that participates in a form and may be autofilled by attaching the
405
+ * `rdlaboAutofill` attribute.
406
+ *
407
+ * @remarks
408
+ * The directive is a no-op on every platform other than iOS, where the value already propagates
409
+ * correctly. A short timeout is used because `ion-input` creates its inner `<input>` element
410
+ * asynchronously after the host element is initialized.
411
+ *
412
+ * @example
413
+ * ```html
414
+ * <ion-input rdlaboAutofill type="password" [(ngModel)]="password"></ion-input>
415
+ * ```
416
+ */
417
+ class KitAutofillDirective {
418
+ #el = inject(ElementRef);
419
+ constructor() { }
420
+ /**
421
+ * Register the iOS autofill workaround once the directive is initialized.
422
+ *
423
+ * Returns immediately on non-iOS platforms. On iOS, after a short delay it attaches a one-shot,
424
+ * passive `change` listener to the inner `<input>` element that `ion-input` renders, copying the
425
+ * autofilled value back onto the host element so the Angular form model stays in sync. Any error
426
+ * while locating the inner input (for example if the element is not yet present) is swallowed.
427
+ *
428
+ * @returns Nothing.
429
+ */
430
+ ngOnInit() {
431
+ if (Capacitor.getPlatform() !== 'ios') {
432
+ return;
433
+ }
434
+ setTimeout(() => {
435
+ try {
436
+ this.#el.nativeElement.children[0].addEventListener('change', (e) => {
437
+ this.#el.nativeElement.value = e.target.value;
438
+ }, {
439
+ capture: false,
440
+ once: true,
441
+ passive: true,
442
+ });
443
+ }
444
+ catch {
445
+ /* empty */
446
+ }
447
+ }, 100); // Need some time for the ion-input to create the input element
448
+ }
449
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitAutofillDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
450
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.17", type: KitAutofillDirective, isStandalone: true, selector: "[rdlaboAutofill]", ngImport: i0 }); }
451
+ }
452
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitAutofillDirective, decorators: [{
453
+ type: Directive,
454
+ args: [{
455
+ selector: '[rdlaboAutofill]',
456
+ standalone: true,
457
+ }]
458
+ }], ctorParameters: () => [] });
459
+
460
+ /**
461
+ * Injection token that carries the {@link KitAuthConfig} to the authentication guards.
462
+ */
463
+ const KIT_AUTH_CONFIG = new InjectionToken('@rdlabo/ionic-angular-kit:auth');
464
+ /**
465
+ * Wire the authentication guard configuration into the application's dependency injection.
466
+ *
467
+ * @remarks
468
+ * The factory runs inside an injection context, so it may call `inject()` (for example
469
+ * `inject(AuthService)`) to build the configuration.
470
+ *
471
+ * @param configFactory - Factory that returns the {@link KitAuthConfig} for the application.
472
+ * @returns Environment providers to add to the application bootstrap.
473
+ *
474
+ * @example
475
+ * ```ts
476
+ * provideKitAuth(() => {
477
+ * const auth = inject(AuthService);
478
+ * return {
479
+ * authState: () => auth.isAuth(),
480
+ * onAuthorized: async () => true,
481
+ * onUnauthenticated: async () => false,
482
+ * redirects: {
483
+ * whenAuthorized: '/',
484
+ * whenConfirming: '/auth/confirm',
485
+ * whenNotConfirming: '/auth/signin',
486
+ * whenUnauthorized: 'auth',
487
+ * },
488
+ * };
489
+ * });
490
+ * ```
491
+ */
492
+ const provideKitAuth = (configFactory) => makeEnvironmentProviders([{ provide: KIT_AUTH_CONFIG, useFactory: configFactory }]);
493
+ /**
494
+ * Guard that requires the user to be unauthenticated (for example sign-in or sign-up pages).
495
+ *
496
+ * @remarks
497
+ * Allows the `required` and `anonymous` states (an anonymous user is permitted to proceed to a
498
+ * registration page). An authenticated user (`user`) is sent to `whenAuthorized`, and a user
499
+ * awaiting confirmation (`confirm`) is sent to `whenConfirming`.
500
+ *
501
+ * @returns A stream emitting `true` to allow activation, or `false` after triggering a redirect.
502
+ *
503
+ * @example
504
+ * ```ts
505
+ * const routes: Routes = [{ path: 'signin', component: SigninPage, canActivate: [kitRequiredUnauthorizedGuard] }];
506
+ * ```
507
+ */
508
+ const kitRequiredUnauthorizedGuard = () => {
509
+ const { authState, redirects } = inject(KIT_AUTH_CONFIG);
510
+ const router = inject(Router);
511
+ const navCtrl = inject(NavController);
512
+ return authState().pipe(map((data) => {
513
+ if (data === 'user') {
514
+ navCtrl.setDirection('root');
515
+ router.navigate([redirects.whenAuthorized]);
516
+ return false;
517
+ }
518
+ else if (data === 'confirm') {
519
+ router.navigate([redirects.whenConfirming]);
520
+ return false;
521
+ }
522
+ // 'required' | 'anonymous'
523
+ return true;
524
+ }));
525
+ };
526
+ /**
527
+ * Guard that requires the user to be awaiting email confirmation (`confirm`).
528
+ *
529
+ * @remarks
530
+ * Any other state triggers a redirect: an `anonymous` user is sent to the authenticated area
531
+ * (`whenAuthorized`), and every remaining state is sent to `whenNotConfirming`.
532
+ *
533
+ * @returns A stream emitting `true` to allow activation, or `false` after triggering a redirect.
534
+ *
535
+ * @example
536
+ * ```ts
537
+ * const routes: Routes = [{ path: 'confirm', component: ConfirmPage, canActivate: [kitRequireConfirmingGuard] }];
538
+ * ```
539
+ */
540
+ const kitRequireConfirmingGuard = () => {
541
+ const { authState, redirects } = inject(KIT_AUTH_CONFIG);
542
+ const router = inject(Router);
543
+ const navCtrl = inject(NavController);
544
+ return authState().pipe(map((data) => {
545
+ if (data === 'confirm') {
546
+ return true;
547
+ }
548
+ navCtrl.setDirection('root');
549
+ router.navigate([data === 'anonymous' ? redirects.whenAuthorized : redirects.whenNotConfirming]);
550
+ return false;
551
+ }));
552
+ };
553
+ /**
554
+ * Guard that requires the user to be fully authenticated (`user`).
555
+ *
556
+ * @remarks
557
+ * - `user` — runs {@link KitAuthConfig.onAuthorized} (token login, permission checks, and so on).
558
+ * - `anonymous` — allowed as-is, for applications that permit anonymous browsing.
559
+ * - `required` / `confirm` — runs {@link KitAuthConfig.onUnauthenticated}; if it resolves to `false`,
560
+ * the user is redirected to `whenUnauthorized`.
561
+ *
562
+ * @param _route - The activated route snapshot (unused).
563
+ * @param state - The router state snapshot, forwarded to the configuration hooks.
564
+ * @returns A stream emitting the activation result: `true`, a `UrlTree`, or `false` after a redirect.
565
+ *
566
+ * @example
567
+ * ```ts
568
+ * const routes: Routes = [{ path: 'home', component: HomePage, canActivate: [kitRequireAuthorizedGuard] }];
569
+ * ```
570
+ */
571
+ const kitRequireAuthorizedGuard = (_route, state) => {
572
+ const { authState, onAuthorized, onUnauthenticated, redirects } = inject(KIT_AUTH_CONFIG);
573
+ const router = inject(Router);
574
+ const navCtrl = inject(NavController);
575
+ return authState().pipe(mergeMap(async (data) => {
576
+ if (data === 'user') {
577
+ return onAuthorized(state);
578
+ }
579
+ if (data === 'anonymous') {
580
+ return true;
581
+ }
582
+ const fallback = await onUnauthenticated(state);
583
+ if (fallback !== false) {
584
+ return fallback;
585
+ }
586
+ navCtrl.setDirection('root');
587
+ router.navigate([redirects.whenUnauthorized]);
588
+ return false;
589
+ }));
590
+ };
591
+
592
+ /**
593
+ * HTTP status codes that must never be retried. `401` is handled separately and thrown immediately.
594
+ *
595
+ * @internal
596
+ */
597
+ const NON_RETRYABLE_STATUSES = [400, 403, 404, 418, 500, 502];
598
+ /**
599
+ * Injection token that carries the {@link KitHttpConfig} to {@link kitAuthInterceptor}.
600
+ */
601
+ const KIT_HTTP_CONFIG = new InjectionToken('@rdlabo/ionic-angular-kit:http');
602
+ /**
603
+ * Wire the {@link kitAuthInterceptor} configuration into the application's dependency injection.
604
+ *
605
+ * @remarks
606
+ * Register the interceptor itself separately via `provideHttpClient(withInterceptors([kitAuthInterceptor]))`.
607
+ * The factory runs inside an injection context, so it may call `inject()`.
608
+ *
609
+ * @param configFactory - Factory that returns the {@link KitHttpConfig} for the application.
610
+ * @returns Environment providers to add to the application bootstrap.
611
+ *
612
+ * @example
613
+ * ```ts
614
+ * bootstrapApplication(AppComponent, {
615
+ * providers: [
616
+ * provideHttpClient(withInterceptors([kitAuthInterceptor])),
617
+ * provideKitHttp(() => {
618
+ * const auth = inject(AuthService);
619
+ * const reload = inject(KitReloadAlertController);
620
+ * return {
621
+ * // Only getAuthHeaders is required; every other hook is optional and defaults to a no-op.
622
+ * getAuthHeaders: async () => ({ Authorization: `Bearer ${await auth.token()}` }),
623
+ * onUnauthorized: () => auth.signOut(),
624
+ * onNetworkError: (status) =>
625
+ * reload.present({ header: 'Network error', message: `Reload? (${status})`, okText: 'Reload' }),
626
+ * onResponse: () => void reload.dismiss(),
627
+ * };
628
+ * }),
629
+ * ],
630
+ * });
631
+ * ```
632
+ */
633
+ const provideKitHttp = (configFactory) => makeEnvironmentProviders([{ provide: KIT_HTTP_CONFIG, useFactory: configFactory }]);
634
+ /**
635
+ * Canonical functional HTTP interceptor that applies authentication, retries, and error handling.
636
+ *
637
+ * @remarks
638
+ * Behavior, driven by the injected {@link KitHttpConfig}:
639
+ *
640
+ * 1. Requests for which `bypass` returns `true` are forwarded untouched.
641
+ * 2. Otherwise the headers from `getAuthHeaders` and `buildExtraHeaders` are merged onto a cloned request.
642
+ * 3. Failed requests are retried up to 2 times with a linearly increasing backoff of `500ms * (retryCount + 5)`,
643
+ * except that `401` and any {@link NON_RETRYABLE_STATUSES | non-retryable status}
644
+ * (`400`, `403`, `404`, `418`, `500`, `502`) are thrown immediately without retrying.
645
+ * 4. On error, `offlineFallback` is consulted first; otherwise `401` calls `onUnauthorized`, `403`
646
+ * calls `onForbidden`, network-class failures (anything other than `400`/`500`) call
647
+ * `onNetworkError` when the device is connected, and `400`/`500` responses carrying a body
648
+ * message call `onServerError`.
649
+ *
650
+ * @param request - The outgoing request.
651
+ * @param next - The next handler in the interceptor chain.
652
+ * @returns A stream of HTTP events for the (possibly modified, retried, or replaced) request.
653
+ *
654
+ * @example
655
+ * ```ts
656
+ * provideHttpClient(withInterceptors([kitAuthInterceptor]));
657
+ * ```
658
+ */
659
+ const kitAuthInterceptor = (request, next) => {
660
+ const config = inject(KIT_HTTP_CONFIG);
661
+ if (config.bypass?.(request)) {
662
+ return next(request);
663
+ }
664
+ return from(config.getAuthHeaders(request)).pipe(mergeMap((authHeaders) => {
665
+ const req = request.clone({ setHeaders: { ...authHeaders, ...config.buildExtraHeaders?.(request) } });
666
+ return next(req).pipe(retry({
667
+ count: 2,
668
+ delay: (e, retryCount) => {
669
+ if (e.status === 401) {
670
+ return throwError(() => e);
671
+ }
672
+ if (NON_RETRYABLE_STATUSES.includes(e.status)) {
673
+ return throwError(() => e);
674
+ }
675
+ return timer((retryCount + 5) * 500);
676
+ },
677
+ }), tap((event) => {
678
+ if (event instanceof HttpResponse) {
679
+ config.onResponse?.(event);
680
+ }
681
+ }), catchError((e) => {
682
+ const fallback = config.offlineFallback?.(req, e);
683
+ if (fallback) {
684
+ return fallback;
685
+ }
686
+ if (e.status === 401) {
687
+ config.onUnauthorized?.(req);
688
+ }
689
+ else if (e.status === 403) {
690
+ config.onForbidden?.(req);
691
+ }
692
+ else if (![400, 500].includes(e.status)) {
693
+ void Network.getStatus().then((status) => {
694
+ if (status.connected) {
695
+ config.onNetworkError?.(e.status);
696
+ }
697
+ });
698
+ }
699
+ else if (e.error?.message) {
700
+ config.onServerError?.(e.error.message);
701
+ }
702
+ return throwError(() => e);
703
+ }));
704
+ }));
705
+ };
706
+
707
+ /**
708
+ * Merge a newly fetched page of items into an existing list by id, keeping the result sorted.
709
+ *
710
+ * Items are merged by the numeric `key` field. On a duplicate `key` the item from `arrayNew` wins
711
+ * and replaces the one in `arrayOld`. Old items whose `key` falls *inside* the window spanned by
712
+ * `arrayNew` (between its first and last `key`) are dropped, since the freshly fetched page is the
713
+ * authoritative copy of that window; old items *outside* the window are kept. The merged items are
714
+ * finally sorted by `key`, ascending or descending according to `order`.
715
+ *
716
+ * The algorithm is:
717
+ * 1. If both inputs are empty, return an empty array.
718
+ * 2. Read the first (`lead`) and last (`last`) `key` values of `arrayNew` as the bounds of the
719
+ * page's window. Keep the old items whose `key` lies *outside* that window (at or beyond the
720
+ * extremes) and drop those strictly inside it, handling both a high-to-low page (`lead > last`)
721
+ * and a low-to-high page (`lead < last`). A single-value page (`lead === last`) keeps all old items.
722
+ * 3. Remove old items whose `key` already exists in `arrayNew` (new wins on duplicates).
723
+ * 4. Concatenate `arrayNew` with the surviving old items and sort by `key` in the requested
724
+ * direction.
725
+ *
726
+ * @remarks
727
+ * Designed for infinite-scroll / paginated list merging, where each fetched page may overlap the
728
+ * previously held items and the server returns a contiguous, ordered window of records. The `key`
729
+ * field is treated as a number for range comparison and sorting.
730
+ *
731
+ * @typeParam T - Element type of both arrays. Its `key` property must be a numeric value.
732
+ * @param arrayOld - The previously accumulated list. May be empty or nullish-safe (empty when falsy).
733
+ * @param arrayNew - The newly fetched page of items; its `key` range defines the window that its own
734
+ * items replace, and its items take precedence on duplicates.
735
+ * @param key - The property of `T` used as the unique, numeric id for matching, range filtering, and sorting.
736
+ * @param order - Sort direction by `key`: `'ASC'` for ascending or `'DESC'` for descending. Defaults to `'DESC'`.
737
+ * @returns A new array containing `arrayNew` merged with the out-of-window, non-duplicate old items, sorted by `key`.
738
+ * @example
739
+ * ```ts
740
+ * interface Post {
741
+ * id: number;
742
+ * title: string;
743
+ * }
744
+ *
745
+ * const loaded: Post[] = [
746
+ * { id: 30, title: 'c' },
747
+ * { id: 20, title: 'b' },
748
+ * { id: 10, title: 'a' },
749
+ * ];
750
+ * const nextPage: Post[] = [
751
+ * { id: 20, title: 'b (updated)' },
752
+ * { id: 15, title: 'a.5' },
753
+ * ];
754
+ *
755
+ * // Descending merge: id 30 lies above the new page's [15, 20] window so it is kept; id 20 is
756
+ * // inside the window and is replaced by the new value; id 10 lies below the window and is kept.
757
+ * const merged = arrayConcatById(loaded, nextPage, 'id', 'DESC');
758
+ * // => [{ id: 30, title: 'c' }, { id: 20, title: 'b (updated)' }, { id: 15, title: 'a.5' }, { id: 10, title: 'a' }]
759
+ * ```
760
+ */
761
+ const arrayConcatById = (arrayOld, arrayNew, key, order = 'DESC') => {
762
+ if (!arrayNew.length && !arrayOld.length) {
763
+ return [];
764
+ }
765
+ const lead = arrayNew[0][key];
766
+ const last = arrayNew[arrayNew.length - 1][key];
767
+ const filteredOld = (arrayOld || []).filter((vol) => {
768
+ const value = vol[key];
769
+ return (lead > last && (value >= lead || value <= last)) || (lead < last && (value <= lead || value >= last)) || lead === last;
770
+ });
771
+ const oldData = filteredOld.filter((vol) => !arrayNew.some((element) => element[key] === vol[key]));
772
+ const data = arrayNew.concat(oldData);
773
+ const direction = order === 'ASC' ? 1 : -1;
774
+ return data.sort((a, b) => {
775
+ const x = a[key];
776
+ const y = b[key];
777
+ if (x > y) {
778
+ return direction;
779
+ }
780
+ if (x < y) {
781
+ return direction * -1;
782
+ }
783
+ return 0;
784
+ });
785
+ };
786
+
787
+ /*
788
+ * Public API Surface of @rdlabo/ionic-angular-kit
789
+ */
790
+ // Storage: typed wrapper around the platform key/value store.
791
+
792
+ /**
793
+ * Generated bundle index. Do not edit.
794
+ */
795
+
796
+ export { KIT_AUTH_CONFIG, KIT_HTTP_CONFIG, KIT_OVERLAY_CONFIG, KitAutofillDirective, KitOverlayController, KitReloadAlertController, KitStorageService, arrayConcatById, kitAuthInterceptor, kitImpact, kitRequireAuthorizedGuard, kitRequireConfirmingGuard, kitRequiredUnauthorizedGuard, provideKitAuth, provideKitHttp, provideKitOverlay };
797
+ //# sourceMappingURL=rdlabo-ionic-angular-kit.mjs.map