@rdlabo/ionic-angular-kit 0.0.12 → 0.0.14

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.
@@ -1,15 +1,20 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, Injectable, InjectionToken, makeEnvironmentProviders, ElementRef, Directive } from '@angular/core';
2
+ import { inject, Injectable, InjectionToken, makeEnvironmentProviders, ElementRef, Directive, DOCUMENT } from '@angular/core';
3
3
  import { Storage } from '@ionic/storage-angular';
4
- import { ModalController, PopoverController, ToastController, AlertController, NavController } from '@ionic/angular/standalone';
4
+ import { ModalController, PopoverController, ToastController, AlertController, LoadingController, NavController } from '@ionic/angular/standalone';
5
5
  import { Capacitor } from '@capacitor/core';
6
6
  import { Keyboard } from '@capacitor/keyboard';
7
7
  import { ImpactStyle, Haptics } from '@capacitor/haptics';
8
+ import { StatusBar, Style } from '@capacitor/status-bar';
9
+ import { BehaviorSubject, from, throwError, retry, timer, Observable, startWith } from 'rxjs';
10
+ import { Preferences } from '@capacitor/preferences';
11
+ import { InAppReview } from '@capacitor-community/in-app-review';
12
+ import { BRLMPrinterHalftone, BRLMPrinterCustomPaperUnit, BRLMPrinterCustomPaperType, BRLMPrinterPrintQuality, BRLMPrinterHorizontalAlignment, BRLMPrinterVerticalAlignment, BRLMPrinterImageRotation, BRLMPrinterScaleMode } from '@rdlabo/capacitor-brotherprint';
13
+ import domtoimage from 'dom-to-image-more';
8
14
  import { Router } from '@angular/router';
9
15
  import { map, mergeMap, catchError, timeout, tap } from 'rxjs/operators';
10
16
  import { HttpErrorResponse, HttpResponse } from '@angular/common/http';
11
17
  import { Network } from '@capacitor/network';
12
- import { from, throwError, retry, timer } from 'rxjs';
13
18
 
14
19
  /**
15
20
  * Thin, typed wrapper around `@ionic/storage-angular`.
@@ -180,7 +185,7 @@ const watchModalKeyboard = async (modal) => {
180
185
  * constructor(private readonly overlay: KitOverlayController) {}
181
186
  *
182
187
  * async edit(): Promise<void> {
183
- * const result = await this.overlay.presentModal<EditResult>(EditPage, { id: 1 });
188
+ * const result = await this.overlay.presentModal(EditPage, { id: 1 });
184
189
  * if (result) {
185
190
  * await this.overlay.presentToast({ message: 'Saved' });
186
191
  * }
@@ -199,22 +204,6 @@ class KitOverlayController {
199
204
  * second alert on top of the first. Shared across both methods so a confirm cannot stack over a close.
200
205
  */
201
206
  #alertPresenting = false;
202
- /**
203
- * Present a modal and resolve with the data passed to its dismissal.
204
- *
205
- * @typeParam O - type of the data returned when the modal is dismissed
206
- * @param component - the component to render inside the modal
207
- * @param componentProps - props to pass to the modal component
208
- * @param options - additional modal options, including {@link KitModalPresentOptions.watchKeyboard}
209
- * @returns the dismiss data, or `undefined` when the modal is dismissed without data
210
- * @remarks
211
- * Presenting a modal triggers light native haptic feedback as an intentional kit UX choice,
212
- * consistent with {@link presentPopover} and {@link presentToast}.
213
- * @example
214
- * ```ts
215
- * const data = await overlay.presentModal<{ saved: boolean }>(EditPage, { id: 1 }, { watchKeyboard: true });
216
- * ```
217
- */
218
207
  async presentModal(component, componentProps, options = {}) {
219
208
  void kitImpact();
220
209
  const { watchKeyboard, ...modalOptions } = options;
@@ -376,6 +365,119 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImpo
376
365
  }]
377
366
  }] });
378
367
 
368
+ /**
369
+ * Reference-counted wrapper around Ionic's `LoadingController` that keeps at most one loading
370
+ * indicator on screen across concurrent async work.
371
+ *
372
+ * @remarks
373
+ * Each {@link presentLoading} increments a counter and each {@link dismissLoading} decrements it; the
374
+ * indicator is presented on the `0 → 1` transition and dismissed on the `N → 0` transition. This
375
+ * removes the flicker / "stuck spinner" bugs that come from every service calling
376
+ * `LoadingController.create/dismiss` independently.
377
+ *
378
+ * All operations are serialized through an internal promise chain, so a `create → present` sequence
379
+ * can never interleave with a concurrent `dismiss`. That is what makes the counter race-safe: a
380
+ * dismiss that arrives while the indicator is still being presented runs *after* `present()` settles
381
+ * and therefore tears the element down instead of leaving it orphaned on screen.
382
+ *
383
+ * Always pair every `presentLoading()` with exactly one `dismissLoading()` — a `try/finally` is the
384
+ * safest shape.
385
+ *
386
+ * @example
387
+ * ```ts
388
+ * constructor(private readonly loading: KitLoadingController) {}
389
+ *
390
+ * async save(): Promise<void> {
391
+ * await this.loading.presentLoading({ message: 'Saving…' });
392
+ * try {
393
+ * await this.api.save();
394
+ * } finally {
395
+ * await this.loading.dismissLoading();
396
+ * }
397
+ * }
398
+ * ```
399
+ */
400
+ class KitLoadingController {
401
+ #loadingCtrl = inject(LoadingController);
402
+ /** Outstanding {@link presentLoading} calls not yet balanced by {@link dismissLoading}. */
403
+ #count = 0;
404
+ /** The single presented loading element, or `null` when none is on screen. */
405
+ #loading = null;
406
+ /**
407
+ * Serializes present/dismiss operations. Each call chains onto this promise, runs after the
408
+ * previous operation has fully settled, then reads {@link #count} and acts accordingly.
409
+ */
410
+ #queue = Promise.resolve();
411
+ /**
412
+ * Show the loading indicator, or join the one already on screen.
413
+ *
414
+ * @param options - Ionic loading options; only applied by the call that actually creates the
415
+ * indicator (the `0 → 1` transition). Ignored while an indicator is already present.
416
+ * @returns a Promise that resolves once the indicator is on screen (or immediately when one already is)
417
+ */
418
+ async presentLoading(options = {}) {
419
+ this.#count++;
420
+ try {
421
+ await this.#enqueue(async () => {
422
+ // Create only on the transition into "something is loading"; concurrent callers ride the same
423
+ // element. Re-check the count in case a dismiss already balanced this call while queued.
424
+ if (this.#count > 0 && this.#loading === null) {
425
+ const loading = await this.#loadingCtrl.create(options);
426
+ await loading.present();
427
+ this.#loading = loading;
428
+ }
429
+ });
430
+ }
431
+ catch (error) {
432
+ // Roll back the reference this call took: a failed create/present must not leave the counter
433
+ // elevated, otherwise a later cycle never reaches N → 0 and the spinner stays stuck on screen.
434
+ this.#count--;
435
+ throw error;
436
+ }
437
+ }
438
+ /**
439
+ * Release one reference; dismiss the indicator once the last reference is gone.
440
+ *
441
+ * @returns a Promise that resolves once the reference is released (and the indicator dismissed if
442
+ * this was the last one). No-ops when the counter is already at zero.
443
+ */
444
+ async dismissLoading() {
445
+ if (this.#count === 0) {
446
+ return;
447
+ }
448
+ this.#count--;
449
+ await this.#enqueue(async () => {
450
+ // Tear down only when the last consumer is gone. Because this runs after any in-flight
451
+ // present() has settled (via the queue), there is never an orphaned loading element.
452
+ if (this.#count === 0 && this.#loading !== null) {
453
+ const loading = this.#loading;
454
+ this.#loading = null;
455
+ await loading.dismiss();
456
+ }
457
+ });
458
+ }
459
+ /**
460
+ * Append `task` to the serialization chain and return its completion.
461
+ *
462
+ * @remarks
463
+ * The stored chain swallows rejections so a single failing operation cannot wedge every future
464
+ * overlay; the returned promise still rejects so the caller observes the error.
465
+ */
466
+ #enqueue(task) {
467
+ const run = this.#queue.then(task);
468
+ this.#queue = run.then(() => undefined, () => undefined);
469
+ return run;
470
+ }
471
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitLoadingController, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
472
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitLoadingController, providedIn: 'root' }); }
473
+ }
474
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitLoadingController, decorators: [{
475
+ type: Injectable,
476
+ args: [{
477
+ providedIn: 'root',
478
+ }]
479
+ }] });
480
+
379
481
  /**
380
482
  * The fleet's canonical "network error → offer to reload" alert, as a stateful controller.
381
483
  *
@@ -517,6 +619,52 @@ const kitPresentAuthFailedAlert = async (alertCtrl, options) => {
517
619
  await alert.present();
518
620
  };
519
621
 
622
+ /**
623
+ * Present a language picker and, on a new selection, reload the app at that locale's entry point.
624
+ *
625
+ * @remarks
626
+ * A plain function (the `ActionSheetController` is passed in, so nothing is injected) that unifies the
627
+ * language-switch flow duplicated across apps. On a changed selection while {@link enabled} it stashes
628
+ * the current path in `sessionStorage` (so the app can return the user to where they were), records the
629
+ * chosen locale in `localStorage` under `'locale'`, and calls `window.location.replace()` with the
630
+ * app-provided URL. Because it performs navigation, it is a standalone helper rather than part of a
631
+ * controller (mirroring `kitPresentReloadAlert` / `kitPresentAuthFailedAlert`). Centralizing it means a
632
+ * future improvement to the switch flow lands in every app at once.
633
+ *
634
+ * @param actionSheetCtrl - the Ionic `ActionSheetController`
635
+ * @param options - labels, locale list, and redirect configuration; see {@link KitLanguageActionSheetOptions}
636
+ * @returns a Promise that resolves once presented (and, on a new selection, after the reload is triggered)
637
+ * @example
638
+ * ```ts
639
+ * await kitPresentLanguageActionSheet(inject(ActionSheetController), {
640
+ * header: $localize`言語設定`,
641
+ * locales: [{ text: 'English', data: 'en-US' }, { text: '日本語', data: 'ja' }],
642
+ * cancelText: $localize`キャンセル`,
643
+ * currentLocale: normalizedLocale,
644
+ * currentPath: this.#router.url,
645
+ * pathnameStorageKey: StorageKeyEnum.pathnameBeforeRedirect,
646
+ * buildRedirectUrl: (locale) => location.origin + (localePath[locale.toLowerCase()] ?? '/index.html'),
647
+ * enabled: environment.production,
648
+ * });
649
+ * ```
650
+ */
651
+ const kitPresentLanguageActionSheet = async (actionSheetCtrl, options) => {
652
+ const actionSheet = await actionSheetCtrl.create({
653
+ header: options.header,
654
+ buttons: [
655
+ ...options.locales.map((locale) => ({ text: locale.text, data: locale.data })),
656
+ { text: options.cancelText, role: 'cancel' },
657
+ ],
658
+ });
659
+ await actionSheet.present();
660
+ const { data } = await actionSheet.onDidDismiss();
661
+ if (options.enabled && data && data !== options.currentLocale) {
662
+ sessionStorage.setItem(options.pathnameStorageKey, options.currentPath);
663
+ localStorage.setItem('locale', data);
664
+ window.location.replace(options.buildRedirectUrl(data));
665
+ }
666
+ };
667
+
520
668
  /**
521
669
  * Work around iOS `ion-input` autofill values not propagating to the Angular form model.
522
670
  *
@@ -687,6 +835,319 @@ const kitKeyboardInit = async (elementRef, type) => {
687
835
  ];
688
836
  };
689
837
 
838
+ /**
839
+ * Injection token carrying the {@link KitThemeConfig} for `KitThemeController`.
840
+ *
841
+ * @remarks
842
+ * Provide it through {@link provideKitTheme} rather than registering it directly.
843
+ */
844
+ const KIT_THEME_CONFIG = new InjectionToken('@rdlabo/ionic-angular-kit:theme');
845
+ /**
846
+ * Wire `KitThemeController` into the application.
847
+ *
848
+ * @param config - theme configuration: the storage key and the light/dark class lists
849
+ * @returns environment providers to add to the application's provider list
850
+ * @example
851
+ * ```ts
852
+ * bootstrapApplication(AppComponent, {
853
+ * providers: [
854
+ * provideKitTheme({
855
+ * storageKey: StorageKeyEnum.theme,
856
+ * darkClasses: ['ion-palette-dark', 'a2ui-dark'],
857
+ * lightClasses: ['a2ui-light'],
858
+ * }),
859
+ * ],
860
+ * });
861
+ * ```
862
+ */
863
+ const provideKitTheme = (config) => makeEnvironmentProviders([{ provide: KIT_THEME_CONFIG, useValue: config }]);
864
+
865
+ /**
866
+ * Light/dark theme controller: persists the user's choice, follows the OS setting until the user
867
+ * overrides it, toggles the configured palette classes, and syncs the native Android status bar.
868
+ *
869
+ * @remarks
870
+ * Consolidates the theme logic that had drifted across the fleet into one behavior. Notably it fixes
871
+ * a latent leak in one variant where the system-theme listener stayed registered after a manual
872
+ * toggle: {@link changeTheme} always detaches the listener via {@link removeEventListener} before
873
+ * applying the forced theme, so a later OS change can no longer silently flip an app the user pinned.
874
+ *
875
+ * - **Persistence** — the chosen mode is stored via {@link KitStorageService} under the configured key.
876
+ * - **Follow OS until overridden** — on boot with nothing stored, it tracks
877
+ * `prefers-color-scheme` (idempotent registration); once the user calls {@link changeTheme} it stops
878
+ * following and honors the explicit choice.
879
+ * - **Class toggling** — toggles {@link KitThemeConfig.darkClasses} on when dark and
880
+ * {@link KitThemeConfig.lightClasses} on when light, absorbing per-app CSS differences via config.
881
+ * - **Native status bar** — on Android native only, mirrors the Ionic behavior of setting the status
882
+ * bar style to match (iOS derives it from the web content, so it is intentionally left untouched).
883
+ *
884
+ * Subscribe to {@link themeSubject} to reflect the current mode in the UI (e.g. a settings toggle).
885
+ *
886
+ * @example
887
+ * ```ts
888
+ * // On boot (app.component):
889
+ * inject(KitThemeController).setDefaultThemeMode();
890
+ *
891
+ * // From a settings toggle:
892
+ * const theme = inject(KitThemeController);
893
+ * theme.themeSubject.subscribe((mode) => this.isDark.set(mode === 'dark'));
894
+ * theme.changeTheme(true);
895
+ * ```
896
+ */
897
+ class KitThemeController {
898
+ constructor() {
899
+ this.#storage = inject(KitStorageService);
900
+ this.#document = inject(DOCUMENT);
901
+ this.#config = inject(KIT_THEME_CONFIG);
902
+ /**
903
+ * Emits the active theme, seeded with `'light'`.
904
+ *
905
+ * @remarks
906
+ * A `BehaviorSubject`, so a late subscriber immediately receives the current mode; it emits again
907
+ * on every {@link setDefaultThemeMode} / {@link changeTheme} and on OS theme changes while following.
908
+ */
909
+ this.themeSubject = new BehaviorSubject('light');
910
+ }
911
+ #storage;
912
+ #document;
913
+ #config;
914
+ #prefersDark;
915
+ #onSystemThemeChange;
916
+ /**
917
+ * Apply the persisted theme, or start following the OS setting when nothing is stored yet.
918
+ *
919
+ * @remarks
920
+ * Call once on boot (e.g. from `app.component`).
921
+ *
922
+ * @returns a Promise that resolves once the initial theme has been applied
923
+ */
924
+ async setDefaultThemeMode() {
925
+ const stored = await this.#storage.get(this.#config.storageKey);
926
+ if (stored) {
927
+ // 保存済みの選択を強制し、OS 追従は解除する。
928
+ this.#unwatchSystemTheme();
929
+ return this.#applyTheme(stored === 'dark');
930
+ }
931
+ // 未保存 → OS の設定に追従する。
932
+ this.#watchSystemTheme();
933
+ }
934
+ /**
935
+ * Force a theme, persist it, and stop following the OS setting.
936
+ *
937
+ * @param isDark - `true` for the dark theme, `false` for light
938
+ * @returns a Promise that resolves once the theme has been persisted and applied
939
+ */
940
+ async changeTheme(isDark) {
941
+ this.#unwatchSystemTheme();
942
+ await this.#storage.set(this.#config.storageKey, isDark ? 'dark' : 'light');
943
+ await this.#applyTheme(isDark);
944
+ }
945
+ #watchSystemTheme() {
946
+ if (this.#prefersDark) {
947
+ // 既に監視中なら二重登録しない(冪等)。
948
+ return;
949
+ }
950
+ this.#prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
951
+ this.#onSystemThemeChange = (e) => void this.#applyTheme(e.matches);
952
+ this.#prefersDark.addEventListener('change', this.#onSystemThemeChange, { passive: true });
953
+ void this.#applyTheme(this.#prefersDark.matches);
954
+ }
955
+ #unwatchSystemTheme() {
956
+ if (this.#prefersDark && this.#onSystemThemeChange) {
957
+ this.#prefersDark.removeEventListener('change', this.#onSystemThemeChange);
958
+ }
959
+ this.#prefersDark = undefined;
960
+ this.#onSystemThemeChange = undefined;
961
+ }
962
+ async #applyTheme(isDark) {
963
+ if (Capacitor.isNativePlatform() && Capacitor.getPlatform() === 'android') {
964
+ await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light });
965
+ }
966
+ const root = this.#document.documentElement;
967
+ for (const cls of this.#config.darkClasses) {
968
+ root.classList.toggle(cls, isDark);
969
+ }
970
+ for (const cls of this.#config.lightClasses) {
971
+ root.classList.toggle(cls, !isDark);
972
+ }
973
+ this.themeSubject.next(isDark ? 'dark' : 'light');
974
+ }
975
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitThemeController, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
976
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitThemeController, providedIn: 'root' }); }
977
+ }
978
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.17", ngImport: i0, type: KitThemeController, decorators: [{
979
+ type: Injectable,
980
+ args: [{
981
+ providedIn: 'root',
982
+ }]
983
+ }] });
984
+
985
+ /**
986
+ * Request the native in-app review dialog, throttled so the user is prompted at most once per window.
987
+ *
988
+ * @remarks
989
+ * A plain function — no DI needed (`@capacitor/preferences`, `@capacitor-community/in-app-review` and
990
+ * `Capacitor` are all static), so the caller invokes it directly and passes its own config rather
991
+ * than injecting a controller. A no-op on non-native platforms. When enough time has elapsed since
992
+ * the last prompt (per {@link KitRequestReviewOptions.throttleMonths}, tracked under
993
+ * {@link KitRequestReviewOptions.storageKey}), it briefly waits for the app to settle, calls
994
+ * `InAppReview.requestReview()`, and records the new timestamp. The wait/throttle/record sequence
995
+ * was previously copy-pasted verbatim across the fleet; centralizing it means a single place to tune
996
+ * the prompt cadence.
997
+ *
998
+ * @param options - the storage key and throttle window; see {@link KitRequestReviewOptions}
999
+ * @returns a Promise that resolves once the request has been made (or immediately if throttled / on web)
1000
+ * @example
1001
+ * ```ts
1002
+ * await kitRequestReview({ storageKey: StorageEnum.lastRequestRate, throttleMonths: 3 });
1003
+ * ```
1004
+ */
1005
+ const kitRequestReview = async (options) => {
1006
+ if (!Capacitor.isNativePlatform()) {
1007
+ return;
1008
+ }
1009
+ await new Promise((resolve) => setTimeout(() => resolve(), 1000));
1010
+ const threshold = new Date();
1011
+ threshold.setMonth(threshold.getMonth() - options.throttleMonths);
1012
+ const { value } = await Preferences.get({ key: options.storageKey });
1013
+ if (!value || new Date(Number(value)).getTime() < threshold.getTime()) {
1014
+ await InAppReview.requestReview();
1015
+ await Preferences.set({ key: options.storageKey, value: new Date().getTime().toString() });
1016
+ }
1017
+ };
1018
+
1019
+ /**
1020
+ * Rotate a base64 image 90°, returning a new base64 data URL of the same MIME type.
1021
+ *
1022
+ * @remarks
1023
+ * Pure DOM/canvas work — no DI. Used before sending a label to the printer when the artwork must be
1024
+ * turned to match the tape orientation. Extracted verbatim from the fleet's printer services so the
1025
+ * canvas handling lives in one place.
1026
+ *
1027
+ * @param imageData - a base64 data URL (e.g. `data:image/png;base64,...`)
1028
+ * @returns a Promise resolving to the rotated image as a base64 data URL
1029
+ */
1030
+ const kitRotationImage = async (imageData) => {
1031
+ const imgType = imageData.substring(5, imageData.indexOf(';'));
1032
+ const image = new Image();
1033
+ const loaded = new Promise((resolve) => {
1034
+ image.onload = () => resolve();
1035
+ });
1036
+ image.src = imageData;
1037
+ await loaded;
1038
+ const canvas = document.createElement('canvas');
1039
+ const ctx = canvas.getContext('2d');
1040
+ canvas.width = image.height;
1041
+ canvas.height = image.width;
1042
+ ctx.rotate((90 * Math.PI) / 180);
1043
+ ctx.translate(0, -image.height);
1044
+ ctx.drawImage(image, 0, 0, image.width, image.height);
1045
+ return canvas.toDataURL(imgType);
1046
+ };
1047
+ /**
1048
+ * Render a DOM element to a base64 PNG for label printing, with the fleet's device-specific fixes.
1049
+ *
1050
+ * @remarks
1051
+ * Pure function — no DI (reads the platform from `Capacitor`, uses the global `document`), so the
1052
+ * caller presents its own loading UI around it. Centralizes the hard-won device quirks: on iOS it
1053
+ * pads width/height by 2px (otherwise the bottom is clipped), on Android it does not (the padding
1054
+ * introduces a black line). Retries the `dom-to-image-more` render up to 10 times because the first
1055
+ * pass can occasionally return empty. This is exactly the kind of plumbing where a future fix should
1056
+ * land in every app at once.
1057
+ *
1058
+ * @param element - the element to rasterize (e.g. the label preview host)
1059
+ * @param options - rendering options; see {@link KitDomToPngOptions}
1060
+ * @returns a Promise resolving to the PNG as a base64 data URL (empty string if every attempt failed)
1061
+ * @example
1062
+ * ```ts
1063
+ * const loading = await this.#loadingCtrl.create({ message: this.text.generating });
1064
+ * await loading.present();
1065
+ * const png = await kitDomToPng(this.preview().nativeElement, { rotate: true });
1066
+ * await loading.dismiss();
1067
+ * ```
1068
+ */
1069
+ const kitDomToPng = async (element, options) => {
1070
+ await new Promise((resolve) => requestAnimationFrame(resolve));
1071
+ const { clientHeight, clientWidth } = element;
1072
+ // デバイス毎の問題解決のため、px 調整。
1073
+ // iOS: ないと下が途切れる。Android: あると黒線が入る。
1074
+ const addClient = Capacitor.getPlatform() === 'ios' ? 2 : 0;
1075
+ let dataUrl = '';
1076
+ for (let i = 0; i < 10; i++) {
1077
+ const url = await domtoimage.toPng(element, {
1078
+ width: clientWidth + addClient,
1079
+ height: clientHeight + addClient,
1080
+ scale: options?.scale ?? 3,
1081
+ copyDefaultStyles: false,
1082
+ });
1083
+ if (url) {
1084
+ dataUrl = url;
1085
+ break;
1086
+ }
1087
+ }
1088
+ return options?.rotate ? kitRotationImage(dataUrl) : dataUrl;
1089
+ };
1090
+ /**
1091
+ * Assemble the Brother `BRLMPrintOptions` for a die-cut label print, minus the transport fields.
1092
+ *
1093
+ * @remarks
1094
+ * Pure function — no DI. Centralizes the fleet's canonical print settings (fit-page scale, centered,
1095
+ * best quality, threshold halftone, 2mm/1mm margins, `gapLength` 2.0) and the tape sizing derived
1096
+ * from the label's `W<width>H<height>` code. The caller merges the printer's `port` / `channelInfo`
1097
+ * onto the result before calling `BrotherPrint.printImage()`, so channel selection and loading UI stay
1098
+ * in the app.
1099
+ *
1100
+ * @param params - model, artwork, label, copies, and halftone threshold; see {@link KitBrotherPrintSettingsParams}
1101
+ * @returns the `BRLMPrintOptions` ready to be spread with `{ port, channelInfo }`
1102
+ * @example
1103
+ * ```ts
1104
+ * const settings = kitBuildBrotherPrintSettings({
1105
+ * modelName, printBase64, label, numberOfCopies: printOptions.printNum, halftoneThreshold: printOptions.halftoneThreshold,
1106
+ * });
1107
+ * await BrotherPrint.printImage({ ...settings, port: channel.port, channelInfo: channel.channelInfo });
1108
+ * ```
1109
+ */
1110
+ const kitBuildBrotherPrintSettings = (params) => {
1111
+ const startPoint = params.printBase64.indexOf(',');
1112
+ const tapeSize = params.label.match(/W(\d+)H(\d+)/);
1113
+ const tapeWidth = tapeSize && tapeSize.length >= 2 ? parseInt(tapeSize[1], 10) : 0;
1114
+ const tapeLength = tapeSize && tapeSize.length >= 3 ? parseInt(tapeSize[2], 10) : 0;
1115
+ // `BRLMPrintOptions` is a `QL | TD` union; a die-cut label legitimately carries fields from both
1116
+ // groups, so the object is composed via spreads to bypass the union's excess-property checks — the
1117
+ // same technique the source printer services used.
1118
+ return {
1119
+ ...{
1120
+ modelName: params.modelName,
1121
+ encodedImage: params.printBase64.slice(startPoint + 1),
1122
+ numberOfCopies: params.numberOfCopies,
1123
+ autoCut: true,
1124
+ scaleMode: BRLMPrinterScaleMode.FitPageAspect,
1125
+ imageRotation: BRLMPrinterImageRotation.Rotate0,
1126
+ verticalAlignment: BRLMPrinterVerticalAlignment.Center,
1127
+ horizontalAlignment: BRLMPrinterHorizontalAlignment.Center,
1128
+ printQuality: BRLMPrinterPrintQuality.Best,
1129
+ },
1130
+ ...{
1131
+ labelName: params.label,
1132
+ },
1133
+ ...{
1134
+ paperType: BRLMPrinterCustomPaperType.dieCutPaper,
1135
+ paperUnit: BRLMPrinterCustomPaperUnit.mm,
1136
+ halftone: BRLMPrinterHalftone.Threshold,
1137
+ halftoneThreshold: params.halftoneThreshold,
1138
+ tapeWidth: Number(tapeWidth.toFixed(1)),
1139
+ tapeLength: Number(tapeLength.toFixed(1)),
1140
+ gapLength: 2.0,
1141
+ marginTop: 1.0,
1142
+ marginRight: 2.0,
1143
+ marginBottom: 1.0,
1144
+ marginLeft: 2.0,
1145
+ paperMarkPosition: 0,
1146
+ paperMarkLength: 0,
1147
+ },
1148
+ };
1149
+ };
1150
+
690
1151
  /**
691
1152
  * Injection token that carries the {@link KitAuthConfig} to the authentication guards.
692
1153
  */
@@ -1175,6 +1636,65 @@ const disableHandler = async (event, work) => {
1175
1636
  target.disabled = false;
1176
1637
  };
1177
1638
 
1639
+ /**
1640
+ * Toggle the `disabled` flag of a signal-held `ion-infinite-scroll` / `ion-refresher` element.
1641
+ *
1642
+ * @remarks
1643
+ * A tiny pure helper for the common pattern of stashing the completing scroll/refresher element in a
1644
+ * signal (e.g. captured from the event) and later enabling/disabling it — for instance disabling
1645
+ * infinite scroll once the last page has loaded. A no-op when the signal holds no element.
1646
+ *
1647
+ * @param completeEvent - a writable signal holding the infinite-scroll / refresher element (or nullish)
1648
+ * @param disabled - the value to set on the element's `disabled` property
1649
+ * @example
1650
+ * ```ts
1651
+ * const infinite = signal<HTMLIonInfiniteScrollElement | null>(null);
1652
+ * // ...on ionInfinite: infinite.set(ev.target); ...when no more pages:
1653
+ * kitChangeEventDisabled(infinite, true);
1654
+ * ```
1655
+ */
1656
+ const kitChangeEventDisabled = (completeEvent, disabled) => {
1657
+ const event = completeEvent();
1658
+ if (event) {
1659
+ event.disabled = disabled;
1660
+ }
1661
+ };
1662
+
1663
+ /**
1664
+ * Observe an Ionic page's "is currently entered" state from its lifecycle DOM events.
1665
+ *
1666
+ * @remarks
1667
+ * Emits `true` on `ionViewDidEnter` and `false` on `ionViewWillEnter` / `ionViewWillLeave`, seeded
1668
+ * with `false` via `startWith`. Useful to pause/resume work (timers, video, expensive rendering)
1669
+ * while a page is off-screen in Ionic's stack navigation, without wiring the four lifecycle hooks by
1670
+ * hand. The listeners are removed when the Observable is unsubscribed.
1671
+ *
1672
+ * @param el - an `ElementRef` for the page host element (the `ion-page`)
1673
+ * @returns an Observable that emits whether the page is currently entered
1674
+ * @example
1675
+ * ```ts
1676
+ * export class FeedPage {
1677
+ * readonly #host = inject(ElementRef);
1678
+ * readonly isEntered = toSignal(kitCreateDidEnter(this.#host), { initialValue: false });
1679
+ * }
1680
+ * ```
1681
+ */
1682
+ const kitCreateDidEnter = (el) => {
1683
+ return new Observable((observer) => {
1684
+ const willEnter = () => observer.next(false);
1685
+ const didEnter = () => observer.next(true);
1686
+ const willLeave = () => observer.next(false);
1687
+ el.nativeElement.addEventListener('ionViewWillEnter', willEnter);
1688
+ el.nativeElement.addEventListener('ionViewDidEnter', didEnter);
1689
+ el.nativeElement.addEventListener('ionViewWillLeave', willLeave);
1690
+ return () => {
1691
+ el.nativeElement.removeEventListener('ionViewWillEnter', willEnter);
1692
+ el.nativeElement.removeEventListener('ionViewDidEnter', didEnter);
1693
+ el.nativeElement.removeEventListener('ionViewWillLeave', willLeave);
1694
+ };
1695
+ }).pipe(startWith(false));
1696
+ };
1697
+
1178
1698
  /*
1179
1699
  * Public API Surface of @rdlabo/ionic-angular-kit
1180
1700
  */
@@ -1184,5 +1704,5 @@ const disableHandler = async (event, work) => {
1184
1704
  * Generated bundle index. Do not edit.
1185
1705
  */
1186
1706
 
1187
- export { KIT_AUTH_CONFIG, KIT_HTTP_CONFIG, KIT_OVERLAY_CONFIG, KitAutofillDirective, KitOverlayController, KitReloadAlertController, KitStorageService, arrayConcatById, disableHandler, kitAuthInterceptor, kitImpact, kitKeyboardInit, kitPresentAuthFailedAlert, kitRequireAuthorizedGuard, kitRequireConfirmingGuard, kitRequiredUnauthorizedGuard, objectEqual, provideKitAuth, provideKitHttp, provideKitOverlay };
1707
+ export { KIT_AUTH_CONFIG, KIT_HTTP_CONFIG, KIT_OVERLAY_CONFIG, KIT_THEME_CONFIG, KitAutofillDirective, KitLoadingController, KitOverlayController, KitReloadAlertController, KitStorageService, KitThemeController, arrayConcatById, disableHandler, kitAuthInterceptor, kitBuildBrotherPrintSettings, kitChangeEventDisabled, kitCreateDidEnter, kitDomToPng, kitImpact, kitKeyboardInit, kitPresentAuthFailedAlert, kitPresentLanguageActionSheet, kitRequestReview, kitRequireAuthorizedGuard, kitRequireConfirmingGuard, kitRequiredUnauthorizedGuard, kitRotationImage, objectEqual, provideKitAuth, provideKitHttp, provideKitOverlay, provideKitTheme };
1188
1708
  //# sourceMappingURL=rdlabo-ionic-angular-kit.mjs.map