feat(ui): animated boot splash and loading screen

Show a Lottie-animated splash while the app bundle boots and dismiss it
once WebAppComponent initialises, plus a reusable mifosx-loading-screen
component with loading and error animations. lottie.min.js is served
from the lottie-web package instead of being vendored into src/assets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 15:34:31 +05:30
co-authored by Claude Opus 5.5
parent 799a6930b8
commit 3928936b93
16 changed files with 6938 additions and 38 deletions
@@ -0,0 +1,88 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
@if (active) {
@if (currentMode === 'overlay') {
<!-- Mifos Mobile Transaction / Action Progress Overlay -->
<div class="cbs-loading-backdrop" (click)="preventDismiss($event)">
<div class="cbs-loading-dialog" role="dialog" aria-modal="true" [attr.aria-label]="title || 'Loading'">
<div class="cbs-lottie-container" #lottieContainer>
<!-- Vector Fallback if Lottie is loading -->
@if (!lottieLoaded) {
<div class="cbs-vector-spinner">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
<div class="cbs-loading-dialog__content">
<h3 class="cbs-loading-dialog__title">{{ title || 'CBS Nexora' }}</h3>
<p class="cbs-loading-dialog__message">{{ message || 'Please wait...' }}</p>
</div>
<div class="cbs-loading-pulse-bar">
<div class="cbs-pulse-bar__indicator"></div>
</div>
</div>
</div>
}
@if (currentMode === 'fullscreen') {
<!-- Full-Screen App Boot / Splash Screen -->
<div class="cbs-fullscreen-loader">
<div class="cbs-fullscreen-loader__brand">
<div class="cbs-brand-icon">
<svg width="40" height="40" viewBox="0 0 32 32" aria-hidden="true">
<rect x="1" y="1" width="14" height="14" rx="2" fill="#2563eb" />
<rect x="17" y="1" width="14" height="14" rx="2" fill="#0ea5e9" />
<rect x="1" y="17" width="14" height="14" rx="2" fill="#10b981" />
<rect x="17" y="17" width="14" height="14" rx="2" fill="#94a3b8" />
</svg>
</div>
<h2 class="cbs-brand-name">{{ title || 'CBS Nexora' }}</h2>
<span class="cbs-brand-sub">Core Banking System</span>
</div>
<div class="cbs-lottie-container cbs-lottie-container--large" #lottieContainer>
@if (!lottieLoaded) {
<div class="cbs-vector-spinner cbs-vector-spinner--large">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
<div class="cbs-progress-track">
<div class="cbs-progress-fill"></div>
</div>
<p class="cbs-fullscreen-loader__status">{{ message || 'Connecting to Core Banking Engine...' }}</p>
</div>
}
@if (currentMode === 'inline') {
<!-- Inline Component Loader -->
<div class="cbs-inline-loader">
<div class="cbs-lottie-container cbs-lottie-container--compact" #lottieContainer>
@if (!lottieLoaded) {
<div class="cbs-vector-spinner cbs-vector-spinner--compact">
<div class="spinner-ring ring-outer"></div>
<div class="spinner-ring ring-mid"></div>
<div class="spinner-ring ring-inner"></div>
</div>
}
</div>
@if (message) {
<p class="cbs-inline-loader__message">{{ message }}</p>
}
</div>
}
}
@@ -0,0 +1,341 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
:host {
display: block;
}
/* ==========================================================================
Overlay Mode (MifosProgressIndicatorOverlay / MifosLoadingDialog)
========================================================================== */
.cbs-loading-backdrop {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
background: rgb(9 13 22 / 72%);
backdrop-filter: blur(8px);
animation: backdrop-fade-in 0.25s ease-out forwards;
}
.cbs-loading-dialog {
position: relative;
width: 90%;
max-width: 340px;
padding: 28px 24px;
background: #111827;
color: #f8fafc;
border-radius: 20px;
border: 1px solid rgb(255 255 255 / 12%);
box-shadow:
0 25px 60px -15px rgb(0 0 0 / 65%),
0 0 35px rgb(37 99 235 / 18%);
text-align: center;
overflow: hidden;
animation: dialog-scale-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
:host-context([data-theme='light']) .cbs-loading-dialog,
:host-context(.light-theme) .cbs-loading-dialog {
background: #fff;
color: #0f172a;
border-color: rgb(0 0 0 / 8%);
box-shadow:
0 25px 50px -12px rgb(0 0 0 / 25%),
0 0 25px rgb(37 99 235 / 12%);
}
.cbs-loading-dialog__content {
margin-top: 10px;
margin-bottom: 16px;
}
.cbs-loading-dialog__title {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
margin: 0 0 6px;
}
.cbs-loading-dialog__message {
font-size: 0.82rem;
color: #94a3b8;
line-height: 1.45;
margin: 0;
}
:host-context([data-theme='light']) .cbs-loading-dialog__message,
:host-context(.light-theme) .cbs-loading-dialog__message {
color: #64748b;
}
/* Pulse bar at dialog bottom */
.cbs-loading-pulse-bar {
width: 100%;
height: 3px;
background: rgb(255 255 255 / 8%);
border-radius: 99px;
overflow: hidden;
position: relative;
margin-top: 6px;
}
.cbs-pulse-bar__indicator {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 40%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
border-radius: 99px;
animation: pulse-bar-slide 1.4s infinite ease-in-out;
}
/* ==========================================================================
Full-Screen Mode (App Boot / Splash)
========================================================================== */
.cbs-fullscreen-loader {
position: fixed;
inset: 0;
z-index: 99999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: radial-gradient(circle at center, #0f172a 0%, #090d16 100%);
color: #f8fafc;
padding: 24px;
text-align: center;
}
:host-context([data-theme='light']) .cbs-fullscreen-loader,
:host-context(.light-theme) .cbs-fullscreen-loader {
background: radial-gradient(circle at center, #fff 0%, #f1f5f9 100%);
color: #0f172a;
}
.cbs-fullscreen-loader__brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.cbs-brand-icon {
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, #1e3a8a 0%, #0284c7 100%);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 24px rgb(14 165 233 / 35%);
margin-bottom: 6px;
}
.cbs-brand-name {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: -0.02em;
margin: 0;
}
.cbs-brand-sub {
font-size: 0.8rem;
color: #94a3b8;
font-weight: 500;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.cbs-progress-track {
width: 240px;
height: 5px;
background: rgb(255 255 255 / 10%);
border-radius: 99px;
overflow: hidden;
margin: 18px 0 12px;
position: relative;
}
.cbs-progress-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
width: 45%;
background: linear-gradient(90deg, #2563eb, #38bdf8);
border-radius: 99px;
box-shadow: 0 0 12px rgb(56 189 248 / 50%);
animation: pulse-bar-slide 1.5s infinite ease-in-out;
}
.cbs-fullscreen-loader__status {
font-size: 0.82rem;
color: #94a3b8;
font-family: inherit;
margin: 0;
}
/* ==========================================================================
Inline Mode
========================================================================== */
.cbs-inline-loader {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 16px;
text-align: center;
}
.cbs-inline-loader__message {
font-size: 0.82rem;
color: #94a3b8;
margin-top: 10px;
}
/* ==========================================================================
Lottie & Vector Spinner Dimensions
========================================================================== */
.cbs-lottie-container {
width: 110px;
height: 110px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.cbs-lottie-container--large {
width: 140px;
height: 140px;
}
.cbs-lottie-container--compact {
width: 64px;
height: 64px;
}
/* Vector Spinner Fallback (CSS Multi-Ring Loader) */
.cbs-vector-spinner {
width: 72px;
height: 72px;
position: relative;
}
.cbs-vector-spinner--large {
width: 96px;
height: 96px;
}
.cbs-vector-spinner--compact {
width: 44px;
height: 44px;
}
.spinner-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid transparent;
}
.ring-outer {
border-top-color: #2563eb;
border-right-color: #2563eb;
animation: spin-outer 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
.ring-mid {
inset: 6px;
border-bottom-color: #0ea5e9;
border-left-color: #0ea5e9;
animation: spin-mid 1.6s cubic-bezier(0.5, 0, 0.5, 1) infinite reverse;
}
.ring-inner {
inset: 13px;
border-top-color: #10b981;
animation: spin-inner 0.9s cubic-bezier(0.5, 0, 0.5, 1) infinite;
}
/* ==========================================================================
Keyframe Animations
========================================================================== */
@keyframes backdrop-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes dialog-scale-in {
from {
opacity: 0;
transform: scale(0.92);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes pulse-bar-slide {
0% {
left: -40%;
width: 30%;
}
50% {
left: 35%;
width: 50%;
}
100% {
left: 100%;
width: 30%;
}
}
@keyframes spin-outer {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-mid {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes spin-inner {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,168 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import {
Component,
OnInit,
OnDestroy,
Input,
ElementRef,
ViewChild,
AfterViewInit,
inject,
ChangeDetectorRef
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subscription } from 'rxjs';
import { LoadingScreenService, LoadingConfig } from './loading-screen.service';
declare const lottie: any;
@Component({
selector: 'mifosx-loading-screen',
standalone: true,
imports: [CommonModule],
templateUrl: './loading-screen.component.html',
styleUrls: ['./loading-screen.component.scss']
})
export class LoadingScreenComponent implements OnInit, AfterViewInit, OnDestroy {
private readonly loadingService = inject(LoadingScreenService);
private readonly cdr = inject(ChangeDetectorRef);
@Input() mode: 'overlay' | 'inline' | 'fullscreen' = 'overlay';
@Input() title: string = 'CBS Nexora';
@Input() message: string = 'Processing transaction...';
@Input() active: boolean = false;
@Input() isGlobalOverlay: boolean = true;
@ViewChild('lottieContainer') lottieContainerRef?: ElementRef<HTMLDivElement>;
currentMode: 'overlay' | 'inline' | 'fullscreen' = 'overlay';
lottieLoaded: boolean = false;
private animInstance: any = null;
private subscription?: Subscription;
ngOnInit(): void {
this.currentMode = this.mode;
if (this.isGlobalOverlay) {
this.subscription = new Subscription();
this.subscription.add(
this.loadingService.isVisible$.subscribe((visible) => {
this.active = visible;
this.cdr.markForCheck();
if (visible) {
setTimeout(() => this.initLottie(), 50);
} else {
this.destroyLottie();
}
})
);
this.subscription.add(
this.loadingService.config$.subscribe((cfg: LoadingConfig) => {
if (cfg.title !== undefined) this.title = cfg.title;
if (cfg.message !== undefined) this.message = cfg.message;
if (cfg.mode !== undefined) this.currentMode = cfg.mode;
this.cdr.markForCheck();
})
);
}
}
ngAfterViewInit(): void {
if (this.active) {
this.initLottie();
}
}
ngOnDestroy(): void {
this.subscription?.unsubscribe();
this.destroyLottie();
}
preventDismiss(event: MouseEvent): void {
event.stopPropagation();
}
private initLottie(): void {
if (!this.lottieContainerRef?.nativeElement) return;
this.destroyLottie();
const container = this.lottieContainerRef.nativeElement;
// Check if lottie-web global is available
if (typeof lottie !== 'undefined') {
try {
this.animInstance = lottie.loadAnimation({
container,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animations/loading_animation.json'
});
this.lottieLoaded = true;
this.cdr.markForCheck();
return;
} catch (err) {
console.warn('Could not load Lottie animation, using vector fallback', err);
}
}
// Try dynamic script load of local asset if not loaded yet
this.loadLottieScript()
.then(() => {
if (typeof lottie !== 'undefined' && this.lottieContainerRef?.nativeElement) {
this.animInstance = lottie.loadAnimation({
container: this.lottieContainerRef.nativeElement,
renderer: 'svg',
loop: true,
autoplay: true,
path: 'assets/animations/loading_animation.json'
});
this.lottieLoaded = true;
this.cdr.markForCheck();
}
})
.catch(() => {
this.lottieLoaded = false;
this.cdr.markForCheck();
});
}
private loadLottieScript(): Promise<void> {
return new Promise((resolve, reject) => {
if (typeof lottie !== 'undefined') {
resolve();
return;
}
const existingScript = document.getElementById('cbs-lottie-script');
if (existingScript) {
existingScript.addEventListener('load', () => resolve());
return;
}
const script = document.createElement('script');
script.id = 'cbs-lottie-script';
script.src = 'assets/js/lottie.min.js';
script.onload = () => resolve();
script.onerror = () => reject();
document.head.appendChild(script);
});
}
private destroyLottie(): void {
if (this.animInstance) {
try {
this.animInstance.destroy();
} catch (_) {}
this.animInstance = null;
}
}
}
@@ -0,0 +1,61 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
export interface LoadingConfig {
title?: string;
message?: string;
mode?: 'overlay' | 'inline' | 'fullscreen';
}
@Injectable({
providedIn: 'root'
})
export class LoadingScreenService {
private readonly visibleSubject = new BehaviorSubject<boolean>(false);
private readonly configSubject = new BehaviorSubject<LoadingConfig>({
title: 'CBS Nexora',
message: 'Processing transaction...',
mode: 'overlay'
});
readonly isVisible$: Observable<boolean> = this.visibleSubject.asObservable();
readonly config$: Observable<LoadingConfig> = this.configSubject.asObservable();
/**
* Show the loading screen/overlay
* @param message Optional message to display (e.g. "Processing Transfer...")
* @param title Optional title to display (defaults to "CBS Nexora")
* @param mode Optional display mode ('overlay' | 'fullscreen')
*/
show(message?: string, title?: string, mode: 'overlay' | 'fullscreen' = 'overlay'): void {
this.configSubject.next({
title: title || 'CBS Nexora',
message: message || 'Please wait...',
mode
});
this.visibleSubject.next(true);
}
/**
* Hide the loading screen/overlay
*/
hide(): void {
this.visibleSubject.next(false);
}
/**
* Update message while loading screen is active
*/
setMessage(message: string): void {
const current = this.configSubject.getValue();
this.configSubject.next({ ...current, message });
}
}
+3
View File
@@ -30,6 +30,7 @@ import { SearchToolComponent } from './search-tool/search-tool.component';
import { KeyboardShortcutsDialogComponent } from './keyboard-shortcuts-dialog/keyboard-shortcuts-dialog.component';
import { ServerSelectorComponent } from './server-selector/server-selector.component';
import { TenantSelectorComponent } from './tenant-selector/tenant-selector.component';
import { LoadingScreenComponent } from './loading-screen/loading-screen.component';
/** Custom Modules */
import { IconsModule } from './icons.module';
@@ -116,9 +117,11 @@ import { DatetimePickerStubComponent } from './datetime-picker-stub/datetime-pic
DropdownComponent,
InputAmountComponent,
InputPasswordComponent,
LoadingScreenComponent,
DatetimePickerStubComponent // temporarily added stub for datetime picker
],
exports: [
LoadingScreenComponent,
FileUploadComponent,
FooterComponent,
LanguageSelectorComponent,