feat(shared-scanner): Moved to shared/scanner

feat(common-data-access): takeUnitl operators for keydown

Refs: #5062
This commit is contained in:
Lorenz Hilpert
2025-06-12 16:34:21 +02:00
parent 055cfb67d3
commit 3cf05f04ef
30 changed files with 93 additions and 44 deletions

View File

@@ -0,0 +1,215 @@
# Scanner Library
## Overview
The Scanner library provides barcode scanning capabilities for the ISA application using the [Scandit SDK](https://www.scandit.com/). It offers a complete solution for mobile barcode scanning with support for various barcode formats, ready-state detection, and seamless form integration.
## Features
- Barcode scanning with support for multiple symbologies:
- EAN8
- EAN13/UPCA
- UPCE
- Code128
- Code39
- Code93
- Interleaved 2 of 5
- QR Code
- Platform detection (iOS/Android support)
- Ready-state detection with conditional rendering
- Form control integration via output bindings
- Configuration through injection tokens
- Error handling for unsupported platforms
## Components and Directives
### ScannerButtonComponent
A button component that integrates with the scanner service to trigger barcode scanning. It implements `OnDestroy` to properly clean up resources.
**Features:**
- Only appears when scanner is ready
- Can be disabled through binding
- Configurable size
- Emits scanned value through output binding
- Includes E2E testing attribute `data-which="scan-button"`
**Usage Example:**
```typescript
import { ScannerButtonComponent } from '@isa/core/scanner';
@Component({
selector: 'app-my-component',
template: `
<shared-scanner-button
[disabled]="isDisabled"
[size]="'large'"
(scan)="onScan($event)"
>
</shared-scanner-button>
`,
imports: [ScannerButtonComponent],
standalone: true,
})
export class MyComponent {
isDisabled = false;
onScan(value: string | null) {
console.log('Scanned barcode:', value);
}
}
```
### ScannerReadyDirective
A structural directive (`*sharedScannerReady`) that conditionally renders its content based on the scanner's ready state. Similar to `*ngIf`, but specifically tied to scanner readiness.
**Features:**
- Only renders content when the scanner is ready
- Supports an optional else template for when the scanner is not ready
- Uses Angular's effect system for reactive updates
**Usage Example:**
```typescript
import { ScannerReadyDirective } from '@isa/core/scanner';
@Component({
selector: 'app-my-component',
template: `
<div *sharedScannerReady>
<!-- Content only shown when scanner is ready -->
<p>Scanner is ready to use</p>
<button (click)="startScanning()">Scan Now</button>
</div>
<!-- Alternative with else template -->
<div *sharedScannerReady="; else notReady">
<p>Scanner is ready</p>
</div>
<ng-template #notReady>
<p>Scanner is not yet ready</p>
<app-spinner></app-spinner>
</ng-template>
`,
imports: [ScannerReadyDirective],
standalone: true,
})
export class MyComponent {
// Component logic
}
```
### ScannerComponent
Internal component used by ScannerService to render the camera view and process barcode scanning.
**Features:**
- Integrates with Scandit SDK
- Handles camera setup and barcode detection
- Emits scanned values
- Includes a close button to cancel scanning
## Services
### ScannerService
Core service that provides barcode scanning functionality.
**Features:**
- Initializes and configures Scandit SDK
- Checks platform compatibility
- Manages scanner lifecycle
- Provides a reactive `ready` signal
- Handles scanning operations with proper cleanup
**Usage Example:**
```typescript
import { ScannerService, ScannerInputs } from '@isa/core/scanner';
import { Component, inject } from '@angular/core';
@Component({
selector: 'app-my-component',
template: `
<button (click)="scan()" [disabled]="!isReady()">Scan Barcode</button>
<div *ngIf="result">Last Scan: {{ result }}</div>
`,
standalone: true,
})
export class MyComponent {
private scannerService = inject(ScannerService);
isReady = this.scannerService.ready;
result: string | null = null;
async scan() {
const options: ScannerInputs = {
// Optional configuration
// symbologies: [...] // Specify barcode types
};
try {
this.result = await this.scannerService.open(options);
} catch (error) {
console.error('Scanning failed:', error);
}
}
}
```
## Configuration
The scanner module uses injection tokens for configuration:
- `SCANDIT_LICENSE` - The Scandit license key (defaults to config value at 'licence.scandit')
- `SCANDIT_LIBRARY_LOCATION` - The location of the Scandit library (defaults to '/scandit' relative to base URI)
- `SCANDIT_DEFAULT_SYMBOLOGIES` - The default barcode symbologies to use
**Custom Configuration Example:**
```typescript
import { SCANDIT_LICENSE, SCANDIT_LIBRARY_LOCATION } from '@isa/core/scanner';
import { Symbology } from 'scandit-web-datacapture-barcode';
@NgModule({
providers: [
// Custom license key
{
provide: SCANDIT_LICENSE,
useValue: 'YOUR-SCANDIT-LICENSE-KEY',
},
// Custom library location
{
provide: SCANDIT_LIBRARY_LOCATION,
useValue: 'https://cdn.example.com/scandit/',
},
],
})
export class AppModule {}
```
## Error Handling
The scanner library includes error handling for various scenarios:
- `PlatformNotSupportedError` is thrown when the scanner is used on unsupported platforms
- Configuration errors are logged and propagated
- Aborted scan operations are handled gracefully
## Requirements
- The Scandit SDK must be properly installed and configured
- Requires a valid Scandit license key
- Currently supports iOS and Android platforms
## E2E Testing
The scanner components include E2E testing attributes for easier selection in automated tests:
- ScannerButton includes `data-which="scan-button"` for E2E test selection

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'shared',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'shared',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,21 @@
export default {
displayName: 'shared-scanner',
preset: '../../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
coverageDirectory: '../../../coverage/libs/shared/scanner',
transform: {
'^.+\\.(ts|mjs|js|html)$': [
'jest-preset-angular',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
},
],
},
transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'],
snapshotSerializers: [
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment',
],
};

View File

@@ -0,0 +1,20 @@
{
"name": "shared-scanner",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/scanner/src",
"prefix": "shared",
"projectType": "library",
"tags": [],
"targets": {
"test": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/scanner/jest.config.ts"
}
},
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1,5 @@
export * from './lib/errors';
export * from './lib/render-if-scanner-is-ready.directive';
export * from './lib/scanner-button.component';
export * from './lib/scanner.component';
export * from './lib/scanner.service';

View File

@@ -0,0 +1 @@
export * from './platform-not-supported.error';

View File

@@ -0,0 +1,6 @@
export class PlatformNotSupportedError extends Error {
constructor() {
super('ScannerService is only supported on iOS and Android platforms');
this.name = 'PlatformNotSupportedError';
}
}

View File

@@ -0,0 +1,90 @@
import { signal, Signal, WritableSignal } from '@angular/core';
import {
createDirectiveFactory,
SpectatorDirective,
} from '@ngneat/spectator/jest';
import { ScannerReadyDirective } from './render-if-scanner-is-ready.directive';
import { ScannerService } from './scanner.service';
// FULLY MOCK EXTERNAL DEPENDENCIES
jest.mock('scandit-web-datacapture-core', () => ({
configure: jest.fn(),
}));
jest.mock('scandit-web-datacapture-barcode', () => ({
barcodeCaptureLoader: jest.fn(() => 'mockedBarcodeLoader'),
}));
const loggerInfo = jest.fn();
const loggerError = jest.fn();
jest.mock('@isa/core/logging', () => ({
logger: jest.fn(() => ({ info: loggerInfo, error: loggerError })),
}));
/**
* Unit tests for the ScannerReadyDirective.
* Tests conditional rendering based on scanner ready state.
*/
describe('ScannerReadyDirective', () => {
/**
* Spectator instance for the directive
*/
let spectator: SpectatorDirective<ScannerReadyDirective>;
/**
* Signal to control the scanner ready state
*/
let readySignal: WritableSignal<boolean>;
/**
* Mock of the ScannerService
*/
let scannerServiceMock: jest.Mocked<ScannerService>;
const createDirective = createDirectiveFactory({
directive: ScannerReadyDirective,
mocks: [ScannerService], // mock out the real ScannerService (and its Config deps)
detectChanges: false, // manual CD
template: `<div *sharedScannerReady>Ready</div>`,
});
beforeEach(() => {
// 1) instantiate
spectator = createDirective();
// 2) grab the Spectator mock
scannerServiceMock = spectator.inject(ScannerService);
// 3) override its `ready` with a real Angular signal
readySignal = signal(false);
(scannerServiceMock as any).ready = readySignal;
});
it('should render when ready=true', () => {
// ARRANGE
readySignal.set(true);
// ACT
spectator.detectChanges();
// ASSERT
expect(spectator.query('div')).toHaveText('Ready');
expect(spectator.query('div')).toBeTruthy();
});
it('should not render when ready=false', () => {
// ARRANGE
readySignal.set(false);
// ACT
spectator.detectChanges();
// ASSERT
expect(spectator.query('div')).not.toExist();
});
it('should update view when ready flips', () => {
// ARRANGE
readySignal.set(false);
spectator.detectChanges();
expect(spectator.query('div')).not.toExist();
// ACT
readySignal.set(true);
spectator.detectChanges();
// ASSERT
expect(spectator.query('div')).toHaveText('Ready');
});
});

View File

@@ -0,0 +1,62 @@
import {
Directive,
TemplateRef,
ViewContainerRef,
inject,
effect,
input,
} from '@angular/core';
import { ScannerService } from './scanner.service';
/**
* Structural directive that conditionally renders its content based on scanner ready state.
*
* Similar to Angular's *ngIf, but specifically tied to the scanner's readiness.
* Can be used with an optional else template to show alternative content when the scanner is not ready.
*
* @example
* ```html
* <div *sharedScannerReady>Content shown when scanner is ready</div>
*
* <!-- With else template -->
* <div *sharedScannerReady="; else notReadyTemplate">Scanner is ready</div>
* <ng-template #notReadyTemplate>Scanner is not ready yet</ng-template>
* ```
*/
@Directive({
selector: '[sharedScannerReady]',
standalone: true,
})
export class ScannerReadyDirective {
#scanner = inject(ScannerService);
#vcRef = inject(ViewContainerRef);
#tpl = inject(TemplateRef<unknown>);
/**
* Optional template to render when scanner is not ready.
* Similar to *ngIf's else template.
*/
scannerReadyElse = input<TemplateRef<unknown> | undefined>();
constructor() {
effect(() => {
this.#updateView();
});
}
/**
* Updates the view based on scanner ready state.
* Clears the view container and creates the appropriate embedded view.
*/
#updateView(): void {
this.#vcRef.clear();
if (this.#scanner.ready()) {
this.#vcRef.createEmbeddedView(this.#tpl);
return;
}
const elseTpl = this.scannerReadyElse();
if (elseTpl) {
this.#vcRef.createEmbeddedView(elseTpl);
}
}
}

View File

@@ -0,0 +1,10 @@
<ui-icon-button
*sharedScannerReady
type="submit"
[color]="'primary'"
[disabled]="disabled()"
(click)="openScanner()"
[size]="size()"
name="isaActionScanner"
data-which="scan-button"
></ui-icon-button>

View File

View File

@@ -0,0 +1,85 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
model,
OnDestroy,
output,
} from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';
import { logger } from '@isa/core/logging';
import { IconButtonComponent, IconButtonSize } from '@isa/ui/buttons';
import { ScannerReadyDirective } from './render-if-scanner-is-ready.directive';
import { ScannerService } from './scanner.service';
/**
* A button component that integrates with the scanner service to trigger barcode scanning.
*
* Implements ControlValueAccessor to work with Angular forms. This component will only
* be visible when the scanner is ready, and will trigger the scanner when clicked.
*
* @example
* ```html
* <!-- Template-driven form -->
* <shared-scanner-button [(ngModel)]="barcodeValue"></shared-scanner-button>
*
* <!-- Reactive form -->
* <shared-scanner-button formControlName="barcode"></shared-scanner-button>
* ```
*/
@Component({
selector: 'shared-scanner-button',
templateUrl: './scanner-button.component.html',
styleUrls: ['./scanner-button.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [IconButtonComponent, ScannerReadyDirective],
})
export class ScannerButtonComponent implements OnDestroy {
#logger = logger(() => ({
component: 'ScannerButtonComponent',
}));
#abortController = new AbortController();
#scannerService = inject(ScannerService);
/**
* The current scanned value
*/
scan = output<string | null>();
/**
* Whether the button is disabled
*/
disabled = model<boolean>(false);
/**
* The size of the scanner button.
* Accepts values defined in the `IconButtonSize` type.
* Defaults to 'large'.
*
* @default 'large'
*/
size = input<IconButtonSize>('large');
/**
* Opens the scanner when the button is clicked
* The scanned value will be set to the form control if successful
*/
async openScanner() {
this.#logger.info('Opening scanner');
const res = await this.#scannerService.open({
abortSignal: this.#abortController.signal,
});
this.#logger.info('Scanner closed', () => ({ result: res }));
this.scan.emit(res);
}
/**
* Cleanup by aborting any in-progress scanning operations
*/
ngOnDestroy(): void {
this.#abortController.abort();
}
}

View File

@@ -0,0 +1,7 @@
<ui-icon-button
class="shared-scanner-close"
name="isaActionClose"
color="tertiary"
size="large"
(click)="scan.emit(null)"
></ui-icon-button>

View File

@@ -0,0 +1,20 @@
.shared-scanner {
@apply relative;
@apply block overflow-hidden bg-isa-neutral-300 w-screen h-screen;
}
.shared-scanner-close {
@apply absolute top-6 right-6;
}
@screen isa-desktop {
.shared-scanner {
@apply max-h-96;
@apply max-w-[42rem];
@apply rounded-3xl;
}
}
.scanner-backdrop {
@apply bg-isa-neutral-900/50;
}

View File

@@ -0,0 +1,188 @@
import {
ChangeDetectionStrategy,
Component,
effect,
ElementRef,
inject,
input,
NgZone,
OnDestroy,
output,
untracked,
ViewEncapsulation,
} from '@angular/core';
import {
BarcodeCapture,
BarcodeCaptureSession,
BarcodeCaptureSettings,
Symbology,
} from 'scandit-web-datacapture-barcode';
import {
Camera,
DataCaptureContext,
DataCaptureView,
FrameSourceState,
} from 'scandit-web-datacapture-core';
import { IconButtonComponent } from '@isa/ui/buttons';
import { provideIcons } from '@ng-icons/core';
import { isaActionClose } from '@isa/icons';
/**
* Component that provides the UI and functionality for barcode scanning.
*
* This component integrates with the Scandit SDK to render a camera view and process
* barcode scanning. It is used internally by the ScannerService.
*/
@Component({
selector: 'shared-scanner',
styleUrls: ['./scanner.component.scss'],
templateUrl: './scanner.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [IconButtonComponent],
providers: [provideIcons({ isaActionClose })],
encapsulation: ViewEncapsulation.None, // Use global styles
host: {
class: 'shared-scanner',
},
})
export class ScannerComponent implements OnDestroy {
#zone = inject(NgZone);
#elementRef = inject(ElementRef, { self: true });
#dataCaptureView = new DataCaptureView();
#dataCaptureContext: DataCaptureContext | null = null;
#barcodeCapture: BarcodeCapture | null = null;
#camera: Camera = Camera.default;
/**
* Input array of barcode symbologies to recognize.
* Determines which types of barcodes the scanner will detect.
*/
symbologies = input<Symbology[]>([]);
/**
* Effect that handles initializing the scanner when symbologies change.
* Creates the data capture context and starts barcode capture with the specified symbologies.
*/
symbologiesEffect = effect(() => {
const symbologies = this.symbologies();
untracked(async () => {
await this.#createDataCaptureContext();
await this.#startBarcodeCapture(symbologies);
});
});
/**
* Output event emitted when a barcode is successfully scanned.
* Emits the barcode data as a string.
*/
scan = output<string>();
/**
* Initializes the scanner component.
* Sets up the data capture view and shows a progress bar while loading.
* Configures the view for full-screen scanning experience.
*/ constructor() {
// Configure the view to take maximum space
this.#dataCaptureView.connectToElement(this.#elementRef.nativeElement);
this.#dataCaptureView.showProgressBar();
}
/**
* Creates and initializes the Scandit DataCaptureContext.
* This is required before barcode scanning can begin.
*
* @returns Promise that resolves when the context is created
*/
async #createDataCaptureContext(): Promise<void> {
if (this.#dataCaptureContext) {
return;
}
this.#dataCaptureContext = await DataCaptureContext.create();
this.#dataCaptureView.setContext(this.#dataCaptureContext);
}
/**
* Configures and starts barcode capture with the specified symbologies.
* Sets up the camera as the frame source and configures event listeners.
*
* @param symbologies - Array of barcode types to recognize
* @returns Promise that resolves to the initialized BarcodeCapture instance
* @throws Error if DataCaptureContext is not initialized
*/
async #startBarcodeCapture(
symbologies: Symbology[],
): Promise<BarcodeCapture> {
const dataCaptureContext = this.#dataCaptureContext;
if (!dataCaptureContext) {
throw new Error('DataCaptureContext is not initialized');
}
if (this.#barcodeCapture) {
this.#cleanup();
}
const settings = new BarcodeCaptureSettings();
settings.enableSymbologies(symbologies);
console.log({ symbologies });
this.#barcodeCapture = await BarcodeCapture.forContext(
dataCaptureContext,
settings,
);
this.#barcodeCapture.addListener({
didScan: this.#didScan,
});
dataCaptureContext.setFrameSource(this.#camera);
await this.#camera.switchToDesiredState(FrameSourceState.On);
this.#dataCaptureView.hideProgressBar();
return this.#barcodeCapture;
}
/**
* Callback that handles barcode scanning events.
* Runs within Angular zone to ensure proper change detection.
* Emits the scanned barcode data via the scan output.
*
* @param _ - BarcodeCapture instance (unused)
* @param session - The barcode capture session containing scan results
*/
#didScan = (_: BarcodeCapture, session: BarcodeCaptureSession) => {
this.#zone.run(() => {
const result = session.newlyRecognizedBarcode;
const code = result?.data;
console.log('Scanned code:', code);
if (code) {
this.scan.emit(code);
}
});
};
/**
* Cleanup resources when the component is destroyed.
* Disables barcode capture, removes event listeners, and turns off the camera.
* Runs outside Angular zone for better performance.
*/
ngOnDestroy(): void {
this.#zone.runOutsideAngular(() => {
this.#cleanup();
});
}
#cleanup() {
this.#barcodeCapture?.setEnabled(false);
this.#barcodeCapture?.removeListener({
didScan: this.#didScan,
});
this.#camera.switchToDesiredState(FrameSourceState.Off);
}
}

View File

@@ -0,0 +1,121 @@
import { createServiceFactory, SpectatorService } from '@ngneat/spectator/jest';
import {
ScannerService,
SCANDIT_LICENSE,
SCANDIT_LIBRARY_LOCATION,
SCANDIT_DEFAULT_SYMBOLOGIES,
} from './scanner.service';
import { Platform } from '@angular/cdk/platform';
import { configure } from 'scandit-web-datacapture-core';
import { Config, CONFIG_DATA } from '@isa/core/config'; // <-- Use the real tokens
class MockConfig {
get(key: string) {
if (key === 'licence.scandit') return 'FAKE-LICENSE';
return undefined;
}
}
jest.mock('scandit-web-datacapture-core');
jest.mock('scandit-web-datacapture-barcode', () => ({
barcodeCaptureLoader: jest.fn(() => 'mockedBarcodeLoader'),
}));
const loggerInfo = jest.fn();
const loggerError = jest.fn();
jest.mock('@isa/core/logging', () => ({
logger: jest.fn(() => ({
info: loggerInfo,
error: loggerError,
})),
}));
/**
* Unit tests for the ScannerService.
* Tests initialization, configuration, and scanning functionality.
*/
describe('ScannerService', () => {
let spectator: SpectatorService<ScannerService>;
const createService = createServiceFactory({
service: ScannerService,
providers: [
{ provide: SCANDIT_LICENSE, useValue: 'FAKE-LICENSE' },
{ provide: SCANDIT_LIBRARY_LOCATION, useValue: 'https://fake-location/' },
{ provide: SCANDIT_DEFAULT_SYMBOLOGIES, useValue: ['ean8'] },
{ provide: Platform, useValue: { IOS: true, ANDROID: false } },
{ provide: CONFIG_DATA, useValue: {} },
{ provide: Config, useClass: MockConfig },
],
});
beforeEach(() => {
jest.clearAllMocks();
spectator = createService();
});
describe('configure', () => {
it('should configure successfully on supported platform', async () => {
(configure as jest.Mock).mockResolvedValue(undefined);
await spectator.service.configure();
expect(configure).toHaveBeenCalledWith({
licenseKey: 'FAKE-LICENSE',
libraryLocation: 'https://fake-location/',
moduleLoaders: ['mockedBarcodeLoader'],
});
expect(loggerInfo).toHaveBeenCalledWith('Scanner initializing');
expect(loggerInfo).toHaveBeenCalledWith('Scanner ready');
});
it('should not re-configure if already initializing or ready', async () => {
(configure as jest.Mock).mockResolvedValue(undefined);
await spectator.service.configure();
await spectator.service.configure();
expect((configure as jest.Mock).mock.calls.length).toBe(1);
});
it('should throw and log error if configure throws', async () => {
(configure as jest.Mock).mockRejectedValue(new Error('Config failed'));
await expect(spectator.service.configure()).rejects.toThrow(
'Config failed',
);
expect(loggerError).toHaveBeenCalledWith(
'Failed to configure Scandit',
expect.any(Error),
);
});
});
describe('ready', () => {
it('should return true if status is Ready', async () => {
(configure as jest.Mock).mockResolvedValue(undefined);
await spectator.service.configure();
expect(spectator.service.ready()).toBe(true);
});
it('should return false if status is not Ready', () => {
expect(spectator.service.ready()).toBe(false);
});
});
// Additional tests for edge cases and error handling
it('should set status to Error if configure throws', async () => {
(configure as jest.Mock).mockRejectedValue(new Error('fail'));
try {
await spectator.service.configure();
} catch {
// ignore
}
expect(spectator.service.ready()).toBe(false);
expect(loggerError).toHaveBeenCalledWith(
'Failed to configure Scandit',
expect.any(Error),
);
});
});

View File

@@ -0,0 +1,240 @@
import {
computed,
inject,
Injectable,
InjectionToken,
signal,
untracked,
} from '@angular/core';
import { Config } from '@isa/core/config';
import { z } from 'zod';
import { configure } from 'scandit-web-datacapture-core';
import {
barcodeCaptureLoader,
Symbology,
} from 'scandit-web-datacapture-barcode';
import { Platform } from '@angular/cdk/platform';
import { PlatformNotSupportedError } from './errors/platform-not-supported.error';
import { logger } from '@isa/core/logging';
import { Overlay } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { ScannerComponent } from './scanner.component';
import { Subscription } from 'rxjs';
/**
* Injection token for Scandit license key.
* By default, retrieves the license key from the application config.
*/
export const SCANDIT_LICENSE = new InjectionToken<string>('ScanditLicense', {
factory() {
return inject(Config).get('licence.scandit', z.string());
},
});
/**
* Injection token for the Scandit library location.
* By default, uses a 'scandit' path relative to the document base URI.
*/
export const SCANDIT_LIBRARY_LOCATION = new InjectionToken<string>(
'ScanditLibraryLocation',
{
factory() {
return new URL('scandit', document.baseURI).toString();
},
},
);
/**
* Injection token for the default barcode symbologies to scan.
* Provides a set of commonly used barcode formats.
*/
export const SCANDIT_DEFAULT_SYMBOLOGIES = new InjectionToken<Symbology[]>(
'ScanditDefaultSymbologies',
{
factory() {
return [
Symbology.EAN8,
Symbology.EAN13UPCA,
Symbology.UPCE,
Symbology.Code128,
Symbology.Code39,
Symbology.Code93,
Symbology.InterleavedTwoOfFive,
Symbology.QR,
];
},
},
);
/**
* Enumeration of possible scanner status values.
*
* - None: Initial state, scanner not yet initialized
* - Initializing: Scanner is in the process of initializing
* - Ready: Scanner is fully initialized and ready to use
* - Error: Scanner encountered an error during initialization
*/
export const ScannerStatus = {
None: 'none',
Initializing: 'initializing',
Ready: 'ready',
Error: 'error',
} as const;
export type ScannerStatus = (typeof ScannerStatus)[keyof typeof ScannerStatus];
/**
* Configuration options for scanner operations.
*
* @property symbologies - Optional array of barcode symbologies to use for scanning
* @property abortSignal - Optional AbortSignal to cancel an ongoing scan operation
*/
export type ScannerInputs = {
symbologies?: Symbology[];
abortSignal?: AbortSignal;
};
@Injectable({ providedIn: 'root' })
/**
* Service that provides barcode scanning functionality using the Scandit SDK.
* The service handles initialization, configuration, and scanning operations.
*/
export class ScannerService {
#logger = logger(() => ({ service: 'ScannerService' }));
#licenseKey = inject(SCANDIT_LICENSE);
#platform = inject(Platform);
#libraryLocation = inject(SCANDIT_LIBRARY_LOCATION);
#defaultSymbologies = inject(SCANDIT_DEFAULT_SYMBOLOGIES);
#overlay = inject(Overlay);
#overlayRef = this.#overlay.create({
hasBackdrop: true,
backdropClass: 'scanner-backdrop',
panelClass: 'scanner-panel',
positionStrategy: this.#overlay
.position()
.global()
.centerHorizontally()
.centerVertically(),
scrollStrategy: this.#overlay.scrollStrategies.block(),
});
/** Current status of the scanner */
#status = signal<ScannerStatus>(ScannerStatus.None);
/**
* Signal that indicates whether the scanner is ready to use.
* This will trigger configuration if the scanner has not been initialized.
*/
ready = computed(() => {
untracked(() => {
this.configure();
});
return this.#status() === ScannerStatus.Ready;
});
/**
* Checks if the current platform is supported for scanning.
* Currently supports iOS and Android.
*
* @throws {PlatformNotSupportedError} If the current platform is not supported
*/
private checkPlatformSupported() {
if (this.#platform.IOS || this.#platform.ANDROID) {
return;
}
throw new PlatformNotSupportedError();
}
/**
* Configures the Scandit barcode scanner SDK.
* This method must be called before attempting to use the scanner.
*
* @returns A promise that resolves when configuration is complete
* @throws Will propagate any errors from the Scandit SDK
*/
async configure() {
const status = this.#status();
if (
status === ScannerStatus.Initializing ||
status === ScannerStatus.Ready
) {
return;
}
this.#status.set(ScannerStatus.Initializing);
this.#logger.info('Scanner initializing');
try {
this.checkPlatformSupported();
await configure({
licenseKey: this.#licenseKey,
libraryLocation: this.#libraryLocation,
moduleLoaders: [barcodeCaptureLoader()],
});
this.#status.set(ScannerStatus.Ready);
this.#logger.info('Scanner ready');
} catch (error) {
if (error instanceof PlatformNotSupportedError) {
this.#logger.warn(error.message, () => ({
error,
}));
return;
}
this.#status.set(ScannerStatus.Error);
this.#logger.error('Failed to configure Scandit', error);
throw error;
}
}
/**
* Opens the scanner interface and initiates a scan operation.
*
* @param options - Optional configuration for the scan operation
* @returns A promise that resolves to the scanned barcode value, or null if scanning was cancelled
* @throws Will propagate errors from configure() if scanner initialization fails
*/
async open(options?: ScannerInputs): Promise<string | null> {
await this.configure();
return new Promise((resolve, reject) => {
try {
const componentPortal = new ComponentPortal(ScannerComponent);
const componentRef = this.#overlayRef.attach(componentPortal);
componentRef.setInput(
'symbologies',
options?.symbologies ?? this.#defaultSymbologies,
);
const sub = new Subscription();
function cleanup() {
componentRef.destroy();
options?.abortSignal?.removeEventListener('abort', handleAbort);
sub.unsubscribe();
}
function handleScan(value: string | null) {
cleanup();
resolve(value);
}
function handleAbort() {
cleanup();
reject(new Error('Scanner aborted'));
}
sub.add(componentRef.instance.scan.subscribe(handleScan));
options?.abortSignal?.addEventListener('abort', handleAbort);
} catch (error) {
this.#logger.error('Failed to scan', error);
reject(error);
}
});
}
}

View File

@@ -0,0 +1,6 @@
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
setupZoneTestEnv({
errorOnUnknownElements: true,
errorOnUnknownProperties: true,
});

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "es2022",
"forceConsistentCasingInFileNames": true,
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../../tsconfig.base.json",
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@@ -0,0 +1,17 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"exclude": [
"src/**/*.spec.ts",
"src/test-setup.ts",
"jest.config.ts",
"src/**/*.test.ts"
],
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,16 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"target": "es2016",
"types": ["jest", "node"]
},
"files": ["src/test-setup.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}