feat: Implements reception-specific scanning

Replaces the generic scanner route with a dedicated reception scanning page.

This change ensures the scanning process initiated from reception details is context-aware by passing the reception ID directly to the new `ReceptionScanPage`. This provides a more tailored and efficient workflow for reception operations.
This commit is contained in:
mandreshope 2025-07-30 17:00:49 +03:00
parent 60d959eb79
commit 2c003ba335
7 changed files with 691 additions and 2 deletions

View File

@ -2,3 +2,4 @@ export 'reception/reception_page.dart';
export 'delivery/delivery_page.dart';
export 'inventory/inventory_page.dart';
export 'reception/reception_details_page.dart';
export 'reception/reception_scan_page.dart';

View File

@ -47,7 +47,9 @@ class _ReceptionDetailsPageState extends ConsumerState<ReceptionDetailsPage> {
if (reception?.isDone == false) ...[
QuickActionComponent(
onTapScan: () {
ScannerRoute().push(context);
final id = reception?.id;
if (id == null) return;
ReceptionScanRoute(receptionId: id).push(context);
},
),
const SizedBox(height: 16),
@ -138,7 +140,9 @@ class _ReceptionDetailsPageState extends ConsumerState<ReceptionDetailsPage> {
? FloatingActionButton(
backgroundColor: AppTheme.of(context).primary,
onPressed: () {
ScannerRoute().push(context);
final id = reception?.id;
if (id == null) return;
ReceptionScanRoute(receptionId: id).push(context);
},
child: Icon(Icons.qr_code, color: AppTheme.of(context).white),
)

View File

@ -0,0 +1,439 @@
import 'dart:async';
import 'package:e_scan/backend/api/api_calls.dart';
import 'package:e_scan/backend/objectbox/entities/product/product_entity.dart';
import 'package:e_scan/backend/objectbox/objectbox_manager.dart';
import 'package:e_scan/backend/schema/product/product_model.dart';
import 'package:e_scan/components/loading_progress_component.dart';
import 'package:e_scan/components/product_scanned_component.dart';
import 'package:e_scan/pages/operation/reception/reception_scan_page_model.dart';
import 'package:e_scan/router/go_secure_router_builder.dart';
import 'package:e_scan/themes/app_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
class ReceptionScanPage extends ConsumerStatefulWidget {
const ReceptionScanPage({super.key, required this.receptionId});
final int receptionId;
@override
ConsumerState<ConsumerStatefulWidget> createState() =>
_ReceptionScanPageState();
}
class _ReceptionScanPageState extends ConsumerState<ReceptionScanPage>
with WidgetsBindingObserver {
final MobileScannerController mobileScannerController =
MobileScannerController(
// required options for the scanner
);
StreamSubscription<Object?>? _subscription;
bool qrcodeFound = false;
bool loading = false;
@override
void initState() {
super.initState();
// Start listening to lifecycle changes.
WidgetsBinding.instance.addObserver(this);
SchedulerBinding.instance.addPostFrameCallback((_) {
ref
.read(receptionScanPageModelProvider.notifier)
.getReceptionById(id: widget.receptionId);
});
// Start listening to the barcode events.
_subscription = mobileScannerController.barcodes.listen(_handleBarcode);
// Finally, start the scanner itself.
unawaited(mobileScannerController.start());
}
@override
void dispose() {
// Stop listening to lifecycle changes.
WidgetsBinding.instance.removeObserver(this);
// Stop listening to the barcode events.
unawaited(_subscription?.cancel());
_subscription = null;
// Dispose the widget itself.
// Finally, dispose of the controller.
mobileScannerController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// If the controller is not ready, do not try to start or stop it.
// Permission dialogs can trigger lifecycle changes before the controller is ready.
if (!mobileScannerController.value.hasCameraPermission) {
return;
}
switch (state) {
case AppLifecycleState.detached:
case AppLifecycleState.hidden:
case AppLifecycleState.paused:
return;
case AppLifecycleState.resumed:
// Restart the scanner when the app is resumed.
// Don't forget to resume listening to the barcode events.
_subscription = mobileScannerController.barcodes.listen(_handleBarcode);
unawaited(mobileScannerController.start());
case AppLifecycleState.inactive:
// Stop the scanner when the app is paused.
// Also stop the barcode events subscription.
unawaited(_subscription?.cancel());
_subscription = null;
unawaited(mobileScannerController.stop());
}
}
Future<void> _handleBarcode(BarcodeCapture barcodeCapture) async {
if (qrcodeFound) return;
if (barcodeCapture.barcodes.isNotEmpty) {
qrcodeFound = true;
mobileScannerController.stop();
final code = barcodeCapture.barcodes.first;
final qrcodeValue = code.displayValue;
debugPrint("Code détecté : ${code.displayValue}");
if (qrcodeValue == null) {
debugPrint("Qrcode non valide");
return;
}
setState(() {
loading = true;
});
final product = await ApiCalls.fetchProduct(code.displayValue!);
if (product != null) {
setState(() {
loading = false;
});
debugPrint('Nom du produit : ${product["product_name"]}');
debugPrint('Marque : ${product["brands"]}');
debugPrint('Image : ${product["image_url"]}');
final productStruct = ProductModel(
id: int.parse(product["id"]),
displayName: product["generic_name"],
);
Box<ProductEntity> productStore = objectboxManager.store
.box<ProductEntity>();
productStore.put(productStruct.toEntity());
//show dialog
await showDialog(
barrierDismissible: false,
context: context,
builder: (dialogContext) {
return Dialog(
elevation: 0,
insetPadding: EdgeInsets.zero,
backgroundColor: Colors.transparent,
alignment: AlignmentDirectional(
0.0,
0.0,
).resolve(Directionality.of(context)),
child: GestureDetector(
onTap: () {
FocusScope.of(dialogContext).unfocus();
FocusManager.instance.primaryFocus?.unfocus();
},
child: SizedBox(
width: MediaQuery.sizeOf(context).width * 0.9,
child: ProductScannedComponent(
productStruct: productStruct,
onRescan: () async {
Navigator.of(context).pop();
qrcodeFound = false;
mobileScannerController.start();
},
onDetails: () {
Navigator.of(context).pop();
unawaited(_subscription?.cancel());
_subscription = null;
unawaited(mobileScannerController.stop());
ProductFormRoute(id: productStruct.id ?? 0).push(context);
},
),
),
),
);
},
);
} else {
setState(() {
loading = false;
});
debugPrint('Aucun produit trouvé.');
}
}
}
Future<void> _fakeBarcode() async {
final qrcodeValue = "0737628064502";
qrcodeFound = true;
mobileScannerController.stop();
setState(() {
loading = true;
});
final product = await ApiCalls.fetchProduct(qrcodeValue);
if (product != null) {
setState(() {
loading = false;
});
debugPrint('Nom du produit : ${product["product_name"]}');
debugPrint('Marque : ${product["brands"]}');
debugPrint('Image : ${product["image_url"]}');
final productStruct = ProductModel(
id: int.parse(product["id"]),
displayName: product["generic_name"],
);
Box<ProductEntity> productStore = objectboxManager.store
.box<ProductEntity>();
productStore.put(productStruct.toEntity());
//show dialog
await showDialog(
barrierDismissible: false,
context: context,
builder: (dialogContext) {
return Dialog(
elevation: 0,
insetPadding: EdgeInsets.zero,
backgroundColor: Colors.transparent,
alignment: AlignmentDirectional(
0.0,
0.0,
).resolve(Directionality.of(context)),
child: GestureDetector(
onTap: () {
FocusScope.of(dialogContext).unfocus();
FocusManager.instance.primaryFocus?.unfocus();
},
child: SizedBox(
width: MediaQuery.sizeOf(context).width * 0.9,
child: ProductScannedComponent(
productStruct: productStruct,
onRescan: () async {
Navigator.of(context).pop();
qrcodeFound = false;
mobileScannerController.start();
},
onDetails: () {
Navigator.of(context).pop();
unawaited(_subscription?.cancel());
_subscription = null;
unawaited(mobileScannerController.stop());
ProductFormRoute(id: productStruct.id ?? 0).push(context);
},
),
),
),
);
},
);
} else {
setState(() {
loading = false;
});
debugPrint('Aucun produit trouvé.');
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(receptionScanPageModelProvider);
final reception = state.reception;
return Scaffold(
backgroundColor: AppTheme.of(context).primaryBackground,
appBar: AppBar(
backgroundColor: AppTheme.of(context).primaryBackground,
automaticallyImplyLeading: false,
leading: IconButton(
icon: Icon(
Icons.arrow_back_ios,
color: AppTheme.of(context).primaryText,
size: 24.0,
),
onPressed: () async {
Navigator.of(context).pop();
},
),
title: Text(
'Réception ${reception?.name}',
style: AppTheme.of(context).titleLarge,
),
actions: [
Padding(
padding: EdgeInsetsDirectional.fromSTEB(8.0, 0.0, 8.0, 0.0),
child: IconButton(
icon: Icon(Icons.help_outline, color: Colors.white, size: 24.0),
onPressed: () {
debugPrint('IconButton pressed ...');
},
),
),
],
centerTitle: true,
elevation: 0.0,
),
body: loading || state.loading
? Center(child: LoadingProgressComponent())
: SizedBox(
width: double.infinity,
height: double.infinity,
child: Stack(
children: [
SizedBox(
width: double.infinity,
height: double.infinity,
child: MobileScanner(
controller: mobileScannerController,
onDetectError: (error, stackTrace) {
debugPrint("===========> $error");
},
errorBuilder: (c, p2) {
return Center(child: Icon(Icons.error));
},
),
),
Container(
width: double.infinity,
height: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppTheme.of(context).primaryBackground,
Colors.transparent,
AppTheme.of(context).primaryBackground,
],
stops: [0.0, 0.5, 1.0],
begin: AlignmentDirectional(0.0, -1.0),
end: AlignmentDirectional(0, 1.0),
),
),
child: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: EdgeInsetsDirectional.fromSTEB(
24.0,
40.0,
24.0,
0.0,
),
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
'Scanner le code',
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.of(context).primaryText,
),
),
Padding(
padding: EdgeInsetsDirectional.fromSTEB(
0.0,
8.0,
0.0,
0.0,
),
child: Text(
'Positionnez le code-barres ou QR code dans le cadre',
textAlign: TextAlign.center,
style: TextStyle(
color: AppTheme.of(context).primaryText,
),
),
),
],
),
),
Align(
alignment: AlignmentDirectional(0.0, 0.0),
child: Container(
width: 280.0,
height: 280.0,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.0),
border: Border.all(
color: Colors.white,
width: 3.0,
),
),
child: GestureDetector(
onTap: () {
_fakeBarcode();
qrcodeFound = false;
mobileScannerController.start();
},
child: Container(
width: 260.0,
height: 260.0,
decoration: BoxDecoration(
color: Color(0x33FFFFFF),
borderRadius: BorderRadius.circular(12.0),
),
child: Padding(
padding: EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(
Icons.qr_code_scanner,
color: Colors.white.withValues(
alpha: .5,
),
size: 64.0,
),
],
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(40),
child: Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
color: AppTheme.of(context).primaryText,
iconSize: 2,
icon: Icon(
mobileScannerController.torchEnabled
? Icons.flash_off
: Icons.flash_on,
color: AppTheme.of(context).primaryText,
size: 28.0,
),
onPressed: () {
mobileScannerController.toggleTorch();
},
),
],
),
),
],
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,45 @@
import 'package:e_scan/backend/api/api_calls.dart';
import 'package:e_scan/backend/objectbox/entities/stock_picking/stock_picking_record_entity.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'reception_scan_page_model.freezed.dart';
final receptionScanPageModelProvider =
StateNotifierProvider.autoDispose<
ReceptionScanPageModel,
ReceptionScanPageModelState
>((ref) {
return ReceptionScanPageModel();
});
class ReceptionScanPageModel
extends StateNotifier<ReceptionScanPageModelState> {
ReceptionScanPageModel() : super(const ReceptionScanPageModelState());
Future getReceptionById({required int id}) async {
try {
state = state.copyWith(loading: true);
final res = await ApiCalls.getStockPikingById(id: id);
res.when(
(data) {
state = state.copyWith(loading: false, reception: data);
},
(error) {
state = state.copyWith(loading: false);
},
);
} catch (e) {
state = state.copyWith(loading: false);
}
}
}
@freezed
abstract class ReceptionScanPageModelState with _$ReceptionScanPageModelState {
const factory ReceptionScanPageModelState({
StockPickingRecordEntity? reception,
@Default(false) bool loading,
}) = _ReceptionScanPageModelState;
}

View File

@ -0,0 +1,157 @@
// dart format width=80
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'reception_scan_page_model.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ReceptionScanPageModelState implements DiagnosticableTreeMixin {
StockPickingRecordEntity? get reception; bool get loading;
/// Create a copy of ReceptionScanPageModelState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ReceptionScanPageModelStateCopyWith<ReceptionScanPageModelState> get copyWith => _$ReceptionScanPageModelStateCopyWithImpl<ReceptionScanPageModelState>(this as ReceptionScanPageModelState, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'ReceptionScanPageModelState'))
..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ReceptionScanPageModelState&&(identical(other.reception, reception) || other.reception == reception)&&(identical(other.loading, loading) || other.loading == loading));
}
@override
int get hashCode => Object.hash(runtimeType,reception,loading);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'ReceptionScanPageModelState(reception: $reception, loading: $loading)';
}
}
/// @nodoc
abstract mixin class $ReceptionScanPageModelStateCopyWith<$Res> {
factory $ReceptionScanPageModelStateCopyWith(ReceptionScanPageModelState value, $Res Function(ReceptionScanPageModelState) _then) = _$ReceptionScanPageModelStateCopyWithImpl;
@useResult
$Res call({
StockPickingRecordEntity? reception, bool loading
});
}
/// @nodoc
class _$ReceptionScanPageModelStateCopyWithImpl<$Res>
implements $ReceptionScanPageModelStateCopyWith<$Res> {
_$ReceptionScanPageModelStateCopyWithImpl(this._self, this._then);
final ReceptionScanPageModelState _self;
final $Res Function(ReceptionScanPageModelState) _then;
/// Create a copy of ReceptionScanPageModelState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? reception = freezed,Object? loading = null,}) {
return _then(_self.copyWith(
reception: freezed == reception ? _self.reception : reception // ignore: cast_nullable_to_non_nullable
as StockPickingRecordEntity?,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
class _ReceptionScanPageModelState with DiagnosticableTreeMixin implements ReceptionScanPageModelState {
const _ReceptionScanPageModelState({this.reception, this.loading = false});
@override final StockPickingRecordEntity? reception;
@override@JsonKey() final bool loading;
/// Create a copy of ReceptionScanPageModelState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ReceptionScanPageModelStateCopyWith<_ReceptionScanPageModelState> get copyWith => __$ReceptionScanPageModelStateCopyWithImpl<_ReceptionScanPageModelState>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'ReceptionScanPageModelState'))
..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ReceptionScanPageModelState&&(identical(other.reception, reception) || other.reception == reception)&&(identical(other.loading, loading) || other.loading == loading));
}
@override
int get hashCode => Object.hash(runtimeType,reception,loading);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'ReceptionScanPageModelState(reception: $reception, loading: $loading)';
}
}
/// @nodoc
abstract mixin class _$ReceptionScanPageModelStateCopyWith<$Res> implements $ReceptionScanPageModelStateCopyWith<$Res> {
factory _$ReceptionScanPageModelStateCopyWith(_ReceptionScanPageModelState value, $Res Function(_ReceptionScanPageModelState) _then) = __$ReceptionScanPageModelStateCopyWithImpl;
@override @useResult
$Res call({
StockPickingRecordEntity? reception, bool loading
});
}
/// @nodoc
class __$ReceptionScanPageModelStateCopyWithImpl<$Res>
implements _$ReceptionScanPageModelStateCopyWith<$Res> {
__$ReceptionScanPageModelStateCopyWithImpl(this._self, this._then);
final _ReceptionScanPageModelState _self;
final $Res Function(_ReceptionScanPageModelState) _then;
/// Create a copy of ReceptionScanPageModelState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? reception = freezed,Object? loading = null,}) {
return _then(_ReceptionScanPageModelState(
reception: freezed == reception ? _self.reception : reception // ignore: cast_nullable_to_non_nullable
as StockPickingRecordEntity?,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@ -21,6 +21,7 @@ final appSecureRoutes = $appRoutes;
TypedGoRoute<DeliveryRoute>(path: 'DeliveryPage'),
TypedGoRoute<InventoryRoute>(path: 'InventoryPage'),
TypedGoRoute<ReceptionDetailsRoute>(path: 'ReceptionDetailsPage'),
TypedGoRoute<ReceptionScanRoute>(path: 'ReceptionScanPage'),
],
)
class SecureRoute extends GoRouteData with _$SecureRoute {
@ -120,3 +121,12 @@ class ReceptionDetailsRoute extends GoRouteData with _$ReceptionDetailsRoute {
Widget build(BuildContext context, GoRouterState state) =>
ReceptionDetailsPage(receptionId: receptionId);
}
class ReceptionScanRoute extends GoRouteData with _$ReceptionScanRoute {
const ReceptionScanRoute({required this.receptionId});
final int receptionId;
@override
Widget build(BuildContext context, GoRouterState state) =>
ReceptionScanPage(receptionId: receptionId);
}

View File

@ -45,6 +45,11 @@ RouteBase get $secureRoute => GoRouteData.$route(
factory: _$ReceptionDetailsRoute._fromState,
),
GoRouteData.$route(
path: 'ReceptionScanPage',
factory: _$ReceptionScanRoute._fromState,
),
],
);
@ -244,3 +249,31 @@ mixin _$ReceptionDetailsRoute on GoRouteData {
@override
void replace(BuildContext context) => context.replace(location);
}
mixin _$ReceptionScanRoute on GoRouteData {
static ReceptionScanRoute _fromState(GoRouterState state) =>
ReceptionScanRoute(
receptionId: int.parse(state.uri.queryParameters['reception-id']!)!,
);
ReceptionScanRoute get _self => this as ReceptionScanRoute;
@override
String get location => GoRouteData.$location(
'/SecurePage/ReceptionScanPage',
queryParams: {'reception-id': _self.receptionId.toString()},
);
@override
void go(BuildContext context) => context.go(location);
@override
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
@override
void pushReplacement(BuildContext context) =>
context.pushReplacement(location);
@override
void replace(BuildContext context) => context.replace(location);
}