Source

lib/utils/NgxSpinner.ts

import { LoadingOptions } from '@ionic/angular';
import { loadingController } from '@ionic/core';
import { IDecafSpinner } from 'node_modules/@decaf-ts/ui-decorators/lib/types/ui/interfaces/index.mjs';
import { getLogger } from '../engine/helpers';

/**
 * @module lib/utils/NgxSpinner
 * @description Loading overlay utility for Ionic Angular applications.
 * @summary Exposes the `NgxSpinner` singleton helper and the `getNgxSpinner()` factory used
 * to manage Ionic loading overlays with progress updates, message changes, and safe dismissal.
 * The module centralizes loading state so components can request a shared spinner instance
 * instead of handling overlay lifecycles directly.
 */

/**
 * @description Cached singleton factory instance for the loading utility.
 * @summary Holds the shared `NgxSpinner` reference returned by `getNgxSpinner()`. This
 * ensures the application reuses the same loading controller wrapper across calls and
 * avoids competing overlays or inconsistent progress state.
 *
 * @private
 * @type {NgxSpinner}
 */
let instance: NgxSpinner;

/**
 * @description Loading overlay manager for Ionic UI flows.
 * @summary Wraps `loadingController` with a singleton-friendly API for showing, updating,
 * and dismissing loading overlays. It keeps track of the active message and progress value
 * so callers can update the same overlay instead of creating new ones.
 *
 * @class NgxSpinner
 * @implements {IDecafSpinner}
 */
export class NgxSpinner implements IDecafSpinner {
  /**
   * @description Active Ionic loading element reference.
   * @summary Stores the overlay currently presented to the user so it can be updated or
   * dismissed later. The value is `undefined` when no spinner is visible.
   *
   * @private
   * @type {HTMLIonLoadingElement | undefined}
   * @memberOf NgxSpinner
   */
  private instance!: HTMLIonLoadingElement | undefined;

  /**
   * @description Current loading message.
   * @summary Mirrors the latest message shown by the overlay so follow-up updates can reuse
   * it when progress mode is enabled.
   *
   * @private
   * @type {string}
   * @memberOf NgxSpinner
   */
  private message!: string;

  /**
   * @description Current progress percentage for the visible overlay.
   * @summary Tracks the last emitted progress value so callers can advance the spinner in
   * controlled increments without regressing or exceeding the expected range.
   *
   * @private
   * @type {number}
   * @default 0
   * @memberOf NgxSpinner
   */
  private progress: number = 0;

  /**
   * @description Default Ionic loading configuration.
   * @summary Provides the baseline overlay styling and behavior used whenever no explicit
   * options are supplied. Callers can override individual fields per invocation.
   *
   * @private
   * @type {LoadingOptions}
   * @memberOf NgxSpinner
   */
  private options: LoadingOptions = {
    cssClass: 'aeon-loading',
    duration: undefined,
    message: 'Carregando...',
    spinner: 'crescent',
    backdropDismiss: false,
    showBackdrop: true,
    animated: true,
  };

  /**
   * @description Checks whether a loading overlay is visible.
   * @summary Returns `true` when the singleton has an active Ionic loading element and
   * `false` otherwise. Useful for preventing duplicate overlays before starting a new task.
   *
   * @returns {boolean} True if loading overlay is visible, false otherwise
   * @memberOf NgxSpinner
   *
   * @example
   * ```typescript
   * const loading = getCmxLoading();
   *
   * if (!loading.isVisible()) {
   *   await loading.show('Loading data...');
   * }
   *
   * // Conditional operations based on loading state
   * if (loading.isVisible()) {
   *   await loading.update('Processing...', 50);
   * }
   * ```
   */
  isVisible(): boolean {
    return !!this.instance;
  }

  /**
   * @description Presents the loading overlay with a message and optional options.
   * @summary Creates the overlay on first call and reuses the existing one on subsequent
   * calls. When a loading object is passed as the first argument, its message becomes the
   * displayed text and the remaining fields are treated as options.
   *
   * @param {string | LoadingOptions} message - Message text or a full loading options object
   * @param {LoadingOptions} [options] - Optional configuration for the loading overlay
   * @returns {Promise<void>} Promise that resolves when the loading overlay is displayed
   * @memberOf NgxSpinner
   *
   * @example
   * ```typescript
   * const loading = getCmxLoading();
   *
   * // Simple loading with message
   * await loading.show('Loading...');
   *
   * // Loading with custom options
   * await loading.show('Processing data...', {
   *   duration: 5000,
   *   spinner: 'bubbles',
   *   cssClass: 'custom-loading'
   * });
   *
   * // Loading with backdrop dismiss
   * await loading.show('Please wait...', {
   *   backdropDismiss: true
   * });
   * ```
   */
  async show(message: string | LoadingOptions, options?: LoadingOptions): Promise<void> {
    if (typeof message === 'object') {
      options = message;
      message = options.message as string;
    }
    try {
      this.message = message;
      if (this.isVisible()) return this.update(message);
      this.instance = (await loadingController.create(
        await this.getOptions(options, message)
      )) as HTMLIonLoadingElement;
      await this.instance.present();
    } catch (error: unknown) {
      getLogger(this.show).error((error as Error)?.message || String(error));
    }
  }

  /**
   * @description Updates the overlay message and optionally advances progress.
   * @summary Reuses the current loading element when one is visible, otherwise opens a new
   * one with a fallback duration. A numeric second argument advances the stored progress,
   * while `true` treats the first argument as the progress label suffix.
   *
   * @param {string} message - The new message to display
   * @param {boolean | number} [isProgressUpdate=false] - Progress update mode or percentage value
   * @returns {Promise<void>} Promise that resolves when the update is complete
   * @memberOf NgxSpinner
   *
   * @example
   * ```typescript
   * const loading = getCmxLoading();
   * await loading.show('Starting process...');
   *
   * // Simple message update
   * await loading.update('Processing data...');
   *
   * // Progress update with percentage
   * await loading.update('Loading files...', 25);
   * await loading.update('Processing files...', 50);
   * await loading.update('Finalizing...', 90);
   *
   * // Progress update with boolean flag
   * await loading.update('75', true); // Shows "Original Message (75%)"
   * ```
   */
  async update(message: string, isProgressUpdate: boolean | number = false): Promise<void> {
    if (!this.instance) return await this.show(message, { duration: 5000 });

    if (isProgressUpdate) {
      if (typeof isProgressUpdate === 'number' && Number(isProgressUpdate)) {
        if (isProgressUpdate <= this.progress) isProgressUpdate = this.progress + 10;
        this.progress = isProgressUpdate;
        if (this.progress > 100) this.progress = 99;
        this.instance.message = `${message} (${this.progress}%)`;
      } else {
        this.instance.message = `${this.message} (${message}%)`;
      }
    } else {
      this.message = this.instance.message = message;
    }
  }

  /**
   * @description Dismisses the current loading overlay.
   * @summary Clears the cached overlay reference, resets progress, and dismisses the visible
   * Ionic loading element if one exists.
   *
   * @returns {Promise<void>} Promise that resolves when the loading overlay is removed
   * @memberOf NgxSpinner
   *
   * @example
   * ```typescript
   * const loading = getCmxLoading();
   * await loading.show('Loading...');
   *
   * // Perform some async operation
   * await performDataOperation();
   *
   * // Remove loading overlay
   * await loading.remove();
   *
   * // Safe to call even if no loading is present
   * await loading.remove(); // Logs warning but doesn't throw error
   * ```
   */
  async remove(): Promise<void> {
    const loading = this.instance;
    this.instance = undefined;
    this.progress = 0;
    if (loading) {
      await loading.dismiss();
    }
  }

  /**
   * @description Merges custom loading options with the defaults.
   * @summary Produces the final overlay configuration by combining the class defaults,
   * caller-provided overrides, and the effective message value.
   *
   * @param {LoadingOptions} [options={}] - Custom loading options to merge
   * @param {string} [message] - Optional message to override options.message
   * @returns {LoadingOptions} The merged configuration object
   * @memberOf NgxSpinner
   *
   * @example
   * ```typescript
   * // Internal usage - automatically called by show() method
   * const config = await loading.getOptions({
   *   duration: 3000,
   *   spinner: 'bubbles'
   * }, 'Custom message');
   *
   * // Result: default options + custom options + message override
   * ```
   */
  async getOptions(options: LoadingOptions = {}, message?: string): Promise<LoadingOptions> {
    return Object.assign({}, this.options, options, {
      message: message || this.options['message'],
    });
  }

  /**
   * @description Retrieves the current loading message.
   * @summary Returns the latest message tracked by the spinner instance so callers can
   * inspect the overlay state without accessing the Ionic element directly.
   *
   * @returns {Promise<string>} Promise resolving to the current loading message
   * @memberOf NgxSpinner
   *
   * @example
   * ```typescript
   * const loading = getCmxLoading();
   * await loading.show('Processing...');
   *
   * const currentMessage = await loading.getMessage();
   * console.log('Current loading message:', currentMessage); // "Processing..."
   *
   * // Useful for debugging or state management
   * if (await loading.getMessage() === 'Processing...') {
   *   await loading.update('Almost done...', 90);
   * }
   * ```
   */
  async getMessage(): Promise<string> {
    return this.message;
  }
}

/**
 * @description Returns the shared `NgxSpinner` instance.
 * @summary Lazily creates the singleton on first call and reuses it afterwards. The
 * optional options argument is accepted for API compatibility but does not alter the
 * cached instance once it exists.
 *
 * @param {Partial<LoadingOptions>} [options={}] - Optional spinner configuration placeholder
 * @returns {NgxSpinner} The singleton NgxSpinner instance
 * @memberOf NgxSpinner
 *
 * @example
 * ```typescript
 * // Get the singleton instance
 * const loading = getCmxLoading();
 *
 * // All calls return the same instance
 * const loading1 = getCmxLoading();
 * const loading2 = getCmxLoading();
 * console.log(loading1 === loading2); // true
 *
 * // Use throughout the application
 * await loading.show('Loading...');
 * // ... later in another component ...
 * await getCmxLoading().update('Still loading...', 50);
 * ```
 */
export function getNgxSpinner(options: Partial<LoadingOptions> = {}): NgxSpinner {
  if (!instance) {
    instance = new NgxSpinner();
  }
  return instance;
}