diff --git a/src/app/feature/nav-stack/nav-stack-back.service.ts b/src/app/feature/nav-stack/nav-stack-back.service.ts new file mode 100644 index 000000000..306f3b717 --- /dev/null +++ b/src/app/feature/nav-stack/nav-stack-back.service.ts @@ -0,0 +1,43 @@ +import { computed, effect, Injectable } from "@angular/core"; +import { NavStackService } from "./nav-stack.service"; +import { App } from "@capacitor/app"; +import { Capacitor, PluginListenerHandle } from "@capacitor/core"; + +@Injectable({ providedIn: "root" }) +/** + * Utility service to help manage intercepting back button presses when working with nav stacks + * Currently just supports hardware back button press on android, used to dismiss top nav stack + */ +export class NavStackBackService { + /** Track whether navStacks are currently open or not to enable back interception */ + private navStacksOpen = computed(() => this.navStackService.openNavStacks().length > 0); + + private backButtonListener: PluginListenerHandle; + + constructor(private navStackService: NavStackService) { + effect(async () => { + const platform = Capacitor.getPlatform(); + if (platform !== "android") return; + + if (this.navStacksOpen()) { + await this.addBackButtonListener(); + } else { + await this.removeBackButtonListener(); + } + }); + } + + private async addBackButtonListener() { + const listener = await App.addListener("backButton", async (e) => { + await this.navStackService.closeTopNavStack(); + }); + this.backButtonListener = listener; + } + + private async removeBackButtonListener() { + if (this.backButtonListener) { + await this.backButtonListener.remove(); + this.backButtonListener = undefined; + } + } +} diff --git a/src/app/feature/nav-stack/nav-stack.module.ts b/src/app/feature/nav-stack/nav-stack.module.ts index 6bef7e4de..671d616c9 100644 --- a/src/app/feature/nav-stack/nav-stack.module.ts +++ b/src/app/feature/nav-stack/nav-stack.module.ts @@ -7,13 +7,19 @@ import { TemplateComponentsModule } from "../../shared/components/template/templ import { TemplateActionRegistry } from "src/app/shared/components/template/services/instance/template-action.registry"; import { NavStackService } from "./nav-stack.service"; import { NavStackActionFactory } from "./nav-stack.actions"; +import { NavStackBackService } from "./nav-stack-back.service"; @NgModule({ declarations: [NavStackComponent], imports: [CommonModule, IonicModule, TemplateComponentModule, TemplateComponentsModule], }) export class NavStackModule { - constructor(templateActionRegistry: TemplateActionRegistry, navStackService: NavStackService) { + constructor( + templateActionRegistry: TemplateActionRegistry, + navStackService: NavStackService, + // include navStackBack service to enable back button management + navStackBackService: NavStackBackService + ) { const { nav_stack } = new NavStackActionFactory(navStackService); templateActionRegistry.register({ nav_stack }); } diff --git a/src/app/feature/nav-stack/nav-stack.service.spec.ts b/src/app/feature/nav-stack/nav-stack.service.spec.ts index e5faf2d30..1fdf6e62b 100644 --- a/src/app/feature/nav-stack/nav-stack.service.spec.ts +++ b/src/app/feature/nav-stack/nav-stack.service.spec.ts @@ -68,7 +68,7 @@ describe("NavStackService", () => { const modalSpy = await pushNavStack(); expect(modalSpy.present).toHaveBeenCalled(); - expect(service["openNavStacks"].length).toEqual(1); + expect(service.openNavStacks().length).toEqual(1); }); it("tracks stack index as attributes", async () => { @@ -82,17 +82,17 @@ describe("NavStackService", () => { const modalSpy1 = await pushNavStack(); const modalSpy2 = await pushNavStack(); - expect(service["openNavStacks"].length).toEqual(2); + expect(service.openNavStacks().length).toEqual(2); expect(modalSpy1.getAttribute("data-nav-stack-index")).toEqual("0"); expect(modalSpy2.getAttribute("data-nav-stack-index")).toEqual("1"); }); - it("should remove the modal from service['openNavStacks'] on dismissal", async () => { + it("should remove the modal from service.openNavStacks on dismissal", async () => { const modalSpy = await pushNavStack(); await service.closeTopNavStack(); expect(modalSpy.dismiss).toHaveBeenCalled(); - expect(service["openNavStacks"].length).toBe(0); + expect(service.openNavStacks().length).toBe(0); }); it("should close the top modal in the stack", async () => { @@ -103,8 +103,8 @@ describe("NavStackService", () => { expect(modalSpy1.dismiss).not.toHaveBeenCalled(); expect(modalSpy2.dismiss).toHaveBeenCalled(); - expect(service["openNavStacks"].length).toBe(1); - expect(service["openNavStacks"][0]).toBe(modalSpy1); + expect(service.openNavStacks().length).toBe(1); + expect(service.openNavStacks()[0]).toBe(modalSpy1); }); it("should close all modals in the stack", async () => { @@ -115,6 +115,6 @@ describe("NavStackService", () => { expect(modalSpy1.dismiss).toHaveBeenCalled(); expect(modalSpy2.dismiss).toHaveBeenCalled(); - expect(service["openNavStacks"].length).toBe(0); + expect(service.openNavStacks().length).toBe(0); }); }); diff --git a/src/app/feature/nav-stack/nav-stack.service.ts b/src/app/feature/nav-stack/nav-stack.service.ts index 493969395..2405bc467 100644 --- a/src/app/feature/nav-stack/nav-stack.service.ts +++ b/src/app/feature/nav-stack/nav-stack.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@angular/core"; +import { Injectable, signal } from "@angular/core"; import { ModalController } from "@ionic/angular"; import { INavStackConfig, NavStackComponent } from "./components/nav-stack/nav-stack.component"; import { SyncServiceBase } from "src/app/shared/services/syncService.base"; @@ -9,7 +9,8 @@ interface NavStackModal extends HTMLIonModalElement {} providedIn: "root", }) export class NavStackService extends SyncServiceBase { - private openNavStacks: HTMLIonModalElement[] = []; + public openNavStacks = signal([]); + private readonly MAX_NAV_STACKS = 10; constructor(private modalCtrl: ModalController) { super("navStack"); @@ -21,18 +22,22 @@ export class NavStackService extends SyncServiceBase { * Await and remove from openNavStacks array on dismiss */ public async pushNavStack(navStackConfig: INavStackConfig) { + if (this.openNavStacks().length >= this.MAX_NAV_STACKS) { + console.warn(`[NAV STACK] Maximum number of nav stacks reached: ${this.MAX_NAV_STACKS}`); + return null; + } const modal = await this.createNavStackModal(navStackConfig); await this.presentAndTrackModal(modal); return modal; } - public async closeAllNavStacks() { - await Promise.all(this.openNavStacks.map(async (navStack) => await navStack.dismiss())); + public closeAllNavStacks() { + return Promise.all(this.openNavStacks().map((navStack) => navStack.dismiss())); } public async closeTopNavStack() { - if (this.openNavStacks.length === 0) return; - await this.closeNavStack(this.openNavStacks.length - 1); + if (this.openNavStacks().length === 0) return; + await this.closeNavStack(this.openNavStacks().length - 1); } /** @@ -49,26 +54,45 @@ export class NavStackService extends SyncServiceBase { } private async presentAndTrackModal(modal: NavStackModal) { - const navStackIndex = this.openNavStacks.length; + const navStackIndex = this.openNavStacks().length; modal.setAttribute("data-nav-stack-index", navStackIndex.toString()); modal.style.setProperty("--nav-stack-index", navStackIndex.toString()); - // Remove array entry whenever modal is dismissed - modal.onWillDismiss().then(() => { - const index = this.getNavStackIndex(modal); - if (index === -1) return; - this.openNavStacks.splice(index, 1); - }); + // Handle nav stack dismissal here (whether programmatically from service or from nav-stack component) + modal.onWillDismiss().then(() => this.handleNavStackDismissal(modal)); + + this.addNavStackToArray(modal); await modal.present(); - this.openNavStacks.push(modal); + } + + private handleNavStackDismissal(modal: NavStackModal) { + const index = this.getNavStackIndex(modal); + if (index === -1) return; + this.removeNavStackFromArray(index); } private getNavStackIndex(modalElement: HTMLIonModalElement) { - return this.openNavStacks.indexOf(modalElement); + return this.openNavStacks().indexOf(modalElement); } + /** + * Programmatically dismiss a nav-stack. Handling removing from openNavStacks array is done elsewhere + * to handle both programmatic dismissal and dismissal from nav-stack component (e.g. via close button) + */ private async closeNavStack(index: number) { - await this.openNavStacks[index].dismiss(); - this.openNavStacks.splice(index, 1); + const modal = this.openNavStacks()[index]; + if (modal) { + await modal.dismiss(); + } + } + + private addNavStackToArray(modal: NavStackModal) { + // use array destructure to create new object + this.openNavStacks.update((stacks) => [...stacks, modal]); + } + + private removeNavStackFromArray(index: number) { + // use filter instead of splice to avoid modifying original object + this.openNavStacks.update((stacks) => stacks.filter((v, i) => i !== index)); } }