feat: Implement local product storage using ObjectBox

Configures the ObjectBox ProductEntity to allow assigning IDs. Adds a helper to convert ProductStruct to ProductEntity for easier storage.

Saves scanned products directly to the local ObjectBox store.

Updates the ProductListPage to fetch and display locally stored products from ObjectBox upon initialization.

Includes minor UI adjustments in the scanned product component and scanner page, and fixes loading state handling in HomePageModel.
This commit is contained in:
mandreshope 2025-07-03 21:09:49 +03:00
parent 44289e29dc
commit 09405b5da7
11 changed files with 296 additions and 11 deletions

View File

@ -4,7 +4,7 @@ import 'package:objectbox/objectbox.dart';
/// Modèle de base de données ObjectBox /// Modèle de base de données ObjectBox
@Entity() @Entity()
class ProductEntity { class ProductEntity {
@Id() @Id(assignable: true)
int id; int id;
String? code; String? code;

View File

@ -12,7 +12,7 @@
"id": "1:1853465479129290672", "id": "1:1853465479129290672",
"name": "id", "name": "id",
"type": 6, "type": 6,
"flags": 1 "flags": 129
}, },
{ {
"id": "2:4521897043130066476", "id": "2:4521897043130066476",

View File

@ -29,7 +29,7 @@ final _entities = <obx_int.ModelEntity>[
id: const obx_int.IdUid(1, 1853465479129290672), id: const obx_int.IdUid(1, 1853465479129290672),
name: 'id', name: 'id',
type: 6, type: 6,
flags: 1, flags: 129,
), ),
obx_int.ModelProperty( obx_int.ModelProperty(
id: const obx_int.IdUid(2, 4521897043130066476), id: const obx_int.IdUid(2, 4521897043130066476),

View File

@ -3,6 +3,7 @@ import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:barcode_scanner/backend/objectbox/objectbox.g.dart'; import 'package:barcode_scanner/backend/objectbox/objectbox.g.dart';
export 'package:barcode_scanner/backend/objectbox/objectbox.g.dart';
late ObjectboxManager objectboxManager; late ObjectboxManager objectboxManager;

View File

@ -1,3 +1,4 @@
import 'package:barcode_scanner/backend/objectbox/entities/product/product_entity.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
part 'product_struct.freezed.dart'; part 'product_struct.freezed.dart';
@ -18,3 +19,14 @@ abstract class ProductStruct with _$ProductStruct {
factory ProductStruct.fromJson(Map<String, dynamic> json) => factory ProductStruct.fromJson(Map<String, dynamic> json) =>
_$ProductStructFromJson(json); _$ProductStructFromJson(json);
} }
extension ProductStructExt on ProductStruct {
ProductEntity get toEntity => ProductEntity(
id: id,
name: name,
description: description,
price: price,
quantity: quantity,
image: image,
);
}

View File

@ -73,7 +73,7 @@ class _ProductScannedComponentState extends State<ProductScannedComponent> {
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
child: Image.network( child: Image.network(
widget.productStruct.image ?? '', widget.productStruct.image ?? '',
height: MediaQuery.sizeOf(context).height * .2, height: MediaQuery.sizeOf(context).height * .12,
), ),
), ),
SizedBox(height: 15), SizedBox(height: 15),
@ -164,7 +164,9 @@ class _ProductScannedComponentState extends State<ProductScannedComponent> {
}, },
child: Text( child: Text(
'Voir détails', 'Voir détails',
style: AppTheme.of(context).bodyMedium, style: AppTheme.of(context).bodyMedium.override(
color: AppTheme.of(context).white,
),
), ),
), ),
), ),

View File

@ -29,7 +29,7 @@ class HomePageModel extends StateNotifier<HomePageState> {
Future getUserConnected() async { Future getUserConnected() async {
state = state.copyWith(loading: true); state = state.copyWith(loading: true);
final user = await UserStruct(id: '1').getFromLocalStorage(); final user = await UserStruct(id: '1').getFromLocalStorage();
state = state.copyWith(user: user); state = state.copyWith(user: user, loading: false);
} }
} }

View File

@ -1,5 +1,9 @@
import 'package:barcode_scanner/components/loading_progress_component.dart';
import 'package:barcode_scanner/pages/product_list_page/product_list_page_model.dart';
import 'package:barcode_scanner/router/go_secure_router_builder.dart';
import 'package:barcode_scanner/themes/app_theme.dart'; import 'package:barcode_scanner/themes/app_theme.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
class ProductListPage extends ConsumerStatefulWidget { class ProductListPage extends ConsumerStatefulWidget {
@ -11,8 +15,17 @@ class ProductListPage extends ConsumerStatefulWidget {
} }
class _ProductListPageState extends ConsumerState<ProductListPage> { class _ProductListPageState extends ConsumerState<ProductListPage> {
@override
void initState() {
super.initState();
SchedulerBinding.instance.addPostFrameCallback((_) {
ref.read(productListPageModelProvider.notifier).getProductsLocal();
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(productListPageModelProvider);
return Scaffold( return Scaffold(
backgroundColor: AppTheme.of(context).primaryBackground, backgroundColor: AppTheme.of(context).primaryBackground,
appBar: AppBar( appBar: AppBar(
@ -33,6 +46,55 @@ class _ProductListPageState extends ConsumerState<ProductListPage> {
backgroundColor: AppTheme.of(context).primaryBackground, backgroundColor: AppTheme.of(context).primaryBackground,
elevation: 0, elevation: 0,
), ),
body: state.loading
? Center(child: LoadingProgressComponent())
: Builder(
builder: (context) {
if (state.products.isEmpty) {
return Center(child: Text('Aucun donnée trouvée'));
} else {
return ListView.builder(
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 20),
itemCount: state.products.length,
itemBuilder: (context, index) {
final product = state.products[index];
return ListTile(
onTap: () {
ProductFormRoute().push(context);
},
leading: Container(
height: 60,
width: 60,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(60),
image: DecorationImage(
image: NetworkImage(product.image ?? ''),
),
),
),
title: Text(
product.name ?? '',
style: AppTheme.of(context).titleMedium,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Code: ${product.id}",
style: AppTheme.of(context).bodyMedium,
),
Text(
"Quatité: ${product.quantity ?? ''}",
style: AppTheme.of(context).bodyMedium,
),
],
),
);
},
);
}
},
),
); );
} }
} }

View File

@ -0,0 +1,35 @@
import 'package:barcode_scanner/backend/objectbox/entities/product/product_entity.dart';
import 'package:barcode_scanner/backend/objectbox/objectbox_manager.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product_list_page_model.freezed.dart';
/// The provider for the AuthViewModel, using Riverpod's StateNotifierProvider
/// with autoDispose to manage the lifecycle of the view model.
final productListPageModelProvider =
StateNotifierProvider<ProductListPageModel, ProductListPageState>((ref) {
return ProductListPageModel();
});
class ProductListPageModel extends StateNotifier<ProductListPageState> {
/// Constructor initializes the TaskRepository using the provider reference.
ProductListPageModel() : super(const ProductListPageState());
Future getProductsLocal() async {
Box<ProductEntity> productStore = objectboxManager.store
.box<ProductEntity>();
state = state.copyWith(loading: true);
final products = await productStore.getAllAsync();
state = state.copyWith(products: products, loading: false);
}
}
@freezed
abstract class ProductListPageState with _$ProductListPageState {
const factory ProductListPageState({
@Default(<ProductEntity>[]) List<ProductEntity> products,
@Default(false) bool loading,
}) = _ProductListPageState;
}

View File

@ -0,0 +1,163 @@
// 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 'product_list_page_model.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$ProductListPageState implements DiagnosticableTreeMixin {
List<ProductEntity> get products; bool get loading;
/// Create a copy of ProductListPageState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$ProductListPageStateCopyWith<ProductListPageState> get copyWith => _$ProductListPageStateCopyWithImpl<ProductListPageState>(this as ProductListPageState, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'ProductListPageState'))
..add(DiagnosticsProperty('products', products))..add(DiagnosticsProperty('loading', loading));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is ProductListPageState&&const DeepCollectionEquality().equals(other.products, products)&&(identical(other.loading, loading) || other.loading == loading));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(products),loading);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'ProductListPageState(products: $products, loading: $loading)';
}
}
/// @nodoc
abstract mixin class $ProductListPageStateCopyWith<$Res> {
factory $ProductListPageStateCopyWith(ProductListPageState value, $Res Function(ProductListPageState) _then) = _$ProductListPageStateCopyWithImpl;
@useResult
$Res call({
List<ProductEntity> products, bool loading
});
}
/// @nodoc
class _$ProductListPageStateCopyWithImpl<$Res>
implements $ProductListPageStateCopyWith<$Res> {
_$ProductListPageStateCopyWithImpl(this._self, this._then);
final ProductListPageState _self;
final $Res Function(ProductListPageState) _then;
/// Create a copy of ProductListPageState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? products = null,Object? loading = null,}) {
return _then(_self.copyWith(
products: null == products ? _self.products : products // ignore: cast_nullable_to_non_nullable
as List<ProductEntity>,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
class _ProductListPageState with DiagnosticableTreeMixin implements ProductListPageState {
const _ProductListPageState({final List<ProductEntity> products = const <ProductEntity>[], this.loading = false}): _products = products;
final List<ProductEntity> _products;
@override@JsonKey() List<ProductEntity> get products {
if (_products is EqualUnmodifiableListView) return _products;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_products);
}
@override@JsonKey() final bool loading;
/// Create a copy of ProductListPageState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$ProductListPageStateCopyWith<_ProductListPageState> get copyWith => __$ProductListPageStateCopyWithImpl<_ProductListPageState>(this, _$identity);
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
properties
..add(DiagnosticsProperty('type', 'ProductListPageState'))
..add(DiagnosticsProperty('products', products))..add(DiagnosticsProperty('loading', loading));
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _ProductListPageState&&const DeepCollectionEquality().equals(other._products, _products)&&(identical(other.loading, loading) || other.loading == loading));
}
@override
int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_products),loading);
@override
String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) {
return 'ProductListPageState(products: $products, loading: $loading)';
}
}
/// @nodoc
abstract mixin class _$ProductListPageStateCopyWith<$Res> implements $ProductListPageStateCopyWith<$Res> {
factory _$ProductListPageStateCopyWith(_ProductListPageState value, $Res Function(_ProductListPageState) _then) = __$ProductListPageStateCopyWithImpl;
@override @useResult
$Res call({
List<ProductEntity> products, bool loading
});
}
/// @nodoc
class __$ProductListPageStateCopyWithImpl<$Res>
implements _$ProductListPageStateCopyWith<$Res> {
__$ProductListPageStateCopyWithImpl(this._self, this._then);
final _ProductListPageState _self;
final $Res Function(_ProductListPageState) _then;
/// Create a copy of ProductListPageState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? products = null,Object? loading = null,}) {
return _then(_ProductListPageState(
products: null == products ? _self._products : products // ignore: cast_nullable_to_non_nullable
as List<ProductEntity>,loading: null == loading ? _self.loading : loading // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
// dart format on

View File

@ -1,7 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'package:barcode_scanner/backend/api/api_calls.dart'; import 'package:barcode_scanner/backend/api/api_calls.dart';
import 'package:barcode_scanner/backend/objectbox/entities/product/product_entity.dart';
import 'package:barcode_scanner/backend/objectbox/objectbox_manager.dart';
import 'package:barcode_scanner/backend/schema/product/product_struct.dart'; import 'package:barcode_scanner/backend/schema/product/product_struct.dart';
import 'package:barcode_scanner/components/loading_progress_component.dart';
import 'package:barcode_scanner/components/product_scanned_component.dart'; import 'package:barcode_scanner/components/product_scanned_component.dart';
import 'package:barcode_scanner/router/go_secure_router_builder.dart'; import 'package:barcode_scanner/router/go_secure_router_builder.dart';
import 'package:barcode_scanner/themes/app_theme.dart'; import 'package:barcode_scanner/themes/app_theme.dart';
@ -107,11 +110,14 @@ class _ScannerPageState extends ConsumerState<ScannerPage>
debugPrint('Image : ${product["image_url"]}'); debugPrint('Image : ${product["image_url"]}');
final productStruct = ProductStruct( final productStruct = ProductStruct(
id: int.parse(product["id"]), id: int.parse(product["id"]),
name: product["product_name"],
image: product["image_thumb_url"], image: product["image_thumb_url"],
description: product["categories"], name: product["generic_name"],
description: product["product_name"],
quantity: product["quantity"], quantity: product["quantity"],
); );
Box<ProductEntity> productStore = objectboxManager.store
.box<ProductEntity>();
productStore.put(productStruct.toEntity);
//show dialog //show dialog
await showDialog( await showDialog(
barrierDismissible: false, barrierDismissible: false,
@ -179,10 +185,13 @@ class _ScannerPageState extends ConsumerState<ScannerPage>
final productStruct = ProductStruct( final productStruct = ProductStruct(
id: int.parse(product["id"]), id: int.parse(product["id"]),
image: product["image_thumb_url"], image: product["image_thumb_url"],
name: product["product_name"], name: product["generic_name"],
description: product["categories"], description: product["product_name"],
quantity: product["quantity"], quantity: product["quantity"],
); );
Box<ProductEntity> productStore = objectboxManager.store
.box<ProductEntity>();
productStore.put(productStruct.toEntity);
//show dialog //show dialog
await showDialog( await showDialog(
barrierDismissible: false, barrierDismissible: false,
@ -234,6 +243,7 @@ class _ScannerPageState extends ConsumerState<ScannerPage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.of(context).primaryBackground,
appBar: AppBar( appBar: AppBar(
backgroundColor: AppTheme.of(context).primaryBackground, backgroundColor: AppTheme.of(context).primaryBackground,
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
@ -263,7 +273,7 @@ class _ScannerPageState extends ConsumerState<ScannerPage>
elevation: 0.0, elevation: 0.0,
), ),
body: loading body: loading
? Center(child: CircularProgressIndicator()) ? Center(child: LoadingProgressComponent())
: SizedBox( : SizedBox(
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,