Skip to content
Snippets Groups Projects
Commit 41ac7c32 authored by Adrien Morel's avatar Adrien Morel
Browse files

Added images in watchlist + detailled item preview + scanner handeling in listview

parent ad121cf3
Branches
No related tags found
No related merge requests found
Pipeline #21656 passed
image: cirrusci/flutter
stages:
- test # Launching the flutter tests
- build # Trying to build the app
......
......@@ -6,12 +6,40 @@ import 'package:is_eat_safe/models/produit.dart';
import 'models/watchlist_item.dart';
class ApiUtils {
//Returns a list of maximum 20 items based on the number of the page without search filters
//Empty string query doesnt filter anything therefore calling fetchPageByProductId
static Future<List<Produit>> fetchPage(int page) async {
return fetchPageByProductId(page, "");
}
static Future<Produit> getOneRecalledProduct(String id) async {
final response = await http.get(Uri.parse(
"https://data.economie.gouv.fr/api/records/1.0/search/?dataset=rappelconso0&q=$id&rows=1",
));
List<dynamic> decoded = json.decode(response.body)["records"];
if (response.statusCode == 200) {
return Produit(
decoded[0]['fields']['nom_de_la_marque_du_produit'],
decoded[0]['fields']['noms_des_modeles_ou_references'],
decoded[0]['fields']['motif_du_rappel'],
(decoded[0]['fields']['liens_vers_les_images'] ??
'https://st3.depositphotos.com/23594922/31822/v/600/depositphotos_318221368-stock-illustration-missing-picture-page-for-website.jpg'),
decoded[0]['fields']['risques_encourus_par_le_consommateur'],
);
}
return Produit("Marque inconnue", "Nom du produit inconnu", "Motif de rappel inconnu", 'https://st3.depositphotos.com/23594922/31822/v/600/depositphotos_318221368-stock-illustration-missing-picture-page-for-website.jpg', "Risques inconnus");
}
//Returns a list of maximum 20 items based on the number of the page and the search query
static Future<List<Produit>> fetchPageByProductId(int page, String id) async {
page *= 20;
final response = await http.get(
Uri.parse(
"https://data.economie.gouv.fr/api/records/1.0/search/?dataset=rappelconso0&rows=20&start=$page&sort=date_de_publication&facet=nature_juridique_du_rappel&facet=categorie_de_produit&facet=sous_categorie_de_produit&facet=nom_de_la_marque_du_produit&facet=conditionnements&facet=zone_geographique_de_vente&facet=distributeurs&facet=motif_du_rappel&facet=risques_encourus_par_le_consommateur&facet=conduites_a_tenir_par_le_consommateur&facet=modalites_de_compensation&facet=date_de_publication"
,));
final response = await http.get(Uri.parse(
"https://data.economie.gouv.fr/api/records/1.0/search/?dataset=rappelconso0&rows=20&start=$page&q=$id&sort=date_de_publication&facet=nature_juridique_du_rappel&facet=categorie_de_produit&facet=sous_categorie_de_produit&facet=nom_de_la_marque_du_produit&facet=conditionnements&facet=zone_geographique_de_vente&facet=distributeurs&facet=motif_du_rappel&facet=risques_encourus_par_le_consommateur&facet=conduites_a_tenir_par_le_consommateur&facet=modalites_de_compensation&facet=date_de_publication",
));
if (response.statusCode == 200) {
Map<String, dynamic> map = json.decode(response.body);
List<Produit> allProduits = [];
......@@ -21,7 +49,8 @@ class ApiUtils {
i['fields']['nom_de_la_marque_du_produit'],
i['fields']['noms_des_modeles_ou_references'],
i['fields']['motif_du_rappel'],
(i['fields']['liens_vers_les_images'] ?? 'https://st3.depositphotos.com/23594922/31822/v/600/depositphotos_318221368-stock-illustration-missing-picture-page-for-website.jpg'),
(i['fields']['liens_vers_les_images'] ??
'https://st3.depositphotos.com/23594922/31822/v/600/depositphotos_318221368-stock-illustration-missing-picture-page-for-website.jpg'),
i['fields']['risques_encourus_par_le_consommateur'],
);
allProduits.add(p);
......@@ -32,7 +61,37 @@ class ApiUtils {
}
}
static Future<WatchlistItem>? fetchWLItem(String id){
return null;
//Creates a WatchlistItem based on his id obtainned by scanning barcode
static Future<WatchlistItem> fetchWLItem(String id) async {
final response = await http.get(Uri.parse(
"https://world.openfoodfacts.org/api/v0/product/$id.json",
));
var decoded = json.decode(response.body);
if (response.statusCode == 200 && decoded["status_verbose"] != "product not found") {
Map<String, dynamic> map = decoded["product"];
WatchlistItem it = WatchlistItem(
id,
map["product_name"] ?? "Unknown name: $id",
map["image_front_small_url"] ?? "https://us.123rf.com/450wm/dzm1try/dzm1try2011/dzm1try201100099/159901749-secret-product-icon-black-box-clipart-image-isolated-on-white-background.jpg?ver=6",
await isItemRecalled(id));
return it;
}
return WatchlistItem(
id,
"Unknown Product: $id",
"https://us.123rf.com/450wm/dzm1try/dzm1try2011/dzm1try201100099/159901749-secret-product-icon-black-box-clipart-image-isolated-on-white-background.jpg?ver=6",
await isItemRecalled(id));
}
//Call to api to know if the item with id id is recalled
static Future<bool> isItemRecalled(String id) async {
final response = await http.get(Uri.parse(
"https://data.economie.gouv.fr/api/records/1.0/search/?dataset=rappelconso0&q=$id&rows=1",
));
List<dynamic> decoded = json.decode(response.body)["records"];
if (response.statusCode == 200 && decoded.isNotEmpty) {
return true;
}
return false;
}
}
......@@ -12,9 +12,21 @@ part 'produit_state.dart';
class ProduitBloc extends Bloc<ProduitEvent, ProduitState> {
ProduitBloc() : super(ProduitInitial()) {
on<ProduitFetched>((event, emit) async {
emit(await _mapProduitToState(state));
});
on<ProduitSearched>((event, emit) async{
var produits = await ApiUtils.fetchPageByProductId(0, event.search);
emit(ProduitLoaded(produits: produits,search: event.search, hasReachedMax: (produits.length<20)));
});
on<ProduitRefreshed>((event, emit) async {
var produits = await ApiUtils.fetchPage(0);
emit(ProduitLoaded(produits: produits));
});
}
Future<ProduitState> _mapProduitToState(ProduitState state) async {
......@@ -27,8 +39,8 @@ class ProduitBloc extends Bloc<ProduitEvent, ProduitState> {
}
ProduitLoaded produitLoaded = state as ProduitLoaded;
produits = await ApiUtils.fetchPage(state.page);
return produits.isEmpty
produits = await ApiUtils.fetchPageByProductId(state.page, state.search);
return produits.length < 20
? state.copyWith(hasReachedMax: true)
: state.copyWith(produits: state.produits + produits,page: produitLoaded.page + 1);
} catch (_) {
......
......@@ -2,15 +2,21 @@ part of 'produit_bloc.dart';
abstract class ProduitEvent extends Equatable {
const ProduitEvent();
}
class ProduitFetched extends ProduitEvent{
@override
List<Object?> get props => [];
}
class ProduitFetched extends ProduitEvent{
}
class ProduitRefreshed extends ProduitEvent{
@override
List<Object?> get props => [];
}
class ProduitSearched extends ProduitEvent{
final String search;
const ProduitSearched(this.search);
}
\ No newline at end of file
......@@ -25,21 +25,24 @@ class ProduitLoaded extends ProduitState {
const ProduitLoaded({
this.produits = const <Produit>[],
this.page = 1,
this.search = "",
this.hasReachedMax = false
});
final List<Produit> produits;
final int page;
final String search;
final bool hasReachedMax;
ProduitLoaded copyWith({List<Produit>? produits,int? page, bool? hasReachedMax}){
ProduitLoaded copyWith({List<Produit>? produits,int? page,String? search, bool? hasReachedMax}){
return ProduitLoaded(
produits: produits ?? this.produits,
page: page ?? this.page,
search: search ?? this.search,
hasReachedMax: hasReachedMax ?? this.hasReachedMax
);
}
@override
List<Object?> get props => [produits, page, hasReachedMax];
List<Object?> get props => [produits, page, search, hasReachedMax];
}
......@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:is_eat_safe/api_utils.dart';
import 'package:is_eat_safe/models/watchlist_item.dart';
import 'package:shared_preferences/shared_preferences.dart';
......@@ -37,7 +38,7 @@ class WatchlistBloc extends Bloc<WatchlistEvent, WatchlistState> {
Future<WatchlistState> _mapToStateOnAdded(
WatchlistState state, String toBeAddedItemId) async {
try {
var it = WatchlistItem(toBeAddedItemId, "nomProduit", "image");
var it = await ApiUtils.fetchWLItem(toBeAddedItemId);
//TODO Api call to get real data
......@@ -53,6 +54,7 @@ class WatchlistBloc extends Bloc<WatchlistEvent, WatchlistState> {
}
return state.copyWith(wlItems: List.of(state.wlItems)..add(it));
} catch (e) {
print(e);
return WatchlistError();
}
}
......
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:is_eat_safe/api_utils.dart';
import 'package:is_eat_safe/bloc/watchlist_bloc.dart';
import 'package:is_eat_safe/models/watchlist_item.dart';
import 'package:is_eat_safe/views/detailled_product_view.dart';
import 'package:is_eat_safe/views/rappel_listview.dart';
import 'package:barcode_scan2/barcode_scan2.dart';
import 'package:is_eat_safe/views/watchlist_view.dart';
......@@ -11,23 +13,28 @@ import 'bloc/produit_bloc.dart';
late WatchlistBloc _watchlistBloc;
void main() async{
late ProduitBloc _produitBloc;
void main() async {
WidgetsFlutterBinding.ensureInitialized();
//Get saved watchlist items id and convered them in fully exploitable WatchlistItem objetcs
final prefs = await SharedPreferences.getInstance();
final List<String> items = prefs.getStringList('watchlist_items') ?? [];
List<WatchlistItem> tmp = [];
for (var e in items) {
tmp.add(WatchlistItem(e, "nomProduit", "image"));
tmp.add(await ApiUtils.fetchWLItem(e));
}
//Instanciate blocs for use in multiple widgets and views
_watchlistBloc = WatchlistBloc(prefs, tmp);
_produitBloc = ProduitBloc();
runApp(const MyApp());
runApp( MaterialApp(
title: 'IsEatSafe',
theme: ThemeData(primarySwatch: Colors.orange),
debugShowCheckedModeBanner: false,
home: const MyApp(),));
}
class MyApp extends StatefulWidget {
......@@ -39,8 +46,7 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> {
int _index = 0;
bool _searchBoolean = false;
@override
initState() {
......@@ -49,14 +55,30 @@ class _MyAppState extends State<MyApp> {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'IsEatSafe',
theme: ThemeData(primarySwatch: Colors.orange),
debugShowCheckedModeBanner: false,
home: Scaffold(
return Scaffold(
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
appBar: AppBar(
title: const Text('IsEatSafe'),
title: _searchBoolean && _index == 0 ? _searchTextField() : const Text('IsEatSafe'),
actions: [
//add
if (_index == 0)
!_searchBoolean
? IconButton(
icon: const Icon(Icons.search),
onPressed: () {
setState(() {
_searchBoolean = true;
});
})
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_produitBloc.add(ProduitRefreshed());
_searchBoolean = false;
});
}),
],
toolbarHeight: 40,
),
body: Stack(
......@@ -66,7 +88,7 @@ class _MyAppState extends State<MyApp> {
child: TickerMode(
enabled: _index == 0,
child: BlocProvider<ProduitBloc>(
create: (context) => ProduitBloc()..add(ProduitFetched()),
create: (context) => _produitBloc..add(ProduitFetched()),
child: const RappelListView()),
),
),
......@@ -81,21 +103,29 @@ class _MyAppState extends State<MyApp> {
),
],
),
floatingActionButton: _index == 0 ? FloatingActionButton(
floatingActionButton: _index == 0
? FloatingActionButton(
backgroundColor: Colors.black,
onPressed: () async {
//TODO Detailled view of the scanned product
var result = await BarcodeScanner.scan();
if(await ApiUtils.isItemRecalled(result.rawContent)){
ApiUtils.getOneRecalledProduct(result.rawContent).then((value) => Navigator.push(context, MaterialPageRoute(builder: (context) => DetailledProductView(value))));
}else {
showDialog(
context: context,
builder: (_) => _productIsOkDialog(),
barrierDismissible: true,
);
}
},
child: const Icon(
Icons.qr_code_scanner,
color: Colors.white,
),
) :
FloatingActionButton(
)
: FloatingActionButton(
backgroundColor: Colors.black,
onPressed: () async {
//TODO Add scanned product to watchlist bloc
var result = await BarcodeScanner.scan();
_watchlistBloc.add(ElementToBeAdded(result.rawContent));
},
......@@ -104,7 +134,6 @@ class _MyAppState extends State<MyApp> {
color: Colors.white,
),
),
bottomNavigationBar: BottomAppBar(
shape: const CircularNotchedRectangle(),
elevation: 1,
......@@ -140,7 +169,57 @@ class _MyAppState extends State<MyApp> {
],
),
),
);
}
//Search bar widget to be hidden or showed if search icon is clicked
Widget _searchTextField() {
return TextField(
onChanged: (String s) {
//Emits a bloc event to trigger the search/rebuild the listview with desired query
_produitBloc.add(ProduitSearched(s));
},
autofocus: true,
//Display the keyboard when TextField is displayed
cursorColor: Colors.white,
style: const TextStyle(
color: Colors.white,
fontSize: 20,
),
textInputAction: TextInputAction.search,
//Specify the action button on the keyboard
decoration: const InputDecoration(
//Style of TextField
enabledBorder: UnderlineInputBorder(
//Default TextField border
borderSide: BorderSide(color: Colors.white)),
focusedBorder: UnderlineInputBorder(
//Borders when a TextField is in focus
borderSide: BorderSide(color: Colors.white)),
hintText: 'Search', //Text that is displayed when nothing is entered.
hintStyle: TextStyle(
//Style of hintText
color: Colors.white60,
fontSize: 20,
),
),
);
}
Widget _productIsOkDialog(){
return AlertDialog(
title: const Text("Produit OK"),
content: const Text("Selon nos informations ce produit ne fait pas l'objet d'un rappel"),
actions: <Widget>[
TextButton(
child: const Text('Bien reçu!'),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
}
}
import 'package:equatable/equatable.dart';
class WatchlistItem extends Equatable{
String id;
String nomProduit;
String image;
class WatchlistItem {
final String id;
final String nomProduit;
final String image;
bool isRecalled;
WatchlistItem(this.id, this.nomProduit, this.image);
WatchlistItem(this.id, this.nomProduit, this.image, this.isRecalled);
@override
List<Object?> get props => [id, nomProduit, image];
}
\ No newline at end of file
import 'package:flutter/material.dart';
import 'package:is_eat_safe/api_utils.dart';
import '../models/produit.dart';
class DetailledProductView extends StatelessWidget {
final Produit p;
const DetailledProductView(this.p, {super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: CustomScrollView(
slivers: <Widget>[
SliverAppBar(
expandedHeight: 350,
flexibleSpace: FlexibleSpaceBar(
background: Image.network(p.images),
),
),
SliverFixedExtentList(
itemExtent: 30,
delegate: SliverChildListDelegate([
Text(p.nomProduit),
Text(p.motifRappel),
Text(p.risques),
]),
)
],
),
),
);
}
}
......@@ -48,13 +48,14 @@ class _RappelListViewState extends State<RappelListView> {
if (state is ProduitLoaded) {
if (state.produits.isEmpty) {
return const Center(child: Text("no data"),);
return const Center(child: Text("Aucun produit correspondant touvé"),);
}
return ListView.builder(
controller: controller,
itemCount: (state.hasReachedMax) ? state.produits.length : state
.produits.length + 1,
itemBuilder: (BuildContext context, int index) {
if (index < state.produits.length) {
final item = state.produits[index];
......@@ -76,7 +77,7 @@ class _RappelListViewState extends State<RappelListView> {
);
}
else {
return const Center(child: Text("erreur"),);
return const Center(child: Text("Erreur"),);
}
},
);
......
......@@ -2,6 +2,10 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:is_eat_safe/bloc/watchlist_bloc.dart';
import 'package:collection/collection.dart';
import 'package:is_eat_safe/models/produit.dart';
import 'package:is_eat_safe/api_utils.dart';
import 'package:is_eat_safe/views/detailled_product_view.dart';
class WatchListView extends StatefulWidget {
const WatchListView({Key? key}) : super(key: key);
......@@ -41,11 +45,13 @@ class _WatchListViewState extends State<WatchListView> {
return Container(
child: Column(
children: [
if(state.wlItems.firstWhereOrNull((it) => it.isRecalled == true) != null) const Text("Votre list contient un/des produits rappelés"),
TextButton(onPressed: (){
_watchlistBloc.add(WatchlistDeleted());
},
child: const Text("Delete all items")),
ListView.builder(
physics: const RangeMaintainingScrollPhysics(),
scrollDirection: Axis.vertical,
shrinkWrap: true,
itemCount: state.wlItems.length,
......@@ -53,8 +59,14 @@ class _WatchListViewState extends State<WatchListView> {
final item = state.wlItems[index];
return ListTile(
leading: Text(item.image),
title: Text(item.id.toString()),
onTap: () => {
if(item.isRecalled){
ApiUtils.getOneRecalledProduct(item.id).then((value) => Navigator.push(context, MaterialPageRoute(builder: (context) => DetailledProductView(value))))
}
},
tileColor: item.isRecalled ? Colors.redAccent : Colors.white,
leading: Image.network(item.image),
title: Text(item.nomProduit),
trailing: IconButton(
icon: const Icon(Icons.delete_forever),
onPressed: () {
......
PODS:
- FlutterMacOS (1.0.0)
- shared_preferences_macos (0.0.1):
- FlutterMacOS
DEPENDENCIES:
- FlutterMacOS (from `Flutter/ephemeral`)
- shared_preferences_macos (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos`)
EXTERNAL SOURCES:
FlutterMacOS:
:path: Flutter/ephemeral
shared_preferences_macos:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_macos/macos
SPEC CHECKSUMS:
FlutterMacOS: ae6af50a8ea7d6103d888583d46bd8328a7e9811
shared_preferences_macos: a64dc611287ed6cbe28fd1297898db1336975727
PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c
......
......@@ -183,6 +183,7 @@
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
F0926F1CFFB247CE084ED454 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
......@@ -312,6 +313,23 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
F0926F1CFFB247CE084ED454 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment