mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
feat(shared-scanner): Moved to shared/scanner
feat(common-data-access): takeUnitl operators for keydown Refs: #5062
This commit is contained in:
215
libs/shared/scanner/README.md
Normal file
215
libs/shared/scanner/README.md
Normal 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
|
||||
34
libs/shared/scanner/eslint.config.mjs
Normal file
34
libs/shared/scanner/eslint.config.mjs
Normal 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: {},
|
||||
},
|
||||
];
|
||||
21
libs/shared/scanner/jest.config.ts
Normal file
21
libs/shared/scanner/jest.config.ts
Normal 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',
|
||||
],
|
||||
};
|
||||
20
libs/shared/scanner/project.json
Normal file
20
libs/shared/scanner/project.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
5
libs/shared/scanner/src/index.ts
Normal file
5
libs/shared/scanner/src/index.ts
Normal 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';
|
||||
1
libs/shared/scanner/src/lib/errors/index.ts
Normal file
1
libs/shared/scanner/src/lib/errors/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './platform-not-supported.error';
|
||||
@@ -0,0 +1,6 @@
|
||||
export class PlatformNotSupportedError extends Error {
|
||||
constructor() {
|
||||
super('ScannerService is only supported on iOS and Android platforms');
|
||||
this.name = 'PlatformNotSupportedError';
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
libs/shared/scanner/src/lib/scanner-button.component.html
Normal file
10
libs/shared/scanner/src/lib/scanner-button.component.html
Normal 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>
|
||||
85
libs/shared/scanner/src/lib/scanner-button.component.ts
Normal file
85
libs/shared/scanner/src/lib/scanner-button.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
7
libs/shared/scanner/src/lib/scanner.component.html
Normal file
7
libs/shared/scanner/src/lib/scanner.component.html
Normal 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>
|
||||
20
libs/shared/scanner/src/lib/scanner.component.scss
Normal file
20
libs/shared/scanner/src/lib/scanner.component.scss
Normal 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;
|
||||
}
|
||||
188
libs/shared/scanner/src/lib/scanner.component.ts
Normal file
188
libs/shared/scanner/src/lib/scanner.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
121
libs/shared/scanner/src/lib/scanner.service.spec.ts
Normal file
121
libs/shared/scanner/src/lib/scanner.service.spec.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
240
libs/shared/scanner/src/lib/scanner.service.ts
Normal file
240
libs/shared/scanner/src/lib/scanner.service.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
6
libs/shared/scanner/src/test-setup.ts
Normal file
6
libs/shared/scanner/src/test-setup.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone';
|
||||
|
||||
setupZoneTestEnv({
|
||||
errorOnUnknownElements: true,
|
||||
errorOnUnknownProperties: true,
|
||||
});
|
||||
28
libs/shared/scanner/tsconfig.json
Normal file
28
libs/shared/scanner/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
17
libs/shared/scanner/tsconfig.lib.json
Normal file
17
libs/shared/scanner/tsconfig.lib.json
Normal 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"]
|
||||
}
|
||||
16
libs/shared/scanner/tsconfig.spec.json
Normal file
16
libs/shared/scanner/tsconfig.spec.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user