feat: Adds stock picking detail view and API call

Implements a dedicated page for viewing detailed stock picking information.

- Introduces `getStockPikingById` API call to fetch a single stock picking record by ID.
- Enhances `MainAppbarComponent` to support customizable leading widgets, enabling a back button on detail pages.
- Improves `QuickActionComponent` by allowing optional `onTap` callbacks for actions, making buttons conditionally visible and reusable across different contexts.
- Adds `margin` property to `StockPickingCard` for flexible layout adjustments.
- Refactors `ReceptionDetailsPage` to utilize the new API call and display a single stock picking's details, adapting its app bar and quick actions accordingly.
This commit is contained in:
mandreshope 2025-07-29 14:43:43 +03:00
parent 9435907c28
commit 8252cf0b22
9 changed files with 499 additions and 121 deletions

View File

@ -169,4 +169,174 @@ class ApiCalls {
return Result.error(Error(e));
}
}
static Future<Result<StockPickingRecordModel, Error>> getStockPikingById({
required int id,
}) async {
try {
if (!(await checkInternetConnexion())) {
// return local data
}
final response = await dioService.post(
path: '/web/dataset/call_kw/stock.picking/web_read',
data: {
"id": id,
"jsonrpc": "2.0",
"method": "call",
"params": {
"model": "stock.picking",
"method": "web_read",
"args": [
[id],
],
"kwargs": {
"context": {
"lang": "en_US",
"tz": "Africa/Nairobi",
"uid": 2,
"allowed_company_ids": [1],
"bin_size": true,
"active_model": "stock.picking.type",
"active_id": 2,
"active_ids": [2],
"contact_display": "partner_address",
"default_picking_type_id": 2,
"default_company_id": 1,
},
"specification": {
"state": {},
"return_count": {},
"priority": {},
"picking_type_code": {},
"name": {},
"partner_id": {
"fields": {"display_name": {}},
},
"picking_type_id": {
"fields": {"display_name": {}},
},
"location_id": {
"fields": {"complete_name": {}},
},
"location_dest_id": {
"fields": {"complete_name": {}},
},
"backorder_id": {
"fields": {"display_name": {}},
},
"use_create_lots": {},
"scheduled_date": {},
"json_popover": {},
"date_deadline": {},
"products_availability_state": {},
"products_availability": {},
"date_done": {},
"origin": {},
"picking_properties": {},
"move_ids_without_package": {
"fields": {
"company_id": {"fields": {}},
"picking_id": {"fields": {}},
"name": {},
"state": {},
"picking_type_id": {"fields": {}},
"move_line_ids": {},
"location_id": {"fields": {}},
"location_dest_id": {"fields": {}},
"partner_id": {"fields": {}},
"scrapped": {},
"picking_code": {},
"show_details_visible": {},
"additional": {},
"move_lines_count": {},
"is_locked": {},
"product_uom_category_id": {"fields": {}},
"is_storable": {},
"has_tracking": {},
"product_id": {
"fields": {"display_name": {}},
"context": {"default_is_storable": true},
},
"description_picking": {},
"date": {},
"date_deadline": {},
"is_quantity_done_editable": {},
"show_quant": {},
"show_lots_text": {},
"show_lots_m2o": {},
"is_initial_demand_editable": {},
"display_import_lot": {},
"picking_type_entire_packs": {},
"product_uom_qty": {},
"forecast_expected_date": {},
"forecast_availability": {},
"product_qty": {},
"quantity": {},
"product_uom": {"fields": {}},
"picked": {},
},
"context": {
"form_view_ref": "stock.view_stock_move_operations",
},
"limit": 40,
"order": "",
},
"id": {},
"package_level_ids": {
"fields": {
"is_fresh_package": {},
"company_id": {"fields": {}},
"package_id": {
"fields": {"display_name": {}},
},
"state": {},
"is_done": {},
},
"limit": 40,
"order": "",
},
"move_type": {},
"user_id": {
"fields": {"display_name": {}},
},
"sale_id": {
"fields": {"display_name": {}},
},
"note": {},
"show_check_availability": {},
"has_scrap_move": {},
"has_packages": {},
"is_locked": {},
"show_next_pickings": {},
"company_id": {"fields": {}},
"picking_type_entire_packs": {},
"display_name": {},
},
},
},
},
);
if (response.statusCode == 200) {
final data = response.data;
if (data.containsKey('result')) {
final datas = data['result'] as List;
if (datas.isNotEmpty) {
return Result.success(
StockPickingRecordModel.fromJson(datas.first),
);
} else {
return Result.error(Error('Data not found'));
}
} else {
return Result.error(Error(data['error']));
}
} else {
debugPrint('Erreur réseau: ${response.statusCode}');
return Result.error(Error(response.statusMessage));
}
} catch (e) {
debugPrint('Erreur lors de la requête: $e');
return Result.error(Error(e));
}
}
}

View File

@ -8,20 +8,24 @@ class MainAppbarComponent extends StatelessWidget
this.title,
this.subTitle,
this.scaffoledKey,
this.leading,
});
final GlobalKey<ScaffoldState>? scaffoledKey;
final String? title;
final String? subTitle;
final Widget? leading;
@override
Widget build(BuildContext context) {
return AppBar(
leading: IconButton(
icon: Icon(Icons.menu, color: AppTheme.of(context).white),
onPressed: () {
scaffoledKey?.currentState?.openDrawer();
},
),
leading:
leading ??
IconButton(
icon: Icon(Icons.menu, color: AppTheme.of(context).white),
onPressed: () {
scaffoledKey?.currentState?.openDrawer();
},
),
title: title == null || subTitle == null
? null
: Row(

View File

@ -3,7 +3,15 @@ import 'package:barcode_scanner/themes/app_theme.dart';
import 'package:flutter/material.dart';
class QuickActionComponent extends StatelessWidget {
const QuickActionComponent({super.key});
const QuickActionComponent({
super.key,
this.onTapAdd,
this.onTapScan,
this.onTapSearch,
});
final VoidCallback? onTapAdd;
final VoidCallback? onTapScan;
final VoidCallback? onTapSearch;
@override
Widget build(BuildContext context) {
@ -28,52 +36,57 @@ class QuickActionComponent extends StatelessWidget {
).bodyMedium.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: AppTheme.of(context).alternate,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'3 en attente',
style: AppTheme.of(context).bodySmall,
),
),
// Container(
// padding: const EdgeInsets.symmetric(
// horizontal: 10,
// vertical: 4,
// ),
// decoration: BoxDecoration(
// color: AppTheme.of(context).alternate,
// borderRadius: BorderRadius.circular(20),
// ),
// child: Text(
// '3 en attente',
// style: AppTheme.of(context).bodySmall,
// ),
// ),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.maxFinite,
child: PrimaryButtonComponent(
centered: true,
leading: Icon(Icons.add, color: AppTheme.of(context).white),
text: 'Nouvelle Réception',
onPressed: () {},
if (onTapAdd != null) ...[
const SizedBox(height: 16),
SizedBox(
width: double.maxFinite,
child: PrimaryButtonComponent(
centered: true,
leading: Icon(Icons.add, color: AppTheme.of(context).white),
text: 'Nouvelle Réception',
onPressed: onTapAdd,
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.maxFinite,
child: OutlineButtonComponent(
centered: true,
leading: const Icon(Icons.qr_code_scanner),
text: 'Scanner Code-Barres',
onPressed: () {},
],
if (onTapScan != null) ...[
const SizedBox(height: 12),
SizedBox(
width: double.maxFinite,
child: OutlineButtonComponent(
centered: true,
leading: const Icon(Icons.qr_code_scanner),
text: 'Scanner Code-Barres',
onPressed: onTapScan,
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.maxFinite,
child: OutlineButtonComponent(
centered: true,
leading: const Icon(Icons.search),
text: 'Rechercher Existant',
onPressed: () {},
const SizedBox(height: 12),
],
if (onTapSearch != null)
SizedBox(
width: double.maxFinite,
child: OutlineButtonComponent(
centered: true,
leading: const Icon(Icons.search),
text: 'Rechercher Existant',
onPressed: onTapSearch,
),
),
),
],
),
),

View File

@ -10,6 +10,7 @@ class StockPickingCard extends StatelessWidget {
required this.contact,
required this.origin,
required this.status,
this.margin,
});
final String? reference;
final String? from;
@ -17,13 +18,14 @@ class StockPickingCard extends StatelessWidget {
final String? contact;
final String? origin;
final String? status;
final EdgeInsetsGeometry? margin;
@override
Widget build(BuildContext context) {
return Card(
elevation: 3,
color: AppTheme.of(context).primaryBackground,
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
margin: margin ?? const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16.0),

View File

@ -1,5 +1,6 @@
import 'package:barcode_scanner/backend/schema/stock_picking/stock_picking_model.dart';
import 'package:barcode_scanner/components/components.dart';
import 'package:barcode_scanner/pages/operation/reception/reception_details_page_model.dart';
import 'package:barcode_scanner/pages/operation/reception/reception_page_model.dart';
import 'package:barcode_scanner/themes/app_theme.dart';
import 'package:flutter/scheduler.dart';
@ -16,93 +17,48 @@ class ReceptionDetailsPage extends ConsumerStatefulWidget {
}
class _ReceptionDetailsPageState extends ConsumerState<ReceptionDetailsPage> {
final globalKey = GlobalKey<ScaffoldState>();
@override
void initState() {
super.initState();
SchedulerBinding.instance.addPostFrameCallback((_) {
ref.read(receptionPageModelProvider.notifier).getUserConnected();
ref
.read(receptionDetailsPageModelProvider.notifier)
.getReceptionById(id: widget.receptionId);
});
}
@override
Widget build(BuildContext context) {
final state = ref.watch(receptionDetailsPageModelProvider);
final reception = state.reception;
return Scaffold(
backgroundColor: AppTheme.of(context).primaryBackground,
key: globalKey,
drawer: DrawerComponent(isOperationExpanded: true),
appBar: MainAppbarComponent(
scaffoledKey: globalKey,
leading: BackButton(color: AppTheme.of(context).white),
title: "Réceptions",
subTitle: "Opérations d'Entrepôt",
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView(
children: [
const SizedBox(height: 16),
QuickActionComponent(),
const SizedBox(height: 16),
Consumer(
builder: (_, WidgetRef ref, _) {
final state = ref.watch(receptionPageModelProvider);
if (state.loadingReceptions) {
return Center(child: LoadingProgressComponent());
}
return Card(
color: AppTheme.of(context).secondaryBackground,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: AppTheme.of(context).alternate),
body: state.loading
? Center(child: LoadingProgressComponent())
: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView(
children: [
const SizedBox(height: 16),
QuickActionComponent(onTapScan: () {}),
const SizedBox(height: 16),
StockPickingCard(
margin: EdgeInsets.symmetric(horizontal: 5),
reference: reception?.name ?? '',
from: reception?.locationId?.completeName,
to: reception?.locationDestId?.completeName,
contact: reception?.partnerId?.displayName,
origin: reception?.origin,
status: reception?.state,
),
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
spacing: 10,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Réceptions',
style: AppTheme.of(
context,
).bodyMedium.copyWith(fontWeight: FontWeight.bold),
),
state.receptions?.result?.records?.isEmpty == true
? Text('No data')
: ListView.builder(
physics: NeverScrollableScrollPhysics(),
primary: true,
shrinkWrap: true,
itemCount:
(state.receptions?.result?.records ??
<StockPickingRecordModel>[])
.length,
itemBuilder: (context, index) {
final record =
state.receptions?.result?.records![index];
return GestureDetector(
onTap: () {},
child: StockPickingCard(
reference: record?.name ?? '',
from: record?.locationId?.completeName,
to: record?.locationDestId?.completeName,
contact: record?.partnerId?.displayName,
origin: record?.origin,
status: record?.state,
),
);
},
),
],
),
),
);
},
],
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,49 @@
import 'package:barcode_scanner/backend/api/api_calls.dart';
import 'package:barcode_scanner/backend/schema/stock_picking/stock_picking_model.dart';
import 'package:barcode_scanner/backend/schema/user/user_struct.dart';
import 'package:barcode_scanner/services/secure_storage.dart';
import 'package:barcode_scanner/services/token_provider.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'reception_details_page_model.freezed.dart';
final receptionDetailsPageModelProvider =
StateNotifierProvider.autoDispose<
ReceptionDetailsPageModel,
ReceptionDetailsPageState
>((ref) {
return ReceptionDetailsPageModel();
});
class ReceptionDetailsPageModel
extends StateNotifier<ReceptionDetailsPageState> {
ReceptionDetailsPageModel() : super(const ReceptionDetailsPageState());
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 ReceptionDetailsPageState with _$ReceptionDetailsPageState {
const factory ReceptionDetailsPageState({
StockPickingRecordModel? reception,
@Default(false) bool loading,
}) = _ReceptionDetailsPageState;
}

View File

@ -0,0 +1,181 @@
// 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_details_page_model.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ReceptionDetailsPageState implements DiagnosticableTreeMixin {
StockPickingRecordModel? get reception; bool get loading;
/// Create a copy of ReceptionDetailsPageState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ReceptionDetailsPageStateCopyWith<ReceptionDetailsPageState> get copyWith => _$ReceptionDetailsPageStateCopyWithImpl<ReceptionDetailsPageState>(this as ReceptionDetailsPageState, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'ReceptionDetailsPageState'))
..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ReceptionDetailsPageState&&(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 'ReceptionDetailsPageState(reception: $reception, loading: $loading)';
}
}
/// @nodoc
abstract mixin class $ReceptionDetailsPageStateCopyWith<$Res> {
factory $ReceptionDetailsPageStateCopyWith(ReceptionDetailsPageState value, $Res Function(ReceptionDetailsPageState) _then) = _$ReceptionDetailsPageStateCopyWithImpl;
@useResult
$Res call({
StockPickingRecordModel? reception, bool loading
});
$StockPickingRecordModelCopyWith<$Res>? get reception;
}
/// @nodoc
class _$ReceptionDetailsPageStateCopyWithImpl<$Res>
implements $ReceptionDetailsPageStateCopyWith<$Res> {
_$ReceptionDetailsPageStateCopyWithImpl(this._self, this._then);
final ReceptionDetailsPageState _self;
final $Res Function(ReceptionDetailsPageState) _then;
/// Create a copy of ReceptionDetailsPageState
/// 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 StockPickingRecordModel?,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable
as bool,
));
}
/// Create a copy of ReceptionDetailsPageState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$StockPickingRecordModelCopyWith<$Res>? get reception {
if (_self.reception == null) {
return null;
}
return $StockPickingRecordModelCopyWith<$Res>(_self.reception!, (value) {
return _then(_self.copyWith(reception: value));
});
}
}
/// @nodoc
class _ReceptionDetailsPageState with DiagnosticableTreeMixin implements ReceptionDetailsPageState {
const _ReceptionDetailsPageState({this.reception, this.loading = false});
@override final StockPickingRecordModel? reception;
@override@JsonKey() final bool loading;
/// Create a copy of ReceptionDetailsPageState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ReceptionDetailsPageStateCopyWith<_ReceptionDetailsPageState> get copyWith => __$ReceptionDetailsPageStateCopyWithImpl<_ReceptionDetailsPageState>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'ReceptionDetailsPageState'))
..add(DiagnosticsProperty('reception', reception))..add(DiagnosticsProperty('loading', loading));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ReceptionDetailsPageState&&(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 'ReceptionDetailsPageState(reception: $reception, loading: $loading)';
}
}
/// @nodoc
abstract mixin class _$ReceptionDetailsPageStateCopyWith<$Res> implements $ReceptionDetailsPageStateCopyWith<$Res> {
factory _$ReceptionDetailsPageStateCopyWith(_ReceptionDetailsPageState value, $Res Function(_ReceptionDetailsPageState) _then) = __$ReceptionDetailsPageStateCopyWithImpl;
@override @useResult
$Res call({
StockPickingRecordModel? reception, bool loading
});
@override $StockPickingRecordModelCopyWith<$Res>? get reception;
}
/// @nodoc
class __$ReceptionDetailsPageStateCopyWithImpl<$Res>
implements _$ReceptionDetailsPageStateCopyWith<$Res> {
__$ReceptionDetailsPageStateCopyWithImpl(this._self, this._then);
final _ReceptionDetailsPageState _self;
final $Res Function(_ReceptionDetailsPageState) _then;
/// Create a copy of ReceptionDetailsPageState
/// 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(_ReceptionDetailsPageState(
reception: freezed == reception ? _self.reception : reception // ignore: cast_nullable_to_non_nullable
as StockPickingRecordModel?,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable
as bool,
));
}
/// Create a copy of ReceptionDetailsPageState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$StockPickingRecordModelCopyWith<$Res>? get reception {
if (_self.reception == null) {
return null;
}
return $StockPickingRecordModelCopyWith<$Res>(_self.reception!, (value) {
return _then(_self.copyWith(reception: value));
});
}
}
// dart format on

View File

@ -40,7 +40,11 @@ class _ReceptionPageState extends ConsumerState<ReceptionPage> {
child: ListView(
children: [
const SizedBox(height: 16),
QuickActionComponent(),
QuickActionComponent(
onTapAdd: () {},
onTapScan: () {},
onTapSearch: () {},
),
const SizedBox(height: 16),
Consumer(
builder: (_, WidgetRef ref, _) {

View File

@ -20,7 +20,6 @@ final receptionPageModelProvider =
});
class ReceptionPageModel extends StateNotifier<ReceptionPageState> {
/// Constructor initializes the TaskRepository using the provider reference.
ReceptionPageModel({
required this.secureStorage,
required this.tokenProvider,