mirror of
https://dev.azure.com/hugendubel/ISA/_git/ISA-Frontend
synced 2025-12-28 22:42:11 +01:00
Merged PR 1858: feat(scanner): add full-screen scanner styles and components
feat(scanner): add full-screen scanner styles and components Implemented full-screen scanner styles in styles.scss. Added ScannerButtonComponent to trigger barcode scanning. Created ScannerComponent for rendering camera view and processing scans. Updated ScannerService to handle scanning operations and configuration. Enhanced README.md with detailed library features and usage examples. Refactored return process components to utilize new scanner button. Updated search bar input to integrate scanner functionality. Added tests for new components and services, ensuring proper functionality. Improved UI button styles for better integration with scanner features. Ref: #5123 DS // Scanner Overlay QA #5056 Retoure // Scan-Button lösen Suche aus QA #5147 Related work items: #5147
This commit is contained in:
committed by
Nino Righi
parent
b589dc21cd
commit
1617533412
@@ -5,6 +5,31 @@
|
||||
|
||||
@import "./scss/components";
|
||||
|
||||
/* Scanner Fullscreen Styles */
|
||||
.full-screen-scanner {
|
||||
max-width: 100vw !important;
|
||||
max-height: 100vh !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
|
||||
.scanner-component {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Override CDK overlay container styles for scanner */
|
||||
.cdk-overlay-container {
|
||||
.full-screen-scanner {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@import "./scss/root";
|
||||
@import "./scss/customer";
|
||||
@import "./scss/branch";
|
||||
|
||||
@@ -1,7 +1,213 @@
|
||||
# core-scanner
|
||||
# Scanner Library
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
## Overview
|
||||
|
||||
## Running unit tests
|
||||
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.
|
||||
|
||||
Run `nx test core-scanner` to execute the unit tests.
|
||||
## 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
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './lib/scanner.service';
|
||||
export * from './lib/render-if-scanner-is-ready.directive';
|
||||
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';
|
||||
|
||||
@@ -19,9 +19,24 @@ 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({
|
||||
|
||||
@@ -8,6 +8,21 @@ import {
|
||||
} 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,
|
||||
@@ -17,6 +32,10 @@ export class ScannerReadyDirective {
|
||||
#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() {
|
||||
@@ -25,6 +44,10 @@ export class ScannerReadyDirective {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()) {
|
||||
|
||||
10
libs/core/scanner/src/lib/scanner-button.component.html
Normal file
10
libs/core/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/core/scanner/src/lib/scanner-button.component.ts
Normal file
85
libs/core/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/core/scanner/src/lib/scanner.component.html
Normal file
7
libs/core/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/core/scanner/src/lib/scanner.component.scss
Normal file
20
libs/core/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/core/scanner/src/lib/scanner.component.ts
Normal file
188
libs/core/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);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -16,9 +17,7 @@ class MockConfig {
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock('scandit-web-datacapture-core', () => ({
|
||||
configure: jest.fn(),
|
||||
}));
|
||||
jest.mock('scandit-web-datacapture-core');
|
||||
jest.mock('scandit-web-datacapture-barcode', () => ({
|
||||
barcodeCaptureLoader: jest.fn(() => 'mockedBarcodeLoader'),
|
||||
}));
|
||||
@@ -31,6 +30,10 @@ jest.mock('@isa/core/logging', () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Unit tests for the ScannerService.
|
||||
* Tests initialization, configuration, and scanning functionality.
|
||||
*/
|
||||
describe('ScannerService', () => {
|
||||
let spectator: SpectatorService<ScannerService>;
|
||||
|
||||
@@ -39,6 +42,7 @@ describe('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 },
|
||||
@@ -99,24 +103,6 @@ describe('ScannerService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('open', () => {
|
||||
it('should call configure and resolve undefined', async () => {
|
||||
(configure as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await spectator.service.open();
|
||||
|
||||
expect(configure).toHaveBeenCalled();
|
||||
expect(loggerInfo).toHaveBeenCalledWith('Scanner not implemented');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should propagate error from configure', async () => {
|
||||
(configure as jest.Mock).mockRejectedValue(new Error('Config failed'));
|
||||
|
||||
await expect(spectator.service.open()).rejects.toThrow('Config failed');
|
||||
});
|
||||
});
|
||||
|
||||
// Additional tests for edge cases and error handling
|
||||
|
||||
it('should set status to Error if configure throws', async () => {
|
||||
@@ -132,10 +118,4 @@ describe('ScannerService', () => {
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call logger.info with "Scanner not implemented" when open is called', async () => {
|
||||
(configure as jest.Mock).mockResolvedValue(undefined);
|
||||
await spectator.service.open();
|
||||
expect(loggerInfo).toHaveBeenCalledWith('Scanner not implemented');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,17 +9,32 @@ import {
|
||||
import { Config } from '@isa/core/config';
|
||||
import { z } from 'zod';
|
||||
import { configure } from 'scandit-web-datacapture-core';
|
||||
import { barcodeCaptureLoader } from 'scandit-web-datacapture-barcode';
|
||||
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',
|
||||
{
|
||||
@@ -29,6 +44,36 @@ export const SCANDIT_LIBRARY_LOCATION = new InjectionToken<string>(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* 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',
|
||||
@@ -38,15 +83,49 @@ export const ScannerStatus = {
|
||||
|
||||
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();
|
||||
@@ -54,6 +133,12 @@ export class ScannerService {
|
||||
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;
|
||||
@@ -62,6 +147,13 @@ export class ScannerService {
|
||||
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 (
|
||||
@@ -92,14 +184,50 @@ export class ScannerService {
|
||||
this.#logger.info('Scanner ready');
|
||||
}
|
||||
|
||||
async open(): Promise<string | undefined> {
|
||||
/**
|
||||
* 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) => {
|
||||
this.#logger.info('Scanner not implemented');
|
||||
setTimeout(() => {
|
||||
resolve(undefined);
|
||||
}, 1000);
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,15 +42,11 @@
|
||||
</ui-text-field-errors>
|
||||
</ui-text-field-container>
|
||||
|
||||
<ui-icon-button
|
||||
*sharedScannerReady
|
||||
class="self-start"
|
||||
type="button"
|
||||
[color]="'primary'"
|
||||
<shared-scanner-button
|
||||
[disabled]="!!control?.value"
|
||||
(click)="check()"
|
||||
name="isaActionScanner"
|
||||
></ui-icon-button>
|
||||
(scan)="onScan($event)"
|
||||
>
|
||||
</shared-scanner-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import { tapResponse } from '@ngrx/operators';
|
||||
import { ProductImageDirective } from '@isa/shared/product-image';
|
||||
import { provideIcons } from '@ng-icons/core';
|
||||
import { isaActionScanner } from '@isa/icons';
|
||||
import { ScannerReadyDirective } from '@isa/core/scanner';
|
||||
import { ScannerButtonComponent } from '@isa/core/scanner';
|
||||
|
||||
const eanValidator: ValidatorFn = (
|
||||
control: AbstractControl,
|
||||
@@ -61,7 +61,7 @@ const eanValidator: ValidatorFn = (
|
||||
TextFieldClearComponent,
|
||||
ProductImageDirective,
|
||||
IconButtonComponent,
|
||||
ScannerReadyDirective,
|
||||
ScannerButtonComponent,
|
||||
],
|
||||
providers: [provideIcons({ isaActionScanner })],
|
||||
})
|
||||
@@ -101,6 +101,13 @@ export class ReturnProcessProductQuestionComponent {
|
||||
);
|
||||
}
|
||||
|
||||
onScan(value: string | null) {
|
||||
if (value) {
|
||||
this.control.setValue(value);
|
||||
this.check();
|
||||
}
|
||||
}
|
||||
|
||||
check = rxMethod<void>(
|
||||
pipe(
|
||||
filter(() => this.control.valid),
|
||||
|
||||
@@ -24,6 +24,9 @@ jest.mock('@isa/core/process', () => ({
|
||||
injectActivatedProcessId: jest.fn(() => mockActivatedProcessIdSignal),
|
||||
}));
|
||||
|
||||
jest.mock('scandit-web-datacapture-core', () => ({}));
|
||||
jest.mock('scandit-web-datacapture-barcode', () => ({}));
|
||||
|
||||
describe('ReturnProcessComponent', () => {
|
||||
let spectator: SpectatorRouting<ReturnProcessComponent>;
|
||||
const mockEntitiesSignal = signal<ReturnProcess[]>([]);
|
||||
|
||||
@@ -36,14 +36,5 @@
|
||||
}
|
||||
</ui-search-bar>
|
||||
|
||||
<ui-icon-button
|
||||
*sharedScannerReady
|
||||
class="desktop:invisible"
|
||||
type="submit"
|
||||
[color]="'primary'"
|
||||
[disabled]="control.invalid"
|
||||
(click)="triggerSearch.emit()"
|
||||
name="isaActionScanner"
|
||||
data-which="scan-button"
|
||||
></ui-icon-button>
|
||||
<shared-scanner-button (scan)="onScan($event)"></shared-scanner-button>
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { isaActionSearch, isaActionScanner } from '@isa/icons';
|
||||
import { FilterService, TextFilterInput } from '../../core';
|
||||
import { InputType } from '../../types';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ScannerReadyDirective } from '@isa/core/scanner';
|
||||
import { ScannerButtonComponent } from '@isa/core/scanner';
|
||||
|
||||
@Component({
|
||||
selector: 'filter-search-bar-input',
|
||||
@@ -34,7 +34,7 @@ import { ScannerReadyDirective } from '@isa/core/scanner';
|
||||
IconButtonComponent,
|
||||
ReactiveFormsModule,
|
||||
UiSearchBarClearComponent,
|
||||
ScannerReadyDirective,
|
||||
ScannerButtonComponent,
|
||||
],
|
||||
host: {
|
||||
'[class]': "['filter-search-bar-input', appearanceClass()]",
|
||||
@@ -86,4 +86,11 @@ export class SearchBarInputComponent {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onScan(value: string | null): void {
|
||||
if (value) {
|
||||
this.filterService.setInputTextValue(this.inputKey(), value);
|
||||
this.triggerSearch.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,9 @@ import { NgIconComponent } from '@ng-icons/core';
|
||||
import { IconButtonComponent } from '@isa/ui/buttons';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
|
||||
jest.mock('scandit-web-datacapture-core', () => ({}));
|
||||
jest.mock('scandit-web-datacapture-barcode', () => ({}));
|
||||
|
||||
describe('FilterMenuButtonComponent', () => {
|
||||
let spectator: Spectator<FilterMenuButtonComponent>;
|
||||
let filterService: jest.Mocked<FilterService>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
.ui-text-button {
|
||||
@apply font-sans;
|
||||
background: inherit;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user