Repository: openfoodfacts/smooth-app
Pull request #6561: feat: Add infinite scrolling to the various Open Prices ListViews.
Author: login: AshutoshKhadse23
Labels: name: 🌐 l10n
name: Prices
Created: 2025-04-21T19:36:27Z | Merged: 2025-05-08T13:15:49Z

Closes: #6478 

I added infinite scrolling to open the prices list view.

This is how the solution looks :

https://github.com/user-attachments/assets/fd4b4a34-5e06-4439-9645-bd7048dab87f




Diff:
diff --git a/packages/smooth_app/lib/l10n/app_en.arb b/packages/smooth_app/lib/l10n/app_en.arb
index 0c821ed9d922..ea8bed959de5 100644
--- a/packages/smooth_app/lib/l10n/app_en.arb
+++ b/packages/smooth_app/lib/l10n/app_en.arb
@@ -4378,5 +4378,47 @@
   "product_edit_robotoff_expand_proof": "Expand proof",
   "@product_edit_robotoff_expand_proof": {
     "description": "Button to open in fullscreen the proof"
+  },
+   "page_indicator_with_total": "Page {current} / {total}",
+  "@page_indicator_with_total": {
+    "description": "Page indicator showing current page and total pages",
+    "placeholders": {
+      "current": {"type": "int", "description": "Current page number"},
+      "total": {"type": "int", "description": "Total number of pages"}
+    }
+  },
+  "page_indicator": "Page {current}",
+  "@page_indicator": {
+    "description": "Page indicator showing only current page when total is unknown",
+    "placeholders": {
+      "current": {"type": "int", "description": "Current page number"}
+    }
+  },
+  "item_count_with_total": "{count} of {total} items",
+  "@item_count_with_total": {
+    "description": "Item count showing current number of items and total items",
+    "placeholders": {
+      "count": {"type": "int", "description": "Current number of items loaded"},
+      "total": {"type": "int", "description": "Total number of items available"}
+    }
+  },
+  "item_count": "{count} items",
+  "@item_count": {
+    "description": "Item count showing only current number of items when total is unknown",
+    "placeholders": {
+      "count": {"type": "int", "description": "Current number of items loaded"}
+    }
+  },
+  "prices_no_result": "No price statistics found.",
+  "@prices_no_result": {
+    "description": "Message shown when there are no price statistics available to display"
+  },
+  "prices_error_loading_more_items": "Error loading more items",
+  "@prices_error_loading_more_items": {
+    "description": "Error message shown when additional items fail to load"
+  },
+  "prices_proof_error": "Authentication failed, unable to fetch proofs",
+  "@prices_proof_error": {
+    "description": "Error message shown when unable to fetch proofs"
   }
 }
\ No newline at end of file
diff --git a/packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart b/packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
new file mode 100644
index 000000000000..e5f7b2ab0729
--- /dev/null
+++ b/packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
@@ -0,0 +1,167 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/generic_lib/widgets/smooth_card.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_manager.dart';
+
+/// A generic stateful widget for infinite scrolling lists that works with InfiniteScrollManager.
+class InfiniteScrollList<T> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.manager,
+  });
+
+  /// Manager for handling the infinite scroll behavior
+  final InfiniteScrollManager<T> manager;
+
+  @override
+  State<InfiniteScrollList<T>> createState() => _InfiniteScrollListState<T>();
+}
+
+class _InfiniteScrollListState<T> extends State<InfiniteScrollList<T>> {
+  static const double _loadMoreTriggerOffset = 200.0;
+
+  late final ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    unawaited(_initialLoad());
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  Future<void> _initialLoad() async {
+    setState(() {
+      _isInitialLoading = true;
+      _error = null;
+    });
+
+    try {
+      await widget.manager.loadInitiallyIfNeeded(context);
+    } catch (e) {
+      _error = e;
+    } finally {
+      if (mounted) {
+        setState(() {
+          _isInitialLoading = false;
+        });
+      }
+    }
+  }
+
+  void _scrollListener() {
+    if (!widget.manager.canLoadMore()) {
+      return;
+    }
+
+    final double maxScroll = _scrollController.position.maxScrollExtent;
+    final double currentScroll = _scrollController.position.pixels;
+
+    if (currentScroll > maxScroll - _loadMoreTriggerOffset) {
+      unawaited(_loadMoreItems());
+    }
+  }
+
+  Future<void> _loadMoreItems() async {
+    if (mounted) {
+      setState(() {});
+      await widget.manager.loadMore(context);
+      if (mounted) {
+        setState(() {});
+      }
+    }
+  }
+
+  Widget _buildLoadingState(BuildContext context) {
+    return const Center(child: CircularProgressIndicator());
+  }
+
+  Widget _buildErrorState(BuildContext context, dynamic error) {
+    return Text(error.toString());
+  }
+
+  Widget _buildEmptyState(BuildContext context) {
+    return Text(AppLocalizations.of(context).prices_no_result);
+  }
+
+  Widget _buildLoadingMoreIndicator(BuildContext context) {
+    return const Padding(
+      padding: EdgeInsets.symmetric(vertical: 16.0),
+      child: Center(child: CircularProgressIndicator()),
+    );
+  }
+
+  Widget _buildFooter(BuildContext context) {
+    return const SizedBox(height: MINIMUM_TOUCH_SIZE * 2);
+  }
+
+  Widget _buildHeader(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    String title;
+    final int totalPages = widget.manager.totalPages ?? 1;
+    final int currentPage = widget.manager.currentPage;
+    final int itemsCount = widget.manager.items.length;
+    final int totalItems = widget.manager.totalItems ?? itemsCount;
+
+    if (totalPages > 1) {
+      title = appLocalizations.prices_list_length_many_pages(
+        itemsCount,
+        totalItems,
+      );
+      title = '$title ($currentPage / $totalPages)';
+    } else {
+      title = appLocalizations.prices_list_length_one_page(
+        itemsCount,
+      );
+    }
+
+    return SmoothCard(child: ListTile(title: Text(title)));
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (_isInitialLoading) {
+      return _buildLoadingState(context);
+    }
+
+    if (_error != null) {
+      return _buildErrorState(context, _error);
+    }
+
+    if (widget.manager.items.isEmpty) {
+      return _buildEmptyState(context);
+    }
+
+    final List<Widget> children = <Widget>[];
+
+    children.add(_buildHeader(context));
+
+    for (final T item in widget.manager.items) {
+      children.add(widget.manager.getItemWidget(
+        context: context,
+        item: item,
+      ));
+    }
+
+    if (widget.manager.isLoading) {
+      children.add(_buildLoadingMoreIndicator(context));
+    }
+
+    children.add(_buildFooter(context));
+
+    return ListView(
+      controller: _scrollController,
+      children: children,
+    );
+  }
+}
diff --git a/packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart b/packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
new file mode 100644
index 000000000000..0a63f56e07da
--- /dev/null
+++ b/packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
@@ -0,0 +1,149 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = initialItems ?? <T>[],
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  @protected
+  Future<void> fetchInit() async {}
+
+  /// Fetches data for a specific page
+  @protected
+  Future<void> fetchData(int pageNumber);
+
+  /// Displays an item.
+  @protected
+  Widget buildItem({
+    required BuildContext context,
+    required T item,
+  });
+
+  Widget getItemWidget({required BuildContext context, required T item}) {
+    return buildItem(context: context, item: item);
+  }
+
+  /// Update the list with new items and pagination info
+  @protected
+  void updateItems({
+    required List<T>? newItems,
+    required int? pageNumber,
+    required int? totalItems,
+    required int? totalPages,
+  }) {
+    if (newItems == null && pageNumber == null) {
+      return;
+    }
+    if (newItems != null) {
+      _items.addAll(newItems);
+    }
+    if (pageNumber != null) {
+      _currentPage = pageNumber;
+    }
+    _totalItems = totalItems ?? _totalItems;
+    _totalPages = totalPages ?? _totalPages;
+  }
+
+  /// Load initial data only if the list is empty
+  Future<void> loadInitiallyIfNeeded(BuildContext context) async {
+    await fetchInit();
+    if (_items.isNotEmpty) {
+      return;
+    }
+    if (context.mounted) {
+      await _load(context: context, pageNumber: _initialPage);
+    }
+  }
+
+  bool canLoadMore() {
+    return !_isLoading && (totalPages == null || currentPage < totalPages!);
+  }
+
+  /// Load more items (next page)
+  Future<void> loadMore(BuildContext context) async {
+    if (_totalPages != null && _currentPage >= _totalPages!) {
+      return;
+    }
+    await _load(context: context, pageNumber: _currentPage + 1);
+  }
+
+  /// Internal method to handle loading with error handling
+  Future<void> _load({
+    required BuildContext context,
+    required int pageNumber,
+  }) async {
+    if (_isLoading) {
+      return;
+    }
+
+    _isLoading = true;
+
+    try {
+      await fetchData(pageNumber);
+    } catch (e) {
+      if (context.mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(
+            content: Text(
+              AppLocalizations.of(context).prices_error_loading_more_items,
+            ),
+          ),
+        );
+      }
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Returns a formatted page indicator (e.g., "Page 1 / 5")
+  String formattedPageIndicator(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return _totalPages != null
+        ? appLocalizations.page_indicator_with_total(_currentPage, _totalPages!)
+        : appLocalizations.page_indicator(_currentPage);
+  }
+
+  /// Returns a formatted item count (e.g., "25 of 100 items")
+  String formattedItemCount(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return _totalItems != null
+        ? appLocalizations.item_count_with_total(_items.length, _totalItems!)
+        : appLocalizations.item_count(_items.length);
+  }
+}
diff --git a/packages/smooth_app/lib/pages/prices/prices_locations_page.dart b/packages/smooth_app/lib/pages/prices/prices_locations_page.dart
index ae5ce783884e..525a02e0e3a7 100644
--- a/packages/smooth_app/lib/pages/prices/prices_locations_page.dart
+++ b/packages/smooth_app/lib/pages/prices/prices_locations_page.dart
@@ -6,6 +6,8 @@ import 'package:smooth_app/generic_lib/design_constants.dart';
 import 'package:smooth_app/generic_lib/widgets/smooth_back_button.dart';
 import 'package:smooth_app/generic_lib/widgets/smooth_card.dart';
 import 'package:smooth_app/helpers/launch_url_helper.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_list.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_manager.dart';
 import 'package:smooth_app/pages/prices/price_button.dart';
 import 'package:smooth_app/pages/prices/price_count_widget.dart';
 import 'package:smooth_app/pages/prices/price_location_widget.dart';
@@ -13,7 +15,7 @@ import 'package:smooth_app/query/product_query.dart';
 import 'package:smooth_app/widgets/smooth_app_bar.dart';
 import 'package:smooth_app/widgets/smooth_scaffold.dart';
 
-/// Page that displays the top prices locations.
+/// Page that displays the top prices locations with infinite scrolling.
 class PricesLocationsPage extends StatefulWidget {
   const PricesLocationsPage();
 
@@ -23,16 +25,13 @@ class PricesLocationsPage extends StatefulWidget {
 
 class _PricesLocationsPageState extends State<PricesLocationsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetLocationsResult>> _locations =
-      _showTopLocations();
-
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
-  static const int _pageSize = 10;
+  final _InfiniteScrollLocationManager _locationManager =
+      _InfiniteScrollLocationManager();
 
   @override
   Widget build(BuildContext context) {
     final AppLocalizations appLocalizations = AppLocalizations.of(context);
+
     return SmoothScaffold(
       appBar: SmoothAppBar(
         centerTitle: false,
@@ -53,117 +52,87 @@ class _PricesLocationsPageState extends State<PricesLocationsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetLocationsResult>>(
-        future: _locations,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetLocationsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetLocationsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-
-          for (final Location item in result.items!) {
-            final int priceCount = item.priceCount ?? 0;
-            children.add(
-              SmoothCard(
-                child: Wrap(
-                  spacing: VERY_SMALL_SPACE,
-                  children: <Widget>[
-                    PriceLocationWidget(item),
-                    PriceCountWidget(
-                      count: priceCount,
-                      onPressed: () async =>
-                          PriceLocationWidget.showLocationPrices(
-                        locationId: item.locationId,
-                        context: context,
-                      ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.userCount}',
-                      iconData: PriceButton.userIconData,
-                      tooltip: item.userCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_user(
-                              item.userCount!,
-                            ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.productCount}',
-                      iconData: PriceButton.productIconData,
-                      tooltip: item.productCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_product(
-                              item.productCount!,
-                            ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.proofCount}',
-                      iconData: PriceButton.proofIconData,
-                      tooltip: item.proofCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_proof(
-                              item.proofCount!,
-                            ),
-                    ),
-                  ],
-                ),
-              ),
-            );
-          }
-          final String title =
-              appLocalizations.prices_locations_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<Location>(
+        manager: _locationManager,
       ),
     );
   }
+}
+
+/// A manager for handling location data with infinite scrolling
+class _InfiniteScrollLocationManager extends InfiniteScrollManager<Location> {
+  @override
+  Future<void> fetchData(final int pageNumber) async {
+    final MaybeError<GetLocationsResult> result =
+        await OpenPricesAPIClient.getLocations(
+      GetLocationsParameters()
+        ..pageNumber = pageNumber
+        ..pageSize = 10,
+    );
+    if (result.isError) {
+      throw result.detailError;
+    }
+    final GetLocationsResult value = result.value;
+    updateItems(
+      newItems: value.items,
+      pageNumber: value.pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
+    );
+  }
+
+  @override
+  Widget buildItem({
+    required BuildContext context,
+    required Location item,
+  }) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    final int priceCount = item.priceCount ?? 0;
 
-  static Future<MaybeError<GetLocationsResult>> _showTopLocations() async =>
-      OpenPricesAPIClient.getLocations(
-        GetLocationsParameters()
-          ..orderBy = <OrderBy<GetLocationsOrderField>>[
-            const OrderBy<GetLocationsOrderField>(
-              field: GetLocationsOrderField.priceCount,
-              ascending: false,
+    return SmoothCard(
+      child: Wrap(
+        spacing: VERY_SMALL_SPACE,
+        children: <Widget>[
+          PriceLocationWidget(item),
+          PriceCountWidget(
+            count: priceCount,
+            onPressed: () async => PriceLocationWidget.showLocationPrices(
+              locationId: item.locationId,
+              context: context,
             ),
-          ]
-          ..pageSize = _pageSize
-          ..pageNumber = 1,
-        uriHelper: ProductQuery.uriPricesHelper,
-      );
+          ),
+          PriceButton(
+            onPressed: () {},
+            title: '${item.userCount}',
+            iconData: PriceButton.userIconData,
+            tooltip: item.userCount == null
+                ? null
+                : appLocalizations.prices_button_count_user(
+                    item.userCount!,
+                  ),
+          ),
+          PriceButton(
+            onPressed: () {},
+            title: '${item.productCount}',
+            iconData: PriceButton.productIconData,
+            tooltip: item.productCount == null
+                ? null
+                : appLocalizations.prices_button_count_product(
+                    item.productCount!,
+                  ),
+          ),
+          PriceButton(
+            onPressed: () {},
+            title: '${item.proofCount}',
+            iconData: PriceButton.proofIconData,
+            tooltip: item.proofCount == null
+                ? null
+                : appLocalizations.prices_button_count_proof(
+                    item.proofCount!,
+                  ),
+          ),
+        ],
+      ),
+    );
+  }
 }
diff --git a/packages/smooth_app/lib/pages/prices/prices_products_page.dart b/packages/smooth_app/lib/pages/prices/prices_products_page.dart
index b29f0fc41130..50a963192dc5 100644
--- a/packages/smooth_app/lib/pages/prices/prices_products_page.dart
+++ b/packages/smooth_app/lib/pages/prices/prices_products_page.dart
@@ -2,16 +2,17 @@ import 'package:flutter/material.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:matomo_tracker/matomo_tracker.dart';
 import 'package:openfoodfacts/openfoodfacts.dart';
-import 'package:smooth_app/generic_lib/design_constants.dart';
 import 'package:smooth_app/generic_lib/widgets/smooth_back_button.dart';
 import 'package:smooth_app/generic_lib/widgets/smooth_card.dart';
 import 'package:smooth_app/helpers/launch_url_helper.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_list.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_manager.dart';
 import 'package:smooth_app/pages/prices/price_product_widget.dart';
 import 'package:smooth_app/query/product_query.dart';
 import 'package:smooth_app/widgets/smooth_app_bar.dart';
 import 'package:smooth_app/widgets/smooth_scaffold.dart';
 
-/// Page that displays the top prices products.
+/// Page that displays the top prices products with infinite scrolling.
 class PricesProductsPage extends StatefulWidget {
   const PricesProductsPage();
 
@@ -21,16 +22,13 @@ class PricesProductsPage extends StatefulWidget {
 
 class _PricesProductsPageState extends State<PricesProductsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetPriceProductsResult>> _products =
-      _showTopProducts();
-
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
-  static const int _pageSize = 10;
+  final _InfiniteScrollProductManager _productManager =
+      _InfiniteScrollProductManager();
 
   @override
-  Widget build(BuildContext context) {
+  Widget build(final BuildContext context) {
     final AppLocalizations appLocalizations = AppLocalizations.of(context);
+
     return SmoothScaffold(
       appBar: SmoothAppBar(
         centerTitle: false,
@@ -51,76 +49,60 @@ class _PricesProductsPageState extends State<PricesProductsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetPriceProductsResult>>(
-        future: _products,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetPriceProductsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetPriceProductsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-
-          for (final PriceProduct item in result.items!) {
-            children.add(
-              SmoothCard(
-                child: PriceProductWidget(
-                  item,
-                  enableCountButton: true,
-                ),
-              ),
-            );
-          }
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title =
-              appLocalizations.prices_products_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<PriceProduct>(
+        manager: _productManager,
       ),
     );
   }
+}
 
-  static Future<MaybeError<GetPriceProductsResult>> _showTopProducts() async =>
-      OpenPricesAPIClient.getPriceProducts(
-        GetPriceProductsParameters()
-          ..orderBy = <OrderBy<GetPriceProductsOrderField>>[
-            const OrderBy<GetPriceProductsOrderField>(
-              field: GetPriceProductsOrderField.priceCount,
-              ascending: false,
-            ),
-          ]
-          ..pageSize = _pageSize
-          ..pageNumber = 1,
-        uriHelper: ProductQuery.uriPricesHelper,
-      );
+/// A manager for handling product data with infinite scrolling
+class _InfiniteScrollProductManager
+    extends InfiniteScrollManager<PriceProduct> {
+  static const int _pageSize = 10;
+
+  static const List<OrderBy<GetPriceProductsOrderField>> _orderBy =
+      <OrderBy<GetPriceProductsOrderField>>[
+    OrderBy<GetPriceProductsOrderField>(
+      field: GetPriceProductsOrderField.priceCount,
+      ascending: false,
+    ),
+  ];
+
+  @override
+  Future<void> fetchData(final int pageNumber) async {
+    final MaybeError<GetPriceProductsResult> result =
+        await OpenPricesAPIClient.getPriceProducts(
+      GetPriceProductsParameters()
+        ..pageNumber = pageNumber
+        ..pageSize = _pageSize
+        ..orderBy = _orderBy,
+      uriHelper: ProductQuery.uriPricesHelper,
+    );
+
+    if (result.isError) {
+      throw result.detailError;
+    }
+
+    final GetPriceProductsResult value = result.value;
+    updateItems(
+      newItems: value.items,
+      pageNumber: value.pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
+    );
+  }
+
+  @override
+  Widget buildItem({
+    required BuildContext context,
+    required PriceProduct item,
+  }) {
+    return SmoothCard(
+      child: PriceProductWidget(
+        item,
+        enableCountButton: true,
+      ),
+    );
+  }
 }
diff --git a/packages/smooth_app/lib/pages/prices/prices_proofs_page.dart b/packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
index a6686ec89df2..940ec92fa327 100644
--- a/packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
+++ b/packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
@@ -1,4 +1,3 @@
-import 'package:auto_size_text/auto_size_text.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:intl/intl.dart';
@@ -9,6 +8,8 @@ import 'package:smooth_app/generic_lib/widgets/images/smooth_image.dart';
 import 'package:smooth_app/generic_lib/widgets/smooth_back_button.dart';
 import 'package:smooth_app/generic_lib/widgets/smooth_card.dart';
 import 'package:smooth_app/helpers/launch_url_helper.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_list.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_manager.dart';
 import 'package:smooth_app/pages/prices/price_proof_page.dart';
 import 'package:smooth_app/query/product_query.dart';
 import 'package:smooth_app/widgets/smooth_app_bar.dart';
@@ -29,11 +30,16 @@ class PricesProofsPage extends StatefulWidget {
 
 class _PricesProofsPageState extends State<PricesProofsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetProofsResult>> _results = _download();
+  late final _InfiniteScrollProofManager _proofManager =
+      _InfiniteScrollProofManager(
+    selectProof: widget.selectProof,
+  );
 
-  static const int _columns = 3;
-  static const int _rows = 5;
-  static const int _pageSize = _columns * _rows;
+  @override
+  void dispose() {
+    _proofManager.dispose();
+    super.dispose();
+  }
 
   @override
   Widget build(BuildContext context) {
@@ -58,106 +64,25 @@ class _PricesProofsPageState extends State<PricesProofsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetProofsResult>>(
-        future: _results,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetProofsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetProofsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final double squareSize = MediaQuery.sizeOf(context).width / _columns;
-
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title = result.numberOfPages == 1
-              ? appLocalizations.prices_proofs_list_length_one_page(
-                  result.items!.length,
-                )
-              : appLocalizations.prices_proofs_list_length_many_pages(
-                  _pageSize,
-                  result.total!,
-                );
-          return Column(
-            children: <Widget>[
-              SmoothCard(
-                child: ListTile(
-                  title: Text(title),
-                ),
-              ),
-              if (result.items!.isNotEmpty)
-                Expanded(
-                  child: CustomScrollView(
-                    slivers: <Widget>[
-                      SliverGrid(
-                        gridDelegate:
-                            const SliverGridDelegateWithFixedCrossAxisCount(
-                          crossAxisCount: _columns,
-                        ),
-                        delegate: SliverChildBuilderDelegate(
-                          (
-                            final BuildContext context,
-                            final int index,
-                          ) {
-                            final Proof proof = result.items![index];
-                            if (proof.filePath == null) {
-                              // highly improbable
-                              return SizedBox(
-                                width: squareSize,
-                                height: squareSize,
-                              );
-                            }
-                            return InkWell(
-                              onTap: () async {
-                                if (widget.selectProof) {
-                                  Navigator.of(context).pop(proof);
-                                  return;
-                                }
-                                return Navigator.push<void>(
-                                  context,
-                                  MaterialPageRoute<void>(
-                                    builder: (BuildContext context) =>
-                                        PriceProofPage(
-                                      proof,
-                                    ),
-                                  ),
-                                );
-                              }, // PriceProofPage
-                              child: _PriceProofImage(proof,
-                                  squareSize: squareSize),
-                            );
-                          },
-                          addAutomaticKeepAlives: false,
-                          childCount: result.items!.length,
-                        ),
-                      ),
-                    ],
-                  ),
-                ),
-            ],
-          );
-        },
+      body: InfiniteScrollList<Proof>(
+        manager: _proofManager,
       ),
     );
   }
+}
+
+/// A manager for handling proof data with infinite scrolling
+class _InfiniteScrollProofManager extends InfiniteScrollManager<Proof> {
+  _InfiniteScrollProofManager({
+    required this.selectProof,
+  });
+
+  static const int _pageSize = 10;
+  final bool selectProof;
+  String? _bearerToken;
 
-  static Future<MaybeError<GetProofsResult>> _download() async {
+  @override
+  Future<void> fetchInit() async {
     final User user = ProductQuery.getWriteUser();
     final MaybeError<String> token =
         await OpenPricesAPIClient.getAuthenticationToken(
@@ -167,14 +92,19 @@ class _PricesProofsPageState extends State<PricesProofsPage>
     );
 
     if (token.isError) {
-      return MaybeError<GetProofsResult>.error(
-        error: token.error ?? 'Could not authenticate with the server',
-        statusCode: token.statusCode ?? 500,
-      );
+      throw Exception(token.error ?? 'Could not authenticate with the server');
     }
 
-    final String bearerToken = token.value;
+    _bearerToken = token.value;
+  }
+
+  @override
+  Future<void> fetchData(final int pageNumber) async {
+    if (_bearerToken == null) {
+      await fetchInit();
+    }
 
+    final User user = ProductQuery.getWriteUser();
     final MaybeError<GetProofsResult> result =
         await OpenPricesAPIClient.getProofs(
       GetProofsParameters()
@@ -186,71 +116,106 @@ class _PricesProofsPageState extends State<PricesProofsPage>
         ]
         ..owner = user.userId
         ..pageSize = _pageSize
-        ..pageNumber = 1,
+        ..pageNumber = pageNumber,
       uriHelper: ProductQuery.uriPricesHelper,
-      bearerToken: bearerToken,
+      bearerToken: _bearerToken!,
     );
 
-    await OpenPricesAPIClient.deleteUserSession(
-      uriHelper: ProductQuery.uriPricesHelper,
-      bearerToken: bearerToken,
+    if (result.isError) {
+      throw Exception(result.error ?? 'Failed to fetch proofs');
+    }
+
+    final GetProofsResult value = result.value;
+    updateItems(
+      newItems: value.items,
+      pageNumber: value.pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
     );
+  }
+
+  /// Properly dispose of the session when the manager is no longer needed
+  void dispose() {
+    if (_bearerToken != null) {
+      OpenPricesAPIClient.deleteUserSession(
+        uriHelper: ProductQuery.uriPricesHelper,
+        bearerToken: _bearerToken!,
+      );
+    }
+  }
+
+  @override
+  Widget buildItem({
+    required BuildContext context,
+    required Proof item,
+  }) {
+    if (item.filePath == null) {
+      return const SizedBox.shrink();
+    }
 
-    return result;
+    return SmoothCard(
+      child: InkWell(
+        onTap: () async {
+          if (selectProof) {
+            Navigator.of(context).pop(item);
+            return;
+          }
+          return Navigator.push<void>(
+            context,
+            MaterialPageRoute<void>(
+              builder: (BuildContext context) => PriceProofPage(item),
+            ),
+          );
+        },
+        child: _PriceProofListItem(item),
+      ),
+    );
   }
 }
 
-// TODO(monsieurtanuki): reuse whatever will be coded in https://github.com/openfoodfacts/smooth-app/pull/5366
-class _PriceProofImage extends StatelessWidget {
-  const _PriceProofImage(
-    this.proof, {
-    required this.squareSize,
-  });
+class _PriceProofListItem extends StatelessWidget {
+  const _PriceProofListItem(this.proof);
 
   final Proof proof;
-  final double squareSize;
 
   @override
   Widget build(BuildContext context) {
     final DateFormat dateFormat =
         DateFormat.yMd(ProductQuery.getLocaleString());
-    final String date = dateFormat.format(proof.created);
-    return Stack(
-      children: <Widget>[
-        SmoothImage(
-          width: squareSize,
-          height: squareSize,
-          imageProvider: NetworkImage(
-            proof
-                .getFileUrl(
-                  uriProductHelper: ProductQuery.uriPricesHelper,
-                  isThumbnail: true,
-                )
-                .toString(),
+    final String date = dateFormat.format(proof.date ?? proof.created);
+
+    final double screenWidth = MediaQuery.of(context).size.width;
+    final double imageSize = screenWidth * 0.3;
+
+    return Padding(
+      padding: const EdgeInsets.all(SMALL_SPACE),
+      child: Row(
+        children: <Widget>[
+          SmoothImage(
+            width: imageSize,
+            height: imageSize,
+            imageProvider: NetworkImage(
+              proof
+                  .getFileUrl(
+                    uriProductHelper: ProductQuery.uriPricesHelper,
+                    isThumbnail: true,
+                  )
+                  .toString(),
+            ),
+            rounded: false,
           ),
-          rounded: false,
-        ),
-        SizedBox(
-          width: squareSize,
-          height: squareSize,
-          child: Align(
-            alignment: Alignment.bottomCenter,
-            child: Padding(
-              padding: const EdgeInsets.all(SMALL_SPACE),
-              child: Container(
-                height: VERY_LARGE_SPACE,
-                color: Colors.white.withAlpha(128),
-                child: Center(
-                  child: AutoSizeText(
-                    date,
-                    maxLines: 1,
-                  ),
+          const SizedBox(width: MEDIUM_SPACE),
+          Expanded(
+            child: Column(
+              children: <Widget>[
+                Text(
+                  date,
                 ),
-              ),
+              ],
             ),
           ),
-        ),
-      ],
+        ],
+      ),
     );
   }
 }
diff --git a/packages/smooth_app/lib/pages/prices/prices_users_page.dart b/packages/smooth_app/lib/pages/prices/prices_users_page.dart
index f8b87590461c..3f586159c04e 100644
--- a/packages/smooth_app/lib/pages/prices/prices_users_page.dart
+++ b/packages/smooth_app/lib/pages/prices/prices_users_page.dart
@@ -6,13 +6,15 @@ import 'package:smooth_app/generic_lib/design_constants.dart';
 import 'package:smooth_app/generic_lib/widgets/smooth_back_button.dart';
 import 'package:smooth_app/generic_lib/widgets/smooth_card.dart';
 import 'package:smooth_app/helpers/launch_url_helper.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_list.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_manager.dart';
 import 'package:smooth_app/pages/prices/price_count_widget.dart';
 import 'package:smooth_app/pages/prices/price_user_button.dart';
 import 'package:smooth_app/query/product_query.dart';
 import 'package:smooth_app/widgets/smooth_app_bar.dart';
 import 'package:smooth_app/widgets/smooth_scaffold.dart';
 
-/// Page that displays the top prices users.
+/// Page that displays the top prices users with infinite scrolling.
 class PricesUsersPage extends StatefulWidget {
   const PricesUsersPage();
 
@@ -22,15 +24,12 @@ class PricesUsersPage extends StatefulWidget {
 
 class _PricesUsersPageState extends State<PricesUsersPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetUsersResult>> _users = _showTopUsers();
-
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
-  static const int _pageSize = 10;
+  final _InfiniteScrollUserManager _userManager = _InfiniteScrollUserManager();
 
   @override
   Widget build(BuildContext context) {
     final AppLocalizations appLocalizations = AppLocalizations.of(context);
+
     return SmoothScaffold(
       appBar: SmoothAppBar(
         centerTitle: false,
@@ -51,86 +50,68 @@ class _PricesUsersPageState extends State<PricesUsersPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetUsersResult>>(
-        future: _users,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetUsersResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetUsersResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-
-          for (final PriceUser item in result.items!) {
-            final int priceCount = item.priceCount ?? 0;
-            children.add(
-              SmoothCard(
-                child: Wrap(
-                  spacing: VERY_SMALL_SPACE,
-                  children: <Widget>[
-                    PriceUserButton(item.userId),
-                    PriceCountWidget(
-                      count: priceCount,
-                      onPressed: () async => PriceUserButton.showUserPrices(
-                        user: item.userId,
-                        context: context,
-                      ),
-                    ),
-                  ],
-                ),
-              ),
-            );
-          }
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title =
-              appLocalizations.prices_users_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<PriceUser>(
+        manager: _userManager,
       ),
     );
   }
+}
+
+/// A manager for handling user data with infinite scrolling
+class _InfiniteScrollUserManager extends InfiniteScrollManager<PriceUser> {
+  static const int _pageSize = 10;
+
+  @override
+  Future<void> fetchData(final int pageNumber) async {
+    final GetUsersParameters parameters = GetUsersParameters()
+      ..orderBy = <OrderBy<GetUsersOrderField>>[
+        const OrderBy<GetUsersOrderField>(
+          field: GetUsersOrderField.priceCount,
+          ascending: false,
+        ),
+      ]
+      ..pageSize = _pageSize
+      ..pageNumber = pageNumber;
+
+    final MaybeError<GetUsersResult> result =
+        await OpenPricesAPIClient.getUsers(
+      parameters,
+      uriHelper: ProductQuery.uriPricesHelper,
+    );
+
+    if (result.isError) {
+      throw result.detailError;
+    }
+
+    final GetUsersResult value = result.value;
+    updateItems(
+      newItems: value.items,
+      pageNumber: value.pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
+    );
+  }
 
-  static Future<MaybeError<GetUsersResult>> _showTopUsers() async =>
-      OpenPricesAPIClient.getUsers(
-        GetUsersParameters()
-          ..orderBy = <OrderBy<GetUsersOrderField>>[
-            const OrderBy<GetUsersOrderField>(
-              field: GetUsersOrderField.priceCount,
-              ascending: false,
+  @override
+  Widget buildItem({
+    required BuildContext context,
+    required PriceUser item,
+  }) {
+    final int priceCount = item.priceCount ?? 0;
+    return SmoothCard(
+      child: Wrap(
+        spacing: VERY_SMALL_SPACE,
+        children: <Widget>[
+          PriceUserButton(item.userId),
+          PriceCountWidget(
+            count: priceCount,
+            onPressed: () async => PriceUserButton.showUserPrices(
+              user: item.userId,
+              context: context,
             ),
-          ]
-          ..pageSize = _pageSize
-          ..pageNumber = 1,
-        uriHelper: ProductQuery.uriPricesHelper,
-      );
+          ),
+        ],
+      ),
+    );
+  }
 }
diff --git a/packages/smooth_app/lib/pages/prices/product_prices_list.dart b/packages/smooth_app/lib/pages/prices/product_prices_list.dart
index 36dba79bf6e8..0650f9c38eb6 100644
--- a/packages/smooth_app/lib/pages/prices/product_prices_list.dart
+++ b/packages/smooth_app/lib/pages/prices/product_prices_list.dart
@@ -1,20 +1,17 @@
 import 'dart:async';
 
 import 'package:flutter/material.dart';
-import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:matomo_tracker/matomo_tracker.dart';
 import 'package:openfoodfacts/openfoodfacts.dart';
 import 'package:provider/provider.dart';
-import 'package:smooth_app/data_models/preferences/user_preferences.dart';
 import 'package:smooth_app/database/local_database.dart';
-import 'package:smooth_app/generic_lib/design_constants.dart';
 import 'package:smooth_app/generic_lib/widgets/smooth_card.dart';
 import 'package:smooth_app/pages/prices/get_prices_model.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_list.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_manager.dart';
 import 'package:smooth_app/pages/prices/price_data_widget.dart';
-import 'package:smooth_app/pages/prices/price_location_widget.dart';
 import 'package:smooth_app/pages/prices/price_product_widget.dart';
-import 'package:smooth_app/pages/prices/product_price_refresher.dart';
-import 'package:smooth_app/pages/product/common/loading_status.dart';
+import 'package:smooth_app/query/product_query.dart';
 
 /// List of the latest prices for a given model.
 class ProductPricesList extends StatefulWidget {
@@ -32,140 +29,80 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final _InfiniteScrollPriceManager _priceManager;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
-      model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
+    _priceManager = _InfiniteScrollPriceManager(
       pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
-      },
+      model: widget.model,
     );
   }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
   @override
   Widget build(BuildContext context) {
     context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
-    }
-
-    return _ActualList(
-      model: widget.model,
-      result: _productPriceRefresher.pricesResult!,
+    return InfiniteScrollList<Price>(
+      manager: _priceManager,
     );
   }
 }
 
-class _ActualList extends StatelessWidget {
-  const _ActualList({
+/// A manager for handling price data with infinite scrolling
+class _InfiniteScrollPriceManager extends InfiniteScrollManager<Price> {
+  _InfiniteScrollPriceManager({
+    GetPricesResult? pricesResult,
     required this.model,
-    required this.result,
-  });
+  }) : super(initialItems: pricesResult?.items);
 
+  /// The model containing price query parameters
   final GetPricesModel model;
-  final GetPricesResult result;
 
   @override
-  Widget build(BuildContext context) {
-    final List<Widget> children = <Widget>[];
+  Future<void> fetchData(int pageNumber) async {
+    final GetPricesParameters parameters = model.parameters;
+    parameters.pageNumber = pageNumber;
+
+    final MaybeError<GetPricesResult> result =
+        await OpenPricesAPIClient.getPrices(parameters,
+            uriHelper: ProductQuery.uriPricesHelper);
+
+    if (result.isError) {
+      throw result.detailError;
+    }
 
-    if (!model.displayEachProduct) {
-      // in that case we display the product only once, if possible.
-      for (final Price price in result.items!) {
-        final PriceProduct? priceProduct = price.product;
-        if (priceProduct == null) {
-          continue;
-        }
-        children.add(
-          SmoothCard(
-            child: PriceProductWidget(
+    final GetPricesResult value = result.value;
+    updateItems(
+      newItems: value.items,
+      pageNumber: value.pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
+    );
+  }
+
+  @override
+  Widget buildItem({
+    required BuildContext context,
+    required Price item,
+  }) {
+    final PriceProduct? priceProduct = item.product;
+    return SmoothCard(
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.start,
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: <Widget>[
+          if (model.displayEachProduct && priceProduct != null)
+            PriceProductWidget(
               priceProduct,
               enableCountButton: model.enableCountButton,
             ),
+          PriceDataWidget(
+            item,
+            model: model,
           ),
-        );
-        break;
-      }
-    }
-    if (!model.displayEachLocation) {
-      // in that case we display the location only once, if possible.
-      for (final Price price in result.items!) {
-        final Location? location = price.location;
-        if (location == null) {
-          continue;
-        }
-        children.add(
-          SmoothCard(
-            child: PriceLocationWidget(location),
-          ),
-        );
-        break;
-      }
-    }
-
-    for (final Price price in result.items!) {
-      final PriceProduct? priceProduct = price.product;
-      children.add(
-        SmoothCard(
-          child: Column(
-            mainAxisAlignment: MainAxisAlignment.start,
-            crossAxisAlignment: CrossAxisAlignment.start,
-            children: <Widget>[
-              if (model.displayEachProduct && priceProduct != null)
-                PriceProductWidget(
-                  priceProduct,
-                  enableCountButton: model.enableCountButton,
-                ),
-              PriceDataWidget(
-                price,
-                model: model,
-              ),
-            ],
-          ),
-        ),
-      );
-    }
-    final AppLocalizations appLocalizations = AppLocalizations.of(context);
-    final String title =
-        result.numberOfPages != null && result.numberOfPages! <= 1
-            ? appLocalizations.prices_list_length_one_page(
-                result.items!.length,
-              )
-            : appLocalizations.prices_list_length_many_pages(
-                model.parameters.pageSize!,
-                result.total!,
-              );
-    children.insert(
-      0,
-      SmoothCard(child: ListTile(title: Text(title))),
-    );
-    // so that the last content gets not hidden by the FAB
-    children.add(
-      const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-    );
-    return ListView(
-      children: children,
+        ],
+      ),
     );
   }
 }


Reviews:
user: login: monsieurtanuki
body: Hi @AshutoshKhadse23!

I have no strong opinion against your code, that seems to work. Possibly minor comments here and there.

The thing is that for the Prices (in a broad sense) we need different objects with a "load more" feature: prices (with this PR), proofs, contributors, locations and products.

Do you feel confident enough in your OOP skills? If so you should refactor your current code with generic concepts like:
* a loading class
  * with a load method that has the page number as a parameter
  * returning a list of items, a number of items, a number of pages
* an item display class that takes an item as a parameter and displays it
* a "load by page" class that displays items by page, with a "loader" and an "item display" in the constructor

I'm sure there are Design Patterns around those common features.

What do you think about that?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T15:54:33Z
user: login: monsieurtanuki
body: Hi @AshutoshKhadse23!
Good job!
Please have a look at my comments, though, for code simplification.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T11:41:17Z
user: login: monsieurtanuki
body: Hi @AshutoshKhadse23!
Obviously something wrong in your latest push, as we have twice the same class.
The idea was to get rid of parameters that are ALWAYS the same, and that's not was you pushed.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-25T07:38:34Z
user: login: monsieurtanuki
body: Hi @AshutoshKhadse23!
Please have a look at my comments.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-26T07:28:17Z
user: login: monsieurtanuki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T05:36:41Z
user: login: monsieurtanuki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T09:57:44Z
user: login: monsieurtanuki
body: Hi @AshutoshKhadse23
Please have a look at my comments.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-27T10:54:51Z
user: login: monsieurtanuki
body: Hi @AshutoshKhadse23!
Very interesting PR, isn't it?
I found a lot of inspiration reading your code. For the better I hope.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-03T12:02:01Z
user: login: monsieurtanuki
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T10:27:41Z
user: login: monsieurtanuki
body: Hi @AshutoshKhadse23!
I cannot comment everything, but on your way to OOP you experienced some confusion (like casually returning empty `SizedBox()`) and "just-in-case"ism (like a `refresh` method that is NEVER used).
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-04T16:05:15Z
user: login: monsieurtanuki
body: @AshutoshKhadse23 Please ping me when everything is fixed.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T13:26:30Z
user: login: monsieurtanuki
body: Hi @AshutoshKhadse23!
It looks better.

I have currently no bandwidth for the proof token.
My suggestion would be to add a protected Future method in the manager, like `fetchInit`.
This method would do nothing.
But it would be overridden in the Proof manager in order to fetch the token. And it would be called in `loadInitIfNeeded`.
Something like
```dart
String? bearerToken;

@override
Future<void> fetchInit() async {
  if(bearerToken != null) {
    return;
  }
  bearerToken = await ...;
}
```
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T12:01:14Z
user: login: monsieurtanuki
body: Thank you @AshutoshKhadse23 for all the hard work!
There are minor comments, and feel free to ignore them.

I would like you to change the proof display (and then we're good). Or, rollback your changes in prices_proofs_page.dart.

Proof display is currently in a grid, and it used to make sense.
Now that we have more relevant data regarding proofs, a grid is not appropriate anymore.
Besides, a grid won't fully benefit from your PR, as it's not a "standard" infinite scroll list.
So, the idea is to:
* display the proof list as an infinite scroll list (like the other pages of this PR)
* display each proof item as a
  * smoothcard
    * row(
      * square image the size of .3 screen width
      * date as Text

So, I give you the choice between:
1. changing the proof display as explained, which wouldn't take you that long (but means creating many proofs first for testing)
2. rollback your changes in prices_proofs_page.dart, and we can merge your code today
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-07T07:30:20Z
user: login: monsieurtanuki
body: Hi @AshutoshKhadse23!
Besides minor UI comments that I don't want you to work on, there's something wrong with the bearerToken:
1. you never use `fetchInit` - it's precisely for the bearerToken that `fetchInit` was introduced
2. you dismissed the test of the error when retrieving the bearerToken - you should not dismiss error tests
3. you compute the bearerToken for every page - the bearerToken is to be computed only once for all pages
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T06:49:59Z
user: login: AshutoshKhadse23
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T09:16:18Z
user: login: monsieurtanuki
body: Thank you @AshutoshKhadse23!
I do believe the code looks better that way, what do you think? 😉 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-08T13:15:38Z

Review comments:
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-04-24T09:39:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = _kEmptyIterable,
+    this.initialPage = 1,
+    this.onError,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  static const Iterable<Never> _kEmptyIterable = <Never>[];
+
+  /// Returns a Future with the fetched items and a boolean indicating if more items can be loaded
+  final Future<(List<T>, bool)> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Called when an error occurs during fetching
+  final Function(dynamic error)? onError;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Whether more items can be loaded
+  bool _hasMoreItems = true;
+  bool get hasMoreItems => _hasMoreItems;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Load more items
+  Future<void> loadMore(P parameters) async {
+    if (_isLoading || !_hasMoreItems) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final (List<T> newItems, bool hasMore) =
+          await fetchItems(parameters, _currentPage + 1);
+
+      _items.addAll(newItems);
+      _currentPage++;
+      _hasMoreItems = hasMore;
+    } catch (e) {
+      onError?.call(e);
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
+    _currentPage = initialPage;
+    _items = newInitialItems != null
+        ? List<T>.from(newInitialItems)
+        : List<T>.from(initialItems);
+    _isLoading = false;
+    _hasMoreItems = true;
+  }
+
+  /// Update pagination metadata
+  void updatePaginationInfo({int? newTotalItems, int? newTotalPages}) {
+    totalItems = newTotalItems;
+    totalPages = newTotalPages;
+  }
+}
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: Please create a specific file for that widget.
created_at: 2025-04-24T09:41:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: ```suggestion
/// A generic controller for handling infinite scrolling in lists.
```
created_at: 2025-04-24T09:41:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,16 +24,62 @@ class PricesLocationsPage extends StatefulWidget {
 
 class _PricesLocationsPageState extends State<PricesLocationsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetLocationsResult>> _locations =
-      _showTopLocations();
-
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
   static const int _pageSize = 10;
+  late final InfiniteScrollController<Location, GetLocationsParameters>
+      _scrollController;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController =
+        InfiniteScrollController<Location, GetLocationsParameters>(
+      initialItems: const <Location>[],
+      fetchItems: _fetchLocations,
+      onError: (dynamic error) {
+        debugPrint('Error fetching locations: $error');
+      },
+    );
+  }
+
+  Future<(List<Location>, bool)> _fetchLocations(
+      GetLocationsParameters parameters, int page) async {
+    try {
+      final MaybeError<GetLocationsResult> result =
+          await OpenPricesAPIClient.getLocations(
+        parameters..pageNumber = page,
+        uriHelper: ProductQuery.uriPricesHelper,
+      );
+
+      if (result.isError) {
+        throw result.error!;
+      }
+
+      final List<Location> items = result.value.items ?? <Location>[];
+      final bool hasMore = page < (result.value.numberOfPages ?? 1);
+
+      _scrollController.updatePaginationInfo(
+        newTotalItems: result.value.total,
+        newTotalPages: result.value.numberOfPages,
+      );
+
+      return (items, hasMore);
+    } catch (e) {
+      throw e.toString();
+    }
path: packages/smooth_app/lib/pages/prices/prices_locations_page.dart
user: login: monsieurtanuki
body: What's the point of re-throwing the same exception?
created_at: 2025-04-24T09:44:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,16 +22,66 @@ class PricesProductsPage extends StatefulWidget {
 
 class _PricesProductsPageState extends State<PricesProductsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetPriceProductsResult>> _products =
-      _showTopProducts();
-
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
   static const int _pageSize = 10;
+  late final InfiniteScrollController<PriceProduct, GetPriceProductsParameters>
+      _scrollController;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController =
+        InfiniteScrollController<PriceProduct, GetPriceProductsParameters>(
+      initialItems: const <PriceProduct>[],
+      fetchItems: _fetchProducts,
+      onError: (dynamic error) {
+        // Handle error if needed
+        debugPrint('Error fetching products: $error');
+      },
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: We'll always need to handle error: please each time display the same standard error (like an error dialog).
created_at: 2025-04-24T09:46:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4,17 +4,19 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
 import 'package:intl/intl.dart';
 import 'package:matomo_tracker/matomo_tracker.dart';
 import 'package:openfoodfacts/openfoodfacts.dart';
+
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-04-24T10:16:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -4350,5 +4350,13 @@
   "prices_stats_error": "An error occurred while loading statistics.",
   "@prices_stats_error": {
     "description": "Error message shown when price statistics fail to load"
+  },
+  "prices_no_results": "No results found.",
+  "@price_no_result": {
+    "description": "Message shown when there are no price results to display"
+  },
+  "proofs_empty_list": "No proofs available to display.",
+  "@proofs_empty_list": {
+    "description": "Message shown when there are no proofs available in the list"
path: packages/smooth_app/lib/l10n/app_en.arb
user: login: monsieurtanuki
body: ```suggestion
  }
```
Just one "No results found" is good enough.
created_at: 2025-04-24T11:20:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = _kEmptyIterable,
+    this.initialPage = 1,
+    this.onError,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  static const Iterable<Never> _kEmptyIterable = <Never>[];
+
+  /// Returns a Future with the fetched items and a boolean indicating if more items can be loaded
+  final Future<(List<T>, bool)> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Called when an error occurs during fetching
+  final Function(dynamic error)? onError;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Whether more items can be loaded
+  bool _hasMoreItems = true;
+  bool get hasMoreItems => _hasMoreItems;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Load more items
+  Future<void> loadMore(P parameters) async {
+    if (_isLoading || !_hasMoreItems) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final (List<T> newItems, bool hasMore) =
+          await fetchItems(parameters, _currentPage + 1);
+
+      _items.addAll(newItems);
+      _currentPage++;
+      _hasMoreItems = hasMore;
+    } catch (e) {
+      onError?.call(e);
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
+    _currentPage = initialPage;
+    _items = newInitialItems != null
+        ? List<T>.from(newInitialItems)
+        : List<T>.from(initialItems);
+    _isLoading = false;
+    _hasMoreItems = true;
+  }
+
+  /// Update pagination metadata
+  void updatePaginationInfo({int? newTotalItems, int? newTotalPages}) {
+    totalItems = newTotalItems;
+    totalPages = newTotalPages;
+  }
+}
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.loadingBuilder,
+    required this.errorBuilder,
+    required this.emptyBuilder,
+    required this.parameters,
+    this.loadMoreTriggerOffset = 200.0,
+    this.loadingMoreBuilder,
+    this.headerBuilder,
+    this.footerBuilder,
+    this.physics,
+    this.padding,
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: Never used, so please remove those 2 fields.
created_at: 2025-04-24T11:28:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = _kEmptyIterable,
+    this.initialPage = 1,
+    this.onError,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  static const Iterable<Never> _kEmptyIterable = <Never>[];
+
+  /// Returns a Future with the fetched items and a boolean indicating if more items can be loaded
+  final Future<(List<T>, bool)> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Called when an error occurs during fetching
+  final Function(dynamic error)? onError;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Whether more items can be loaded
+  bool _hasMoreItems = true;
+  bool get hasMoreItems => _hasMoreItems;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Load more items
+  Future<void> loadMore(P parameters) async {
+    if (_isLoading || !_hasMoreItems) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final (List<T> newItems, bool hasMore) =
+          await fetchItems(parameters, _currentPage + 1);
+
+      _items.addAll(newItems);
+      _currentPage++;
+      _hasMoreItems = hasMore;
+    } catch (e) {
+      onError?.call(e);
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
+    _currentPage = initialPage;
+    _items = newInitialItems != null
+        ? List<T>.from(newInitialItems)
+        : List<T>.from(initialItems);
+    _isLoading = false;
+    _hasMoreItems = true;
+  }
+
+  /// Update pagination metadata
+  void updatePaginationInfo({int? newTotalItems, int? newTotalPages}) {
+    totalItems = newTotalItems;
+    totalPages = newTotalPages;
+  }
+}
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.loadingBuilder,
+    required this.errorBuilder,
+    required this.emptyBuilder,
+    required this.parameters,
+    this.loadMoreTriggerOffset = 200.0,
+    this.loadingMoreBuilder,
+    this.headerBuilder,
+    this.footerBuilder,
+    this.physics,
+    this.padding,
+    this.shrinkWrap = false,
+    this.onRefresh,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item, int index) itemBuilder;
+
+  /// Builder for showing loading state
+  final Widget Function(BuildContext context) loadingBuilder;
+
+  /// Builder for showing error state
+  final Widget Function(BuildContext context, dynamic error) errorBuilder;
+
+  /// Builder for showing empty state
+  final Widget Function(BuildContext context) emptyBuilder;
+
+  /// Optional builder for showing loading more state at the bottom of the list
+  final Widget Function(BuildContext context)? loadingMoreBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Optional builder for footer
+  final Widget Function(BuildContext context)? footerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  /// Scroll offset from the bottom that triggers loading more items
+  final double loadMoreTriggerOffset;
+
+  /// ScrollPhysics for the ListView
+  final ScrollPhysics? physics;
+
+  /// Padding for the ListView
+  final EdgeInsets? padding;
+
+  /// Whether the ListView should shrink-wrap its contents
+  final bool shrinkWrap;
+
+  /// Callback for pull-to-refresh functionality
+  final Future<void> Function()? onRefresh;
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: I don't think we need an `onRefresh`. Please check that and get rid of it if relevant.
created_at: 2025-04-24T11:28:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = _kEmptyIterable,
+    this.initialPage = 1,
+    this.onError,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  static const Iterable<Never> _kEmptyIterable = <Never>[];
+
+  /// Returns a Future with the fetched items and a boolean indicating if more items can be loaded
+  final Future<(List<T>, bool)> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Called when an error occurs during fetching
+  final Function(dynamic error)? onError;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Whether more items can be loaded
+  bool _hasMoreItems = true;
+  bool get hasMoreItems => _hasMoreItems;
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: I'm a bit surprised that you have to maintain a `bool` field for `hasMoreItems`, as it could be computed with `_currentPage` and `totalPages`;
created_at: 2025-04-24T11:30:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = _kEmptyIterable,
+    this.initialPage = 1,
+    this.onError,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  static const Iterable<Never> _kEmptyIterable = <Never>[];
+
+  /// Returns a Future with the fetched items and a boolean indicating if more items can be loaded
+  final Future<(List<T>, bool)> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Called when an error occurs during fetching
+  final Function(dynamic error)? onError;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Whether more items can be loaded
+  bool _hasMoreItems = true;
+  bool get hasMoreItems => _hasMoreItems;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Load more items
+  Future<void> loadMore(P parameters) async {
+    if (_isLoading || !_hasMoreItems) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final (List<T> newItems, bool hasMore) =
+          await fetchItems(parameters, _currentPage + 1);
+
+      _items.addAll(newItems);
+      _currentPage++;
+      _hasMoreItems = hasMore;
+    } catch (e) {
+      onError?.call(e);
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
+    _currentPage = initialPage;
+    _items = newInitialItems != null
+        ? List<T>.from(newInitialItems)
+        : List<T>.from(initialItems);
+    _isLoading = false;
+    _hasMoreItems = true;
+  }
+
+  /// Update pagination metadata
+  void updatePaginationInfo({int? newTotalItems, int? newTotalPages}) {
+    totalItems = newTotalItems;
+    totalPages = newTotalPages;
+  }
+}
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.loadingBuilder,
+    required this.errorBuilder,
+    required this.emptyBuilder,
+    required this.parameters,
+    this.loadMoreTriggerOffset = 200.0,
+    this.loadingMoreBuilder,
+    this.headerBuilder,
+    this.footerBuilder,
+    this.physics,
+    this.padding,
+    this.shrinkWrap = false,
+    this.onRefresh,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item, int index) itemBuilder;
+
+  /// Builder for showing loading state
+  final Widget Function(BuildContext context) loadingBuilder;
+
+  /// Builder for showing error state
+  final Widget Function(BuildContext context, dynamic error) errorBuilder;
+
+  /// Builder for showing empty state
+  final Widget Function(BuildContext context) emptyBuilder;
+
+  /// Optional builder for showing loading more state at the bottom of the list
+  final Widget Function(BuildContext context)? loadingMoreBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Optional builder for footer
+  final Widget Function(BuildContext context)? footerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  /// Scroll offset from the bottom that triggers loading more items
+  final double loadMoreTriggerOffset;
+
+  /// ScrollPhysics for the ListView
+  final ScrollPhysics? physics;
+
+  /// Padding for the ListView
+  final EdgeInsets? padding;
+
+  /// Whether the ListView should shrink-wrap its contents
+  final bool shrinkWrap;
+
+  /// Callback for pull-to-refresh functionality
+  final Future<void> Function()? onRefresh;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  late ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = true;
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: `true` even with initial values?
created_at: 2025-04-24T11:31:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = _kEmptyIterable,
+    this.initialPage = 1,
+    this.onError,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  static const Iterable<Never> _kEmptyIterable = <Never>[];
+
+  /// Returns a Future with the fetched items and a boolean indicating if more items can be loaded
+  final Future<(List<T>, bool)> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Called when an error occurs during fetching
+  final Function(dynamic error)? onError;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Whether more items can be loaded
+  bool _hasMoreItems = true;
+  bool get hasMoreItems => _hasMoreItems;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Load more items
+  Future<void> loadMore(P parameters) async {
+    if (_isLoading || !_hasMoreItems) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final (List<T> newItems, bool hasMore) =
+          await fetchItems(parameters, _currentPage + 1);
+
+      _items.addAll(newItems);
+      _currentPage++;
+      _hasMoreItems = hasMore;
+    } catch (e) {
+      onError?.call(e);
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
+    _currentPage = initialPage;
+    _items = newInitialItems != null
+        ? List<T>.from(newInitialItems)
+        : List<T>.from(initialItems);
+    _isLoading = false;
+    _hasMoreItems = true;
+  }
+
+  /// Update pagination metadata
+  void updatePaginationInfo({int? newTotalItems, int? newTotalPages}) {
+    totalItems = newTotalItems;
+    totalPages = newTotalPages;
+  }
+}
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.loadingBuilder,
+    required this.errorBuilder,
+    required this.emptyBuilder,
+    required this.parameters,
+    this.loadMoreTriggerOffset = 200.0,
+    this.loadingMoreBuilder,
+    this.headerBuilder,
+    this.footerBuilder,
+    this.physics,
+    this.padding,
+    this.shrinkWrap = false,
+    this.onRefresh,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item, int index) itemBuilder;
+
+  /// Builder for showing loading state
+  final Widget Function(BuildContext context) loadingBuilder;
+
+  /// Builder for showing error state
+  final Widget Function(BuildContext context, dynamic error) errorBuilder;
+
+  /// Builder for showing empty state
+  final Widget Function(BuildContext context) emptyBuilder;
+
+  /// Optional builder for showing loading more state at the bottom of the list
+  final Widget Function(BuildContext context)? loadingMoreBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Optional builder for footer
+  final Widget Function(BuildContext context)? footerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  /// Scroll offset from the bottom that triggers loading more items
+  final double loadMoreTriggerOffset;
+
+  /// ScrollPhysics for the ListView
+  final ScrollPhysics? physics;
+
+  /// Padding for the ListView
+  final EdgeInsets? padding;
+
+  /// Whether the ListView should shrink-wrap its contents
+  final bool shrinkWrap;
+
+  /// Callback for pull-to-refresh functionality
+  final Future<void> Function()? onRefresh;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  late ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = true;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    _initialLoad();
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  @override
+  void didUpdateWidget(covariant InfiniteScrollList<T, P> oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    // If parameters change, reset and reload
+    if (widget.parameters != oldWidget.parameters) {
+      _resetAndReload();
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: PLEASE ALWAYS USE `unawaited` WHEN RELEVANT.
created_at: 2025-04-24T11:32:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = _kEmptyIterable,
+    this.initialPage = 1,
+    this.onError,
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: Doesn't seem to be relevant, does it?
created_at: 2025-04-24T11:33:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = _kEmptyIterable,
+    this.initialPage = 1,
+    this.onError,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  static const Iterable<Never> _kEmptyIterable = <Never>[];
+
+  /// Returns a Future with the fetched items and a boolean indicating if more items can be loaded
+  final Future<(List<T>, bool)> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Called when an error occurs during fetching
+  final Function(dynamic error)? onError;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Whether more items can be loaded
+  bool _hasMoreItems = true;
+  bool get hasMoreItems => _hasMoreItems;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Load more items
+  Future<void> loadMore(P parameters) async {
+    if (_isLoading || !_hasMoreItems) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final (List<T> newItems, bool hasMore) =
+          await fetchItems(parameters, _currentPage + 1);
+
+      _items.addAll(newItems);
+      _currentPage++;
+      _hasMoreItems = hasMore;
+    } catch (e) {
+      onError?.call(e);
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
+    _currentPage = initialPage;
+    _items = newInitialItems != null
+        ? List<T>.from(newInitialItems)
+        : List<T>.from(initialItems);
+    _isLoading = false;
+    _hasMoreItems = true;
+  }
+
+  /// Update pagination metadata
+  void updatePaginationInfo({int? newTotalItems, int? newTotalPages}) {
+    totalItems = newTotalItems;
+    totalPages = newTotalPages;
+  }
+}
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.loadingBuilder,
+    required this.errorBuilder,
+    required this.emptyBuilder,
+    required this.parameters,
+    this.loadMoreTriggerOffset = 200.0,
+    this.loadingMoreBuilder,
+    this.headerBuilder,
+    this.footerBuilder,
+    this.physics,
+    this.padding,
+    this.shrinkWrap = false,
+    this.onRefresh,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item, int index) itemBuilder;
+
+  /// Builder for showing loading state
+  final Widget Function(BuildContext context) loadingBuilder;
+
+  /// Builder for showing error state
+  final Widget Function(BuildContext context, dynamic error) errorBuilder;
+
+  /// Builder for showing empty state
+  final Widget Function(BuildContext context) emptyBuilder;
+
+  /// Optional builder for showing loading more state at the bottom of the list
+  final Widget Function(BuildContext context)? loadingMoreBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Optional builder for footer
+  final Widget Function(BuildContext context)? footerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  /// Scroll offset from the bottom that triggers loading more items
+  final double loadMoreTriggerOffset;
+
+  /// ScrollPhysics for the ListView
+  final ScrollPhysics? physics;
+
+  /// Padding for the ListView
+  final EdgeInsets? padding;
+
+  /// Whether the ListView should shrink-wrap its contents
+  final bool shrinkWrap;
+
+  /// Callback for pull-to-refresh functionality
+  final Future<void> Function()? onRefresh;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  late ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = true;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    _initialLoad();
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  @override
+  void didUpdateWidget(covariant InfiniteScrollList<T, P> oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    // If parameters change, reset and reload
+    if (widget.parameters != oldWidget.parameters) {
+      _resetAndReload();
+    }
+  }
+
+  Future<void> _initialLoad() async {
+    setState(() {
+      _isInitialLoading = true;
+      _error = null;
+    });
+
+    try {
+      final (List<T> items, bool hasMore) = await widget.controller
+          .fetchItems(widget.parameters, widget.controller.initialPage);
+
+      if (mounted) {
+        setState(() {
+          widget.controller.reset(newInitialItems: items);
+          widget.controller._hasMoreItems = hasMore;
+          _isInitialLoading = false;
+        });
+      }
+    } catch (e) {
+      if (mounted) {
+        setState(() {
+          _error = e;
+          _isInitialLoading = false;
+        });
+      }
+    }
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: ```suggestion
      widget.controller.reset(newInitialItems: items);
      widget.controller._hasMoreItems = hasMore;
    } catch (e) {
      _error = e;
    } finally {
      if (mounted) {
        setState(() {
          _isInitialLoading = false;
        });
      }
    }    
```
created_at: 2025-04-24T11:36:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = _kEmptyIterable,
+    this.initialPage = 1,
+    this.onError,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  static const Iterable<Never> _kEmptyIterable = <Never>[];
+
+  /// Returns a Future with the fetched items and a boolean indicating if more items can be loaded
+  final Future<(List<T>, bool)> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Called when an error occurs during fetching
+  final Function(dynamic error)? onError;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Whether more items can be loaded
+  bool _hasMoreItems = true;
+  bool get hasMoreItems => _hasMoreItems;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Load more items
+  Future<void> loadMore(P parameters) async {
+    if (_isLoading || !_hasMoreItems) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final (List<T> newItems, bool hasMore) =
+          await fetchItems(parameters, _currentPage + 1);
+
+      _items.addAll(newItems);
+      _currentPage++;
+      _hasMoreItems = hasMore;
+    } catch (e) {
+      onError?.call(e);
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
+    _currentPage = initialPage;
+    _items = newInitialItems != null
+        ? List<T>.from(newInitialItems)
+        : List<T>.from(initialItems);
+    _isLoading = false;
+    _hasMoreItems = true;
+  }
+
+  /// Update pagination metadata
+  void updatePaginationInfo({int? newTotalItems, int? newTotalPages}) {
+    totalItems = newTotalItems;
+    totalPages = newTotalPages;
+  }
+}
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.loadingBuilder,
+    required this.errorBuilder,
+    required this.emptyBuilder,
+    required this.parameters,
+    this.loadMoreTriggerOffset = 200.0,
+    this.loadingMoreBuilder,
+    this.headerBuilder,
+    this.footerBuilder,
+    this.physics,
+    this.padding,
+    this.shrinkWrap = false,
+    this.onRefresh,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item, int index) itemBuilder;
+
+  /// Builder for showing loading state
+  final Widget Function(BuildContext context) loadingBuilder;
+
+  /// Builder for showing error state
+  final Widget Function(BuildContext context, dynamic error) errorBuilder;
+
+  /// Builder for showing empty state
+  final Widget Function(BuildContext context) emptyBuilder;
+
+  /// Optional builder for showing loading more state at the bottom of the list
+  final Widget Function(BuildContext context)? loadingMoreBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Optional builder for footer
+  final Widget Function(BuildContext context)? footerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  /// Scroll offset from the bottom that triggers loading more items
+  final double loadMoreTriggerOffset;
+
+  /// ScrollPhysics for the ListView
+  final ScrollPhysics? physics;
+
+  /// Padding for the ListView
+  final EdgeInsets? padding;
+
+  /// Whether the ListView should shrink-wrap its contents
+  final bool shrinkWrap;
+
+  /// Callback for pull-to-refresh functionality
+  final Future<void> Function()? onRefresh;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  late ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = true;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    _initialLoad();
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  @override
+  void didUpdateWidget(covariant InfiniteScrollList<T, P> oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    // If parameters change, reset and reload
+    if (widget.parameters != oldWidget.parameters) {
+      _resetAndReload();
+    }
+  }
+
+  Future<void> _initialLoad() async {
+    setState(() {
+      _isInitialLoading = true;
+      _error = null;
+    });
+
+    try {
+      final (List<T> items, bool hasMore) = await widget.controller
+          .fetchItems(widget.parameters, widget.controller.initialPage);
+
+      if (mounted) {
+        setState(() {
+          widget.controller.reset(newInitialItems: items);
+          widget.controller._hasMoreItems = hasMore;
+          _isInitialLoading = false;
+        });
+      }
+    } catch (e) {
+      if (mounted) {
+        setState(() {
+          _error = e;
+          _isInitialLoading = false;
+        });
+      }
+    }
+  }
+
+  void _scrollListener() {
+    if (widget.controller.isLoading || !widget.controller.hasMoreItems) {
+      return;
+    }
+
+    final double maxScroll = _scrollController.position.maxScrollExtent;
+    final double currentScroll = _scrollController.position.pixels;
+
+    if (currentScroll > maxScroll - widget.loadMoreTriggerOffset) {
+      _loadMoreItems();
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: unawaited
created_at: 2025-04-24T11:36:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = _kEmptyIterable,
+    this.initialPage = 1,
+    this.onError,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  static const Iterable<Never> _kEmptyIterable = <Never>[];
+
+  /// Returns a Future with the fetched items and a boolean indicating if more items can be loaded
+  final Future<(List<T>, bool)> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Called when an error occurs during fetching
+  final Function(dynamic error)? onError;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Whether more items can be loaded
+  bool _hasMoreItems = true;
+  bool get hasMoreItems => _hasMoreItems;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Load more items
+  Future<void> loadMore(P parameters) async {
+    if (_isLoading || !_hasMoreItems) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final (List<T> newItems, bool hasMore) =
+          await fetchItems(parameters, _currentPage + 1);
+
+      _items.addAll(newItems);
+      _currentPage++;
+      _hasMoreItems = hasMore;
+    } catch (e) {
+      onError?.call(e);
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
+    _currentPage = initialPage;
+    _items = newInitialItems != null
+        ? List<T>.from(newInitialItems)
+        : List<T>.from(initialItems);
+    _isLoading = false;
+    _hasMoreItems = true;
+  }
+
+  /// Update pagination metadata
+  void updatePaginationInfo({int? newTotalItems, int? newTotalPages}) {
+    totalItems = newTotalItems;
+    totalPages = newTotalPages;
+  }
+}
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.loadingBuilder,
+    required this.errorBuilder,
+    required this.emptyBuilder,
+    required this.parameters,
+    this.loadMoreTriggerOffset = 200.0,
+    this.loadingMoreBuilder,
+    this.headerBuilder,
+    this.footerBuilder,
+    this.physics,
+    this.padding,
+    this.shrinkWrap = false,
+    this.onRefresh,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item, int index) itemBuilder;
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: We never use `index`, do we? If so please get rid of it.
created_at: 2025-04-24T11:40:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,314 @@
+// generic_infinite_scroll.dart
+
+import 'package:flutter/material.dart';
+
+/// A generic class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = _kEmptyIterable,
+    this.initialPage = 1,
+    this.onError,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  static const Iterable<Never> _kEmptyIterable = <Never>[];
+
+  /// Returns a Future with the fetched items and a boolean indicating if more items can be loaded
+  final Future<(List<T>, bool)> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Called when an error occurs during fetching
+  final Function(dynamic error)? onError;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Whether more items can be loaded
+  bool _hasMoreItems = true;
+  bool get hasMoreItems => _hasMoreItems;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Load more items
+  Future<void> loadMore(P parameters) async {
+    if (_isLoading || !_hasMoreItems) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final (List<T> newItems, bool hasMore) =
+          await fetchItems(parameters, _currentPage + 1);
+
+      _items.addAll(newItems);
+      _currentPage++;
+      _hasMoreItems = hasMore;
+    } catch (e) {
+      onError?.call(e);
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
+    _currentPage = initialPage;
+    _items = newInitialItems != null
+        ? List<T>.from(newInitialItems)
+        : List<T>.from(initialItems);
+    _isLoading = false;
+    _hasMoreItems = true;
+  }
+
+  /// Update pagination metadata
+  void updatePaginationInfo({int? newTotalItems, int? newTotalPages}) {
+    totalItems = newTotalItems;
+    totalPages = newTotalPages;
+  }
+}
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.loadingBuilder,
+    required this.errorBuilder,
+    required this.emptyBuilder,
+    required this.parameters,
+    this.loadMoreTriggerOffset = 200.0,
+    this.loadingMoreBuilder,
+    this.headerBuilder,
+    this.footerBuilder,
+    this.physics,
+    this.padding,
+    this.shrinkWrap = false,
+    this.onRefresh,
path: packages/smooth_app/lib/pages/prices/generic_infinite_scroll.dart
user: login: monsieurtanuki
body: Please no "just in case" parameters or default values.
From what I read in the code, the values are ALWAYS the same for the following parameters:
```dart
      loadMoreTriggerOffset: 200.0,
      loadingBuilder: (BuildContext context) =>
          const Center(child: CircularProgressIndicator()),
      errorBuilder: (BuildContext context, dynamic error) =>
          Text(error.toString()),
      emptyBuilder: (BuildContext context) => const Text(appLocalizations.prices_no_results),
      loadingMoreBuilder: (BuildContext context) => const Padding(
        padding: EdgeInsets.symmetric(vertical: 16.0),
        child: Center(child: CircularProgressIndicator()),
      ),
      footerBuilder: (BuildContext context) =>
          const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
```
created_at: 2025-04-24T11:40:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,318 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = const <Never>[],
+    this.initialPage = 1,
+    this.onError,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  /// Returns a Future with the fetched items and a boolean indicating if more items can be loaded
+  final Future<(List<T>, bool)> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Called when an error occurs during fetching
+  final Function(dynamic error)? onError;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Whether more items can be loaded
+  bool _hasMoreItems = true;
+
+  /// Set whether more items can be loaded
+  set hasMoreItems(bool value) {
+    _hasMoreItems = value;
+  }
+
+  /// Computed property for hasMoreItems that considers both the explicit flag and page information
+  bool get hasMoreItems {
+    if (!_hasMoreItems) {
+      return false;
+    }
+    if (totalPages == null) {
+      return _hasMoreItems;
+    }
+    return _currentPage < totalPages!;
+  }
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Load more items
+  Future<void> loadMore(P parameters) async {
+    if (_isLoading || !hasMoreItems) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final (List<T> newItems, bool hasMore) =
+          await fetchItems(parameters, _currentPage + 1);
+
+      _items.addAll(newItems);
+      _currentPage++;
+      _hasMoreItems = hasMore;
+    } catch (e) {
+      onError?.call(e);
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
+    _currentPage = initialPage;
+    _items = newInitialItems != null
+        ? List<T>.from(newInitialItems)
+        : List<T>.from(initialItems);
+    _isLoading = false;
+    _hasMoreItems = true;
+  }
+
+  /// Update pagination metadata
+  void updatePaginationInfo({int? newTotalItems, int? newTotalPages}) {
+    totalItems = newTotalItems;
+    totalPages = newTotalPages;
+  }
+}
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: Obviously needs to be removed.
created_at: 2025-04-25T07:33:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +30,168 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final InfiniteScrollController<Price, GetPricesParameters>
+      _scrollController;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
-      model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
-      pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
+    final List<Price> initialItems = widget.pricesResult?.items ?? <Price>[];
+
+    _scrollController = InfiniteScrollController<Price, GetPricesParameters>(
+      initialItems: initialItems,
+      fetchItems: _fetchPrices,
+      onError: (dynamic error) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Error fetching prices: $error')),
+        );
       },
     );
-  }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
-  @override
-  Widget build(BuildContext context) {
-    context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
+    if (widget.pricesResult != null) {
+      _scrollController.updatePaginationInfo(
+        newTotalItems: widget.pricesResult!.total,
+        newTotalPages: widget.pricesResult!.numberOfPages,
+      );
     }
-
-    return _ActualList(
-      model: widget.model,
-      result: _productPriceRefresher.pricesResult!,
-    );
   }
-}
 
-class _ActualList extends StatelessWidget {
-  const _ActualList({
-    required this.model,
-    required this.result,
-  });
+  Future<(List<Price>, bool)> _fetchPrices(
+    GetPricesParameters parameters,
+    int page,
+  ) async {
+    try {
+      final MaybeError<GetPricesResult> result =
+          await OpenPricesAPIClient.getPrices(
+        parameters,
+      );
 
-  final GetPricesModel model;
-  final GetPricesResult result;
+      if (result.isError) {
+        throw result.detailError;
+      }
+
+      final List<Price> items = result.value.items ?? <Price>[];
+      final bool hasMore = page < (result.value.numberOfPages ?? 1);
+
+      // Update pagination info
+      _scrollController.updatePaginationInfo(
+        newTotalItems: result.value.total,
+        newTotalPages: result.value.numberOfPages,
+      );
+
+      return (items, hasMore);
+    } catch (e) {
+      throw e.toString();
+    }
+  }
 
   @override
   Widget build(BuildContext context) {
-    final List<Widget> children = <Widget>[];
+    context.watch<LocalDatabase>();
 
-    if (!model.displayEachProduct) {
-      // in that case we display the product only once, if possible.
-      for (final Price price in result.items!) {
-        final PriceProduct? priceProduct = price.product;
-        if (priceProduct == null) {
-          continue;
+    return InfiniteScrollList<Price, GetPricesParameters>(
+      controller: _scrollController,
+      parameters: widget.model.parameters,
+      loadMoreTriggerOffset: 200.0,
+      loadingBuilder: (BuildContext context) =>
+          const Center(child: CircularProgressIndicator()),
+      errorBuilder: (BuildContext context, dynamic error) =>
+          Text(error.toString()),
+      emptyBuilder: (BuildContext context) => const Text('No prices available'),
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: How come is that still there?
created_at: 2025-04-25T07:34:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,217 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: ```suggestion
```
Not needed.
created_at: 2025-04-25T07:35:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,217 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+    this.physics,
+    this.padding,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: Not needed.
created_at: 2025-04-25T07:36:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,217 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+    this.physics,
+    this.padding,
+    this.shrinkWrap = false,
+    this.enablePullToRefresh = true,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: Not needed.
created_at: 2025-04-25T07:36:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,217 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+    this.physics,
+    this.padding,
+    this.shrinkWrap = false,
+    this.enablePullToRefresh = true,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item) itemBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  /// ScrollPhysics for the ListView
+  final ScrollPhysics? physics;
+
+  /// Padding for the ListView
+  final EdgeInsets? padding;
+
+  /// Whether the ListView should shrink-wrap its contents
+  final bool shrinkWrap;
+
+  /// Whether to enable pull-to-refresh functionality
+  final bool enablePullToRefresh;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  // Constants that were previously parameters
+  static const double _loadMoreTriggerOffset = 200.0;
+
+  late ScrollController _scrollController;
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: ```suggestion
  late final ScrollController _scrollController;
```
created_at: 2025-04-25T07:36:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,217 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    super.key,
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+    this.physics,
+    this.padding,
+    this.shrinkWrap = false,
+    this.enablePullToRefresh = true,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item) itemBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  /// ScrollPhysics for the ListView
+  final ScrollPhysics? physics;
+
+  /// Padding for the ListView
+  final EdgeInsets? padding;
+
+  /// Whether the ListView should shrink-wrap its contents
+  final bool shrinkWrap;
+
+  /// Whether to enable pull-to-refresh functionality
+  final bool enablePullToRefresh;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  // Constants that were previously parameters
+  static const double _loadMoreTriggerOffset = 200.0;
+
+  late ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    _initialLoad();
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  @override
+  void didUpdateWidget(covariant InfiniteScrollList<T, P> oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    // If parameters change, reset and reload
+    if (widget.parameters != oldWidget.parameters) {
+      unawaited(_resetAndReload());
+    }
+  }
+
+  Future<void> _initialLoad() async {
+    setState(() {
+      _isInitialLoading = true;
+      _error = null;
+    });
+
+    try {
+      final (List<T> items, bool hasMore) = await widget.controller
+          .fetchItems(widget.parameters, widget.controller.initialPage);
+
+      widget.controller.reset(newInitialItems: items);
+      widget.controller.hasMoreItems = hasMore;
+    } catch (e) {
+      _error = e;
+    } finally {
+      if (mounted) {
+        setState(() {
+          _isInitialLoading = false;
+        });
+      }
+    }
+  }
+
+  void _scrollListener() {
+    if (widget.controller.isLoading || !widget.controller.hasMoreItems) {
+      return;
+    }
+
+    final double maxScroll = _scrollController.position.maxScrollExtent;
+    final double currentScroll = _scrollController.position.pixels;
+
+    if (currentScroll > maxScroll - _loadMoreTriggerOffset) {
+      unawaited(_loadMoreItems());
+    }
+  }
+
+  Future<void> _loadMoreItems() async {
+    if (mounted) {
+      setState(() {}); // Trigger rebuild to show loading indicator
+      await widget.controller.loadMore(widget.parameters);
+      if (mounted) {
+        setState(() {}); // Update UI with new items
+      }
+    }
+  }
+
+  Future<void> _resetAndReload() async {
+    if (mounted) {
+      setState(() {
+        _isInitialLoading = true;
+        _error = null;
+      });
+
+      widget.controller.reset();
+      await _initialLoad();
+    }
+  }
+
+  Future<void> _handleRefresh() async {
+    return _resetAndReload();
+  }
+
+  // Default widget builders
+  Widget _buildLoadingState(BuildContext context) {
+    return const Center(child: CircularProgressIndicator());
+  }
+
+  Widget _buildErrorState(BuildContext context, dynamic error) {
+    return Text(error.toString());
+  }
+
+  Widget _buildEmptyState(BuildContext context) {
+    return const Text('No results'); // Use appropriate localization
+  }
+
+  Widget _buildLoadingMoreIndicator(BuildContext context) {
+    return const Padding(
+      padding: EdgeInsets.symmetric(vertical: 16.0),
+      child: Center(child: CircularProgressIndicator()),
+    );
+  }
+
+  Widget _buildFooter(BuildContext context) {
+    return const SizedBox(height: 100); // Assuming MINIMUM_TOUCH_SIZE = 50
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: Then put MINIMUM_TOUCH_SIZE * 2!
created_at: 2025-04-25T07:37:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+import 'dart:async';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = const <Never>[],
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: Not very familiar with `Never`, but wouldn't that be similar to the following code?
```suggestion
    required Iterable<T> initialItems,
```
created_at: 2025-04-25T17:28:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +30,151 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final InfiniteScrollController<Price, GetPricesParameters>
+      _scrollController;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
-      model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
-      pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
+    final List<Price> initialItems = widget.pricesResult?.items ?? <Price>[];
+
+    _scrollController = InfiniteScrollController<Price, GetPricesParameters>(
+      initialItems: initialItems,
+      fetchItems: _fetchPrices,
+      onError: (dynamic error) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Error fetching prices: $error')),
+        );
       },
     );
-  }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
-  @override
-  Widget build(BuildContext context) {
-    context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
+    if (widget.pricesResult != null) {
+      _scrollController.updatePaginationInfo(
+        newTotalItems: widget.pricesResult!.total,
+        newTotalPages: widget.pricesResult!.numberOfPages,
+      );
     }
+  }
 
-    return _ActualList(
-      model: widget.model,
-      result: _productPriceRefresher.pricesResult!,
+  Future<(List<Price>, bool)> _fetchPrices(
+    GetPricesParameters parameters,
+    int page,
+  ) async {
+    final MaybeError<GetPricesResult> result =
+        await OpenPricesAPIClient.getPrices(
+      parameters..pageNumber = page,
     );
-  }
-}
 
-class _ActualList extends StatelessWidget {
-  const _ActualList({
-    required this.model,
-    required this.result,
-  });
+    if (result.isError) {
+      throw result.detailError;
+    }
 
-  final GetPricesModel model;
-  final GetPricesResult result;
+    final List<Price> items = result.value.items ?? <Price>[];
+    final bool hasMore = page < (result.value.numberOfPages ?? 1);
+
+    // Update pagination info
+    _scrollController.updatePaginationInfo(
+      newTotalItems: result.value.total,
+      newTotalPages: result.value.numberOfPages,
+    );
+
+    return (items, hasMore);
+  }
 
   @override
   Widget build(BuildContext context) {
-    final List<Widget> children = <Widget>[];
+    context.watch<LocalDatabase>();
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
 
-    if (!model.displayEachProduct) {
-      // in that case we display the product only once, if possible.
-      for (final Price price in result.items!) {
-        final PriceProduct? priceProduct = price.product;
-        if (priceProduct == null) {
-          continue;
+    return InfiniteScrollList<Price, GetPricesParameters>(
+      controller: _scrollController,
+      parameters: widget.model.parameters,
+      headerBuilder: (BuildContext context) {
+        String title;
+        final int totalPages = _scrollController.totalPages ?? 1;
+        final int currentPage = _scrollController.currentPage;
+        final int itemsCount = _scrollController.items.length;
+        final int totalItems = _scrollController.totalItems ?? itemsCount;
+
+        if (totalPages > 1) {
+          title = appLocalizations.prices_list_length_many_pages(
+            itemsCount,
+            totalItems,
+          );
+          title = '$title ($currentPage / $totalPages)';
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: I would use a subtitle instead, with an explicit label like `Page 1 / 18`.

Besides, the `prices_list_length_many_pages` label isn't relevant anymore (`"Latest {pageSize} prices (total: {total})"`).
created_at: 2025-04-25T17:33:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -55,6 +55,13 @@ class ProductPriceRefresher {
     }
   }
 
+  // Add a refresh method to reload first page
+  Future<void> refresh() async {
+    _loadingStatus = null;
+    pricesResult = null;
+    await _asyncLoad();
+  }
+
path: packages/smooth_app/lib/pages/prices/product_price_refresher.dart
user: login: monsieurtanuki
body: ```suggestion
```

Please remove that unused code.
created_at: 2025-04-25T17:40:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,15 +30,142 @@ class PricesProofsPage extends StatefulWidget {
 
 class _PricesProofsPageState extends State<PricesProofsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetProofsResult>> _results = _download();
-
   static const int _columns = 3;
   static const int _rows = 5;
   static const int _pageSize = _columns * _rows;
 
+  late final InfiniteScrollController<Proof, GetProofsParameters>
+      _scrollController;
+  String? _bearerToken;
+  final ScrollController _gridScrollController = ScrollController();
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = InfiniteScrollController<Proof, GetProofsParameters>(
+      initialItems: const <Proof>[],
+      fetchItems: _fetchProofs,
+      onError: (dynamic error) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Error loading proofs : $error')),
+        );
+      },
+    );
+
+    _gridScrollController.addListener(_scrollListener);
+
+    _authenticate();
+  }
+
+  void _scrollListener() {
+    if (_scrollController.isLoading || !_scrollController.hasMoreItems) {
+      return;
+    }
+
+    final double maxScroll = _gridScrollController.position.maxScrollExtent;
+    final double currentScroll = _gridScrollController.position.pixels;
+    const double triggerOffset = 200.0;
+
+    if (currentScroll > maxScroll - triggerOffset) {
+      final GetProofsParameters parameters = _getProofsParameters();
+      _scrollController.loadMore(parameters);
+    }
+  }
+
+  GetProofsParameters _getProofsParameters() {
+    return GetProofsParameters()
+      ..orderBy = <OrderBy<GetProofsOrderField>>[
+        const OrderBy<GetProofsOrderField>(
+          field: GetProofsOrderField.created,
+          ascending: false,
+        ),
+      ]
+      ..pageSize = _pageSize;
+  }
+
+  @override
+  void dispose() {
+    _gridScrollController.removeListener(_scrollListener);
+    _gridScrollController.dispose();
+    _deleteSession();
+    super.dispose();
+  }
+
+  Future<void> _authenticate() async {
+    final User user = ProductQuery.getWriteUser();
+    final MaybeError<String> token =
+        await OpenPricesAPIClient.getAuthenticationToken(
+      username: user.userId,
+      password: user.password,
+      uriHelper: ProductQuery.uriPricesHelper,
+    );
+
+    if (token.isError) {
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Authentication error: ${token.error}')),
+        );
+      }
+      return;
+    }
+
+    _bearerToken = token.value;
+
+    if (mounted) {
+      setState(() {});
+    }
+  }
+
+  Future<void> _deleteSession() async {
+    if (_bearerToken != null) {
+      await OpenPricesAPIClient.deleteUserSession(
+        uriHelper: ProductQuery.uriPricesHelper,
+        bearerToken: _bearerToken!,
+      );
+      _bearerToken = null;
+    }
+  }
+
+  Future<(List<Proof>, bool)> _fetchProofs(
+      GetProofsParameters parameters, int page) async {
+    try {
+      if (_bearerToken == null) {
+        throw 'Not authenticated yet';
+      }
+
+      final User user = ProductQuery.getWriteUser();
+
+      final MaybeError<GetProofsResult> result =
+          await OpenPricesAPIClient.getProofs(
+        parameters
+          ..owner = user.userId
+          ..pageNumber = page,
+        uriHelper: ProductQuery.uriPricesHelper,
+        bearerToken: _bearerToken!,
+      );
+
+      if (result.isError) {
+        throw result.error!;
+      }
+
+      final List<Proof> items = result.value.items ?? <Proof>[];
+      final bool hasMore = page < (result.value.numberOfPages ?? 1);
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: Really I don't get it: as long as you have the current page number and the total number of pages, you don't need to store or to return a `bool hasMore`.

Here
* you repeat the same code in all your files
* you make the code more complex returning `(items, hasMore)` instead of just `items`.
created_at: 2025-04-25T17:43:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,104 @@
+import 'dart:async';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = const <Never>[],
+    this.initialPage = 1,
+    this.onError,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: Again, we have 5 copied/pasted error managements.
Please don't make it a parameter: just always use the same code in your `catch`, with localized label `loading_dialog_default_error_message` (and the exception.toString()).
created_at: 2025-04-26T07:16:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,76 +99,33 @@ class _PricesProductsPageState extends State<PricesProductsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetPriceProductsResult>>(
-        future: _products,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetPriceProductsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetPriceProductsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
+      body: InfiniteScrollList<PriceProduct, GetPriceProductsParameters>(
+        controller: _scrollController,
+        parameters: parameters,
+        headerBuilder: (final BuildContext context) {
+          final int totalItems = _scrollController.totalItems ?? 0;
 
-          for (final PriceProduct item in result.items!) {
-            children.add(
-              SmoothCard(
-                child: PriceProductWidget(
-                  item,
-                  enableCountButton: true,
-                ),
-              ),
-            );
-          }
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
           final String title =
               appLocalizations.prices_products_list_length_many_pages(
             _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
+            totalItems,
           );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
+
+          return SmoothCard(child: ListTile(title: Text(title)));
+        },
+        // Individual item builder
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: Please remove useless comments.
created_at: 2025-04-26T07:23:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,16 +22,63 @@ class PricesProductsPage extends StatefulWidget {
 
 class _PricesProductsPageState extends State<PricesProductsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetPriceProductsResult>> _products =
-      _showTopProducts();
-
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
   static const int _pageSize = 10;
+  late final InfiniteScrollController<PriceProduct, GetPriceProductsParameters>
+      _scrollController;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController =
+        InfiniteScrollController<PriceProduct, GetPriceProductsParameters>(
+      initialItems: const <PriceProduct>[],
+      fetchItems: _fetchProducts,
+      onError: (dynamic error) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          const SnackBar(content: Text('Error loading products')),
+        );
+      },
+    );
+  }
+
+  Future<(List<PriceProduct>, bool)> _fetchProducts(
+    final GetPriceProductsParameters parameters,
+    final int page,
+  ) async {
+    try {
+      final MaybeError<GetPriceProductsResult> result =
+          await OpenPricesAPIClient.getPriceProducts(
+        parameters..pageNumber = page,
+        uriHelper: ProductQuery.uriPricesHelper,
+      );
+
+      final List<PriceProduct> items = result.value.items ?? <PriceProduct>[];
+      final bool hasMore = page < (result.value.numberOfPages ?? 1);
+
+      // Update pagination info
+      _scrollController.updatePaginationInfo(
+        newTotalItems: result.value.total,
+        newTotalPages: result.value.numberOfPages,
+      );
+
+      return (items, hasMore);
+    } catch (e) {
+      throw e.toString();
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: ```suggestion
```
Haven't already asked you to get rid of that nonsense?
created_at: 2025-04-26T07:24:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,194 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item) itemBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  // Hardcoded constant
+  static const double _loadMoreTriggerOffset = 200.0;
+
+  late final ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    _initialLoad();
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  @override
+  void didUpdateWidget(covariant InfiniteScrollList<T, P> oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    // If parameters change, reset and reload
+    if (widget.parameters != oldWidget.parameters) {
+      unawaited(_resetAndReload());
+    }
+  }
+
+  Future<void> _initialLoad() async {
+    setState(() {
+      _isInitialLoading = true;
+      _error = null;
+    });
+
+    try {
+      final (List<T> items, bool hasMore) = await widget.controller
+          .fetchItems(widget.parameters, widget.controller.initialPage);
+
+      widget.controller.reset(newInitialItems: items);
+      widget.controller.hasMoreItems = hasMore;
+    } catch (e) {
+      _error = e;
+    } finally {
+      if (mounted) {
+        setState(() {
+          _isInitialLoading = false;
+        });
+      }
+    }
+  }
+
+  void _scrollListener() {
+    if (widget.controller.isLoading || !widget.controller.hasMoreItems) {
+      return;
+    }
+
+    final double maxScroll = _scrollController.position.maxScrollExtent;
+    final double currentScroll = _scrollController.position.pixels;
+
+    if (currentScroll > maxScroll - _loadMoreTriggerOffset) {
+      unawaited(_loadMoreItems());
+    }
+  }
+
+  Future<void> _loadMoreItems() async {
+    if (mounted) {
+      setState(() {}); // Trigger rebuild to show loading indicator
+      await widget.controller.loadMore(widget.parameters);
+      if (mounted) {
+        setState(() {}); // Update UI with new items
+      }
+    }
+  }
+
+  Future<void> _resetAndReload() async {
+    if (mounted) {
+      setState(() {
+        _isInitialLoading = true;
+        _error = null;
+      });
+
+      widget.controller.reset();
+      await _initialLoad();
+    }
+  }
+
+  Future<void> _handleRefresh() async {
+    return _resetAndReload();
+  }
+
+  // Default widget builders
+  Widget _buildLoadingState(BuildContext context) {
+    return const Center(child: CircularProgressIndicator());
+  }
+
+  Widget _buildErrorState(BuildContext context, dynamic error) {
+    return Text(error.toString());
+  }
+
+  Widget _buildEmptyState(BuildContext context) {
+    return const Text('No results');
+  }
+
+  Widget _buildLoadingMoreIndicator(BuildContext context) {
+    return const Padding(
+      padding: EdgeInsets.symmetric(vertical: 16.0),
+      child: Center(child: CircularProgressIndicator()),
+    );
+  }
+
+  Widget _buildFooter(BuildContext context) {
+    return const SizedBox(height: MINIMUM_TOUCH_SIZE * 2);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (_isInitialLoading) {
+      return _buildLoadingState(context);
+    }
+
+    if (_error != null) {
+      return _buildErrorState(context, _error);
+    }
+
+    if (widget.controller.items.isEmpty) {
+      return _buildEmptyState(context);
+    }
+
+    final List<Widget> children = <Widget>[];
+
+    // Add header if provided
+    if (widget.headerBuilder != null) {
+      children.add(widget.headerBuilder!(context));
+    }
+
+    // Add list items
+    for (int i = 0; i < widget.controller.items.length; i++) {
+      children.add(widget.itemBuilder(context, widget.controller.items[i]));
+    }
+
+    // Add loading indicator at the bottom if loading more
+    if (widget.controller.isLoading) {
+      children.add(_buildLoadingMoreIndicator(context));
+    }
+
+    // Add footer
+    children.add(_buildFooter(context));
+
+    final ListView listView = ListView(
+      controller: _scrollController,
+      children: children,
+    );
+
+    return RefreshIndicator(
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: No refresh indicator, please.
created_at: 2025-04-26T07:25:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,194 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item) itemBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  // Hardcoded constant
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: Please remove useless comments.
created_at: 2025-04-26T07:27:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,91 @@
+import 'dart:async';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    Iterable<T> initialItems = const <Never>[],
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: ```suggestion
    required Iterable<T> initialItems,
```
created_at: 2025-04-27T05:36:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,91 @@
+import 'dart:async';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    required Iterable<T> initialItems,
+    this.initialPage = 1,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  /// Returns a Future with the fetched items
+  final Future<List<T>> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Returns a formatted page indicator (e.g., "Page 1 / 5")
+  String get formattedPageIndicator {
+    return totalPages != null
+        ? 'Page $currentPage / $totalPages'
+        : 'Page $currentPage';
+  }
+
+  /// Returns a formatted item count (e.g., "25 of 100 items")
+  String get formattedItemCount {
+    return totalItems != null
+        ? '${items.length} of $totalItems items'
+        : '${items.length} items';
+  }
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: Needs to be localized.
created_at: 2025-04-27T09:57:44Z
author_association: CONTRIBUTOR
path: packages/smooth_app/lib/l10n/app_en.arb
user: login: monsieurtanuki
body: Generally speaking, please respect whatever standard you see in a project.
In that specific case, don't use camelCase namings for your label tags.
created_at: 2025-04-27T10:46:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    required Iterable<T> initialItems,
+    this.initialPage = 1,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: Correct me if I'm wrong, but you never set this field to something.
Therefore it's always 1.
Please simplify the code accordingly, making this field a `const` if relevant. That's a good practice anyway to be explicit about the starting point (0 or 1).
created_at: 2025-04-27T10:50:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,95 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    required Iterable<T> initialItems,
+    this.initialPage = 1,
+  })  : _currentPage = initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  /// Returns a Future with the fetched items
+  final Future<List<T>> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  /// Initial page number
+  final int initialPage;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Returns a formatted page indicator (e.g., "Page 1 / 5")
+  String formattedPageIndicator(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return totalPages != null
+        ? appLocalizations.pageIndicatorWithTotal(currentPage, totalPages!)
+        : appLocalizations.pageIndicator(currentPage);
+  }
+
+  /// Returns a formatted item count (e.g., "25 of 100 items")
+  String formattedItemCount(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return totalItems != null
+        ? appLocalizations.itemCountWithTotal(items.length, totalItems!)
+        : appLocalizations.itemCount(items.length);
+  }
+
+  /// Load more items
+  Future<void> loadMore(P parameters) async {
+    if (_isLoading || (totalPages != null && !(_currentPage < totalPages!))) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final List<T> newItems = await fetchItems(parameters, _currentPage + 1);
+      _items.addAll(newItems);
+      _currentPage++;
+    } finally {
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: Adding a `BuildContext` parameter you could have kept your error display.
created_at: 2025-04-27T10:51:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item) itemBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  static const double _loadMoreTriggerOffset = 200.0;
+
+  late final ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    _initialLoad();
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: ```suggestion
    unawaited(_initialLoad());
```
created_at: 2025-04-27T10:52:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,181 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item) itemBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  static const double _loadMoreTriggerOffset = 200.0;
+
+  late final ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    _initialLoad();
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  @override
+  void didUpdateWidget(covariant InfiniteScrollList<T, P> oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.parameters != oldWidget.parameters) {
+      unawaited(_resetAndReload());
+    }
+  }
+
+  Future<void> _initialLoad() async {
+    setState(() {
+      _isInitialLoading = true;
+      _error = null;
+    });
+
+    try {
+      final List<T> items = await widget.controller
+          .fetchItems(widget.parameters, widget.controller.initialPage);
+
+      widget.controller.reset(newInitialItems: items);
+      // Update pagination info if needed
+      // widget.controller.updatePaginationInfo(...) could be called here
+    } catch (e) {
+      _error = e;
+    } finally {
+      if (mounted) {
+        setState(() {
+          _isInitialLoading = false;
+        });
+      }
+    }
+  }
+
+  void _scrollListener() {
+    if (widget.controller.isLoading ||
+        !(widget.controller.totalPages == null ||
+            widget.controller.currentPage < widget.controller.totalPages!)) {
+      return;
+    }
+
+    final double maxScroll = _scrollController.position.maxScrollExtent;
+    final double currentScroll = _scrollController.position.pixels;
+
+    if (currentScroll > maxScroll - _loadMoreTriggerOffset) {
+      unawaited(_loadMoreItems());
+    }
+  }
+
+  Future<void> _loadMoreItems() async {
+    if (mounted) {
+      setState(() {});
+      await widget.controller.loadMore(widget.parameters);
+      if (mounted) {
+        setState(() {});
+      }
+    }
+  }
+
+  Future<void> _resetAndReload() async {
+    if (mounted) {
+      setState(() {
+        _isInitialLoading = true;
+        _error = null;
+      });
+
+      widget.controller.reset();
+      await _initialLoad();
+    }
+  }
+
+  Widget _buildLoadingState(BuildContext context) {
+    return const Center(child: CircularProgressIndicator());
+  }
+
+  Widget _buildErrorState(BuildContext context, dynamic error) {
+    return Text(error.toString());
+  }
+
+  Widget _buildEmptyState(BuildContext context) {
+    return const Text('No results');
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: Localize
created_at: 2025-04-27T10:54:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    required Iterable<T> initialItems,
+  })  : _currentPage = _initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  /// Returns a Future with the fetched items
+  final Future<List<T>> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  static const int _initialPage = 1;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Returns a formatted page indicator (e.g., "Page 1 / 5")
+  String formattedPageIndicator(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return totalPages != null
+        ? appLocalizations.page_indicator_with_total(currentPage, totalPages!)
+        : appLocalizations.page_indicator(currentPage);
+  }
+
+  /// Returns a formatted item count (e.g., "25 of 100 items")
+  String formattedItemCount(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return totalItems != null
+        ? appLocalizations.item_count_with_total(items.length, totalItems!)
+        : appLocalizations.item_count(items.length);
+  }
+
+  /// Load more items
+  Future<void> loadMore(P parameters, [BuildContext? context]) async {
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: ```suggestion
  Future<void> loadMore(P parameters, BuildContext context) async {
```
created_at: 2025-05-03T10:59:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    required Iterable<T> initialItems,
+  })  : _currentPage = _initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  /// Returns a Future with the fetched items
+  final Future<List<T>> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  static const int _initialPage = 1;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Returns a formatted page indicator (e.g., "Page 1 / 5")
+  String formattedPageIndicator(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return totalPages != null
+        ? appLocalizations.page_indicator_with_total(currentPage, totalPages!)
+        : appLocalizations.page_indicator(currentPage);
+  }
+
+  /// Returns a formatted item count (e.g., "25 of 100 items")
+  String formattedItemCount(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return totalItems != null
+        ? appLocalizations.item_count_with_total(items.length, totalItems!)
+        : appLocalizations.item_count(items.length);
+  }
+
+  /// Load more items
+  Future<void> loadMore(P parameters, [BuildContext? context]) async {
+    if (_isLoading || (totalPages != null && !(_currentPage < totalPages!))) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final List<T> newItems = await fetchItems(parameters, _currentPage + 1);
+      _items.addAll(newItems);
+      _currentPage++;
+    } catch (e) {
+      if (context != null && context.mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Error loading more items: $e')),
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: Please localize.
created_at: 2025-05-03T10:59:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    required Iterable<T> initialItems,
+  })  : _currentPage = _initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  /// Returns a Future with the fetched items
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: ```suggestion
  /// Future with the fetched items
```
It's a field, it doesn't "return".
created_at: 2025-05-03T11:01:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    required Iterable<T> initialItems,
+  })  : _currentPage = _initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  /// Returns a Future with the fetched items
+  final Future<List<T>> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  static const int _initialPage = 1;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: Could be private, couldn't it?
created_at: 2025-05-03T11:02:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,182 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item) itemBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  static const double _loadMoreTriggerOffset = 200.0;
+
+  late final ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    unawaited(_initialLoad());
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  @override
+  void didUpdateWidget(covariant InfiniteScrollList<T, P> oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.parameters != oldWidget.parameters) {
+      unawaited(_resetAndReload());
+    }
+  }
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: Not sure why we need this.
Please add a relevant comment or even better: remove that code.
created_at: 2025-05-03T11:04:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,182 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item) itemBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  static const double _loadMoreTriggerOffset = 200.0;
+
+  late final ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    unawaited(_initialLoad());
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  @override
+  void didUpdateWidget(covariant InfiniteScrollList<T, P> oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.parameters != oldWidget.parameters) {
+      unawaited(_resetAndReload());
+    }
+  }
+
+  Future<void> _initialLoad() async {
+    setState(() {
+      _isInitialLoading = true;
+      _error = null;
+    });
+
+    try {
+      final List<T> items =
+          await widget.controller.fetchItems(widget.parameters, 1);
+
+      widget.controller.reset(newInitialItems: items);
+      // Update pagination info if needed
+      // widget.controller.updatePaginationInfo(...) could be called here
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: Please uncomment or remove the comment.
created_at: 2025-05-03T11:05:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,182 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item) itemBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  static const double _loadMoreTriggerOffset = 200.0;
+
+  late final ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    unawaited(_initialLoad());
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  @override
+  void didUpdateWidget(covariant InfiniteScrollList<T, P> oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.parameters != oldWidget.parameters) {
+      unawaited(_resetAndReload());
+    }
+  }
+
+  Future<void> _initialLoad() async {
+    setState(() {
+      _isInitialLoading = true;
+      _error = null;
+    });
+
+    try {
+      final List<T> items =
+          await widget.controller.fetchItems(widget.parameters, 1);
+
+      widget.controller.reset(newInitialItems: items);
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: Why should we reload initial items if we already got some?
The namings of the methods are probably misleading.

What we want in an initial load phase is to load _only if needed_ (e.g. if we don't already have initial items) the first page of data. And if it's the first page of data, of course it should be considered as the initial items.
And we shouldn't have to call an additional `widget.controller.reset` method anyway: when we fetch data, it goes without saying that the items are added to the list.
created_at: 2025-05-03T11:12:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,182 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
+  });
+
+  /// Controller for managing the infinite scroll behavior
+  final InfiniteScrollController<T, P> controller;
+
+  /// Builder for individual list items
+  final Widget Function(BuildContext context, T item) itemBuilder;
+
+  /// Optional builder for header
+  final Widget Function(BuildContext context)? headerBuilder;
+
+  /// Parameters for fetching items
+  final P parameters;
+
+  @override
+  State<InfiniteScrollList<T, P>> createState() =>
+      _InfiniteScrollListState<T, P>();
+}
+
+class _InfiniteScrollListState<T, P> extends State<InfiniteScrollList<T, P>> {
+  static const double _loadMoreTriggerOffset = 200.0;
+
+  late final ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    unawaited(_initialLoad());
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  @override
+  void didUpdateWidget(covariant InfiniteScrollList<T, P> oldWidget) {
+    super.didUpdateWidget(oldWidget);
+    if (widget.parameters != oldWidget.parameters) {
+      unawaited(_resetAndReload());
+    }
+  }
+
+  Future<void> _initialLoad() async {
+    setState(() {
+      _isInitialLoading = true;
+      _error = null;
+    });
+
+    try {
+      final List<T> items =
+          await widget.controller.fetchItems(widget.parameters, 1);
+
+      widget.controller.reset(newInitialItems: items);
+      // Update pagination info if needed
+      // widget.controller.updatePaginationInfo(...) could be called here
+    } catch (e) {
+      _error = e;
+    } finally {
+      if (mounted) {
+        setState(() {
+          _isInitialLoading = false;
+        });
+      }
+    }
+  }
+
+  void _scrollListener() {
+    if (widget.controller.isLoading ||
+        !(widget.controller.totalPages == null ||
+            widget.controller.currentPage < widget.controller.totalPages!)) {
+      return;
+    }
+
+    final double maxScroll = _scrollController.position.maxScrollExtent;
+    final double currentScroll = _scrollController.position.pixels;
+
+    if (currentScroll > maxScroll - _loadMoreTriggerOffset) {
+      unawaited(_loadMoreItems());
+    }
+  }
+
+  Future<void> _loadMoreItems() async {
+    if (mounted) {
+      setState(() {});
+      await widget.controller.loadMore(widget.parameters);
+      if (mounted) {
+        setState(() {});
+      }
+    }
+  }
+
+  Future<void> _resetAndReload() async {
+    if (mounted) {
+      setState(() {
+        _isInitialLoading = true;
+        _error = null;
+      });
+
+      widget.controller.reset();
+      await _initialLoad();
+    }
+  }
+
+  Widget _buildLoadingState(BuildContext context) {
+    return const Center(child: CircularProgressIndicator());
+  }
+
+  Widget _buildErrorState(BuildContext context, dynamic error) {
+    return Text(error.toString());
+  }
+
+  Widget _buildEmptyState(BuildContext context) {
+    return Text(AppLocalizations.of(context).prices_no_result);
+  }
+
+  Widget _buildLoadingMoreIndicator(BuildContext context) {
+    return const Padding(
+      padding: EdgeInsets.symmetric(vertical: 16.0),
+      child: Center(child: CircularProgressIndicator()),
+    );
+  }
+
+  Widget _buildFooter(BuildContext context) {
+    return const SizedBox(height: MINIMUM_TOUCH_SIZE * 2);
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    if (_isInitialLoading) {
+      return _buildLoadingState(context);
+    }
+
+    if (_error != null) {
+      return _buildErrorState(context, _error);
+    }
+
+    if (widget.controller.items.isEmpty) {
+      return _buildEmptyState(context);
+    }
+
+    final List<Widget> children = <Widget>[];
+
+    if (widget.headerBuilder != null) {
+      children.add(widget.headerBuilder!(context));
+    }
+
+    for (int i = 0; i < widget.controller.items.length; i++) {
+      children.add(widget.itemBuilder(context, widget.controller.items[i]));
+    }
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: ```suggestion
    for (final T item in widget.controller.items) {
      children.add(widget.itemBuilder(context, item));
    }
```
created_at: 2025-05-03T11:16:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    required Iterable<T> initialItems,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: ```suggestion
    Iterable<T>? initialItems,
```
created_at: 2025-05-03T11:20:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +30,151 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final InfiniteScrollController<Price, GetPricesParameters>
+      _scrollController;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
-      model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
-      pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
+    final List<Price> initialItems = widget.pricesResult?.items ?? <Price>[];
+
+    _scrollController = InfiniteScrollController<Price, GetPricesParameters>(
+      initialItems: initialItems,
+      fetchItems: _fetchPrices,
+      onError: (dynamic error) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Error fetching prices: $error')),
+        );
       },
     );
-  }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
-  @override
-  Widget build(BuildContext context) {
-    context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
+    if (widget.pricesResult != null) {
+      _scrollController.updatePaginationInfo(
+        newTotalItems: widget.pricesResult!.total,
+        newTotalPages: widget.pricesResult!.numberOfPages,
+      );
     }
+  }
 
-    return _ActualList(
-      model: widget.model,
-      result: _productPriceRefresher.pricesResult!,
+  Future<(List<Price>, bool)> _fetchPrices(
+    GetPricesParameters parameters,
+    int page,
+  ) async {
+    final MaybeError<GetPricesResult> result =
+        await OpenPricesAPIClient.getPrices(
+      parameters..pageNumber = page,
     );
-  }
-}
 
-class _ActualList extends StatelessWidget {
-  const _ActualList({
-    required this.model,
-    required this.result,
-  });
+    if (result.isError) {
+      throw result.detailError;
+    }
 
-  final GetPricesModel model;
-  final GetPricesResult result;
+    final List<Price> items = result.value.items ?? <Price>[];
+    final bool hasMore = page < (result.value.numberOfPages ?? 1);
+
+    // Update pagination info
+    _scrollController.updatePaginationInfo(
+      newTotalItems: result.value.total,
+      newTotalPages: result.value.numberOfPages,
+    );
+
+    return (items, hasMore);
+  }
 
   @override
   Widget build(BuildContext context) {
-    final List<Widget> children = <Widget>[];
+    context.watch<LocalDatabase>();
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
 
-    if (!model.displayEachProduct) {
-      // in that case we display the product only once, if possible.
-      for (final Price price in result.items!) {
-        final PriceProduct? priceProduct = price.product;
-        if (priceProduct == null) {
-          continue;
+    return InfiniteScrollList<Price, GetPricesParameters>(
+      controller: _scrollController,
+      parameters: widget.model.parameters,
+      headerBuilder: (BuildContext context) {
+        String title;
+        final int totalPages = _scrollController.totalPages ?? 1;
+        final int currentPage = _scrollController.currentPage;
+        final int itemsCount = _scrollController.items.length;
+        final int totalItems = _scrollController.totalItems ?? itemsCount;
+
+        if (totalPages > 1) {
+          title = appLocalizations.prices_list_length_many_pages(
+            itemsCount,
+            totalItems,
+          );
+          title = '$title ($currentPage / $totalPages)';
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: Isn't it where you're supposed to use your specific page label from app_en.arb?
created_at: 2025-05-03T11:22:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    required Iterable<T> initialItems,
+  })  : _currentPage = _initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  /// Returns a Future with the fetched items
+  final Future<List<T>> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  static const int _initialPage = 1;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Returns a formatted page indicator (e.g., "Page 1 / 5")
+  String formattedPageIndicator(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return totalPages != null
+        ? appLocalizations.page_indicator_with_total(currentPage, totalPages!)
+        : appLocalizations.page_indicator(currentPage);
+  }
+
+  /// Returns a formatted item count (e.g., "25 of 100 items")
+  String formattedItemCount(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return totalItems != null
+        ? appLocalizations.item_count_with_total(items.length, totalItems!)
+        : appLocalizations.item_count(items.length);
+  }
+
+  /// Load more items
+  Future<void> loadMore(P parameters, [BuildContext? context]) async {
+    if (_isLoading || (totalPages != null && !(_currentPage < totalPages!))) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final List<T> newItems = await fetchItems(parameters, _currentPage + 1);
+      _items.addAll(newItems);
+      _currentPage++;
+    } catch (e) {
+      if (context != null && context.mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Error loading more items: $e')),
+        );
+      }
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: Not needed.
created_at: 2025-05-03T11:28:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
+  InfiniteScrollController({
+    required this.fetchItems,
+    required Iterable<T> initialItems,
+  })  : _currentPage = _initialPage,
+        _items = List<T>.from(initialItems),
+        initialItems = List<T>.from(initialItems);
+
+  /// Returns a Future with the fetched items
+  final Future<List<T>> Function(P parameters, int page) fetchItems;
+
+  /// Parameters for the fetch operation
+  P? parameters;
+
+  static const int _initialPage = 1;
+
+  /// Initial items to populate the list
+  final List<T> initialItems;
+
+  /// Current items in the list
+  List<T> _items;
+  List<T> get items => _items;
+
+  /// Current page being fetched
+  int _currentPage;
+  int get currentPage => _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+  bool get isLoading => _isLoading;
+
+  /// Additional pagination information
+  int? totalItems;
+  int? totalPages;
+
+  /// Returns a formatted page indicator (e.g., "Page 1 / 5")
+  String formattedPageIndicator(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return totalPages != null
+        ? appLocalizations.page_indicator_with_total(currentPage, totalPages!)
+        : appLocalizations.page_indicator(currentPage);
+  }
+
+  /// Returns a formatted item count (e.g., "25 of 100 items")
+  String formattedItemCount(BuildContext context) {
+    final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    return totalItems != null
+        ? appLocalizations.item_count_with_total(items.length, totalItems!)
+        : appLocalizations.item_count(items.length);
+  }
+
+  /// Load more items
+  Future<void> loadMore(P parameters, [BuildContext? context]) async {
+    if (_isLoading || (totalPages != null && !(_currentPage < totalPages!))) {
+      return;
+    }
+
+    this.parameters = parameters;
+    _isLoading = true;
+
+    try {
+      final List<T> newItems = await fetchItems(parameters, _currentPage + 1);
+      _items.addAll(newItems);
+      _currentPage++;
+    } catch (e) {
+      if (context != null && context.mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Error loading more items: $e')),
+        );
+      }
+    } finally {
+      _isLoading = false;
+    }
+  }
+
+  /// Reset the controller to its initial state with optional new initial items
+  void reset({List<T>? newInitialItems}) {
+    _currentPage = _initialPage;
+    _items = newInitialItems != null
+        ? List<T>.from(newInitialItems)
+        : List<T>.from(initialItems);
+    _isLoading = false;
+    totalItems = null;
+    totalPages = null;
+  }
+
+  /// Update pagination information
+  void updatePaginationInfo({int? newTotalItems, int? newTotalPages}) {
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: Private. To be called automatically each time `_fetchItems` is called.
created_at: 2025-05-03T11:29:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+class InfiniteScrollController<T, P> {
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: Thinking again and again about it, that class should make public only a couple of methods:
* `loadInitiallyIfNeeded` - loading initial data, only if empty
* `loadMore` - as currently

And `_fetchItems` should be made private.

Something like that, in pseudo-code:
```
Future<void> _load(int page) {
  final res = await _fetchItems(page);
  _updatePaginationInfo(res);
}

Future<void> loadInitiallyIfNeeded() async {
  if (_initialItems != null && _initialItems.isNotEmpty) {
    return;
  }
  await _load(1);
}

Future<void> loadMore() async {
  if (_somehowNoMoreExpectedItems) {
    return;
  }
  await _load(_currentPage + 1);
}
```
created_at: 2025-05-03T11:31:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +30,140 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final InfiniteScrollController<Price, GetPricesParameters>
+      _scrollController;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
-      model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
-      pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
-      },
+    final List<Price> initialItems = widget.pricesResult?.items ?? <Price>[];
+
+    _scrollController = InfiniteScrollController<Price, GetPricesParameters>(
+      initialItems: initialItems,
+      fetchItems: _fetchPrices,
     );
-  }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
-  @override
-  Widget build(BuildContext context) {
-    context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
+    if (widget.pricesResult != null) {
+      _scrollController.updatePaginationInfo(
+        newTotalItems: widget.pricesResult!.total,
+        newTotalPages: widget.pricesResult!.numberOfPages,
+      );
     }
+  }
 
-    return _ActualList(
-      model: widget.model,
-      result: _productPriceRefresher.pricesResult!,
+  Future<List<Price>> _fetchPrices(
+    GetPricesParameters parameters,
+    int page,
+  ) async {
+    final MaybeError<GetPricesResult> result =
+        await OpenPricesAPIClient.getPrices(
+      parameters..pageNumber = page,
     );
-  }
-}
 
-class _ActualList extends StatelessWidget {
-  const _ActualList({
-    required this.model,
-    required this.result,
-  });
+    final List<Price> items = result.value.items ?? <Price>[];
 
-  final GetPricesModel model;
-  final GetPricesResult result;
+    _scrollController.updatePaginationInfo(
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: Shouldn't be called here, but within the controller.
created_at: 2025-05-03T11:33:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,182 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_controller.dart';
+
+/// A generic stateful widget for infinite scrolling lists.
+class InfiniteScrollList<T, P> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.controller,
+    required this.itemBuilder,
+    required this.parameters,
+    this.headerBuilder,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: Actually, there's no reason why the `headerBuilder` would be differ.

Please don't make it a parameter and just always use the same header with the same data (e.g. Page 1 / 10).
Anyway the purpose of the screen is detailed on the page title.
created_at: 2025-05-03T11:47:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,16 +22,52 @@ class PricesProductsPage extends StatefulWidget {
 
 class _PricesProductsPageState extends State<PricesProductsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetPriceProductsResult>> _products =
-      _showTopProducts();
-
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
   static const int _pageSize = 10;
+  late final InfiniteScrollController<PriceProduct, GetPriceProductsParameters>
+      _scrollController;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController =
+        InfiniteScrollController<PriceProduct, GetPriceProductsParameters>(
+      initialItems: const <PriceProduct>[],
+      fetchItems: _fetchProducts,
+    );
+  }
+
+  Future<List<PriceProduct>> _fetchProducts(
+    final GetPriceProductsParameters parameters,
+    final int page,
+  ) async {
+    final MaybeError<GetPriceProductsResult> result =
+        await OpenPricesAPIClient.getPriceProducts(
+      parameters..pageNumber = page,
+      uriHelper: ProductQuery.uriPricesHelper,
+    );
+
+    final List<PriceProduct> items = result.value.items ?? <PriceProduct>[];
+
+    _scrollController.updatePaginationInfo(
+      newTotalItems: result.value.total,
+      newTotalPages: result.value.numberOfPages,
+    );
+
+    return items;
+  }
 
   @override
-  Widget build(BuildContext context) {
+  Widget build(final BuildContext context) {
     final AppLocalizations appLocalizations = AppLocalizations.of(context);
+    final GetPriceProductsParameters parameters = GetPriceProductsParameters()
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: Could be a class private `final` field, couldn't it?
created_at: 2025-05-03T11:50:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,15 +30,130 @@ class PricesProofsPage extends StatefulWidget {
 
 class _PricesProofsPageState extends State<PricesProofsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetProofsResult>> _results = _download();
-
   static const int _columns = 3;
   static const int _rows = 5;
   static const int _pageSize = _columns * _rows;
 
+  late final InfiniteScrollController<Proof, GetProofsParameters>
+      _scrollController;
+  String? _bearerToken;
+  final ScrollController _gridScrollController = ScrollController();
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = InfiniteScrollController<Proof, GetProofsParameters>(
+      initialItems: const <Proof>[],
+      fetchItems: _fetchProofs,
+    );
+
+    _gridScrollController.addListener(_scrollListener);
+
+    _authenticate();
+  }
+
+  void _scrollListener() {
+    if (_scrollController.isLoading ||
+        !(_scrollController.totalPages == null ||
+            _scrollController.currentPage < _scrollController.totalPages!)) {
+      return;
+    }
+
+    final double maxScroll = _gridScrollController.position.maxScrollExtent;
+    final double currentScroll = _gridScrollController.position.pixels;
+    const double triggerOffset = 200.0;
+
+    if (currentScroll > maxScroll - triggerOffset) {
+      final GetProofsParameters parameters = _getProofsParameters();
+      _scrollController.loadMore(parameters);
+    }
+  }
+
+  GetProofsParameters _getProofsParameters() {
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: private final field instead.
created_at: 2025-05-03T11:51:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,15 +30,130 @@ class PricesProofsPage extends StatefulWidget {
 
 class _PricesProofsPageState extends State<PricesProofsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetProofsResult>> _results = _download();
-
   static const int _columns = 3;
   static const int _rows = 5;
   static const int _pageSize = _columns * _rows;
 
+  late final InfiniteScrollController<Proof, GetProofsParameters>
+      _scrollController;
+  String? _bearerToken;
+  final ScrollController _gridScrollController = ScrollController();
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = InfiniteScrollController<Proof, GetProofsParameters>(
+      initialItems: const <Proof>[],
+      fetchItems: _fetchProofs,
+    );
+
+    _gridScrollController.addListener(_scrollListener);
+
+    _authenticate();
+  }
+
+  void _scrollListener() {
+    if (_scrollController.isLoading ||
+        !(_scrollController.totalPages == null ||
+            _scrollController.currentPage < _scrollController.totalPages!)) {
+      return;
+    }
+
+    final double maxScroll = _gridScrollController.position.maxScrollExtent;
+    final double currentScroll = _gridScrollController.position.pixels;
+    const double triggerOffset = 200.0;
+
+    if (currentScroll > maxScroll - triggerOffset) {
+      final GetProofsParameters parameters = _getProofsParameters();
+      _scrollController.loadMore(parameters);
+    }
+  }
+
+  GetProofsParameters _getProofsParameters() {
+    return GetProofsParameters()
+      ..orderBy = <OrderBy<GetProofsOrderField>>[
+        const OrderBy<GetProofsOrderField>(
+          field: GetProofsOrderField.created,
+          ascending: false,
+        ),
+      ]
+      ..pageSize = _pageSize;
+  }
+
+  @override
+  void dispose() {
+    _gridScrollController.removeListener(_scrollListener);
+    _gridScrollController.dispose();
+    _deleteSession();
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: ```suggestion
    unawaited(_deleteSession());
```
created_at: 2025-05-03T11:51:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,15 +30,130 @@ class PricesProofsPage extends StatefulWidget {
 
 class _PricesProofsPageState extends State<PricesProofsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetProofsResult>> _results = _download();
-
   static const int _columns = 3;
   static const int _rows = 5;
   static const int _pageSize = _columns * _rows;
 
+  late final InfiniteScrollController<Proof, GetProofsParameters>
+      _scrollController;
+  String? _bearerToken;
+  final ScrollController _gridScrollController = ScrollController();
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = InfiniteScrollController<Proof, GetProofsParameters>(
+      initialItems: const <Proof>[],
+      fetchItems: _fetchProofs,
+    );
+
+    _gridScrollController.addListener(_scrollListener);
+
+    _authenticate();
+  }
+
+  void _scrollListener() {
+    if (_scrollController.isLoading ||
+        !(_scrollController.totalPages == null ||
+            _scrollController.currentPage < _scrollController.totalPages!)) {
+      return;
+    }
+
+    final double maxScroll = _gridScrollController.position.maxScrollExtent;
+    final double currentScroll = _gridScrollController.position.pixels;
+    const double triggerOffset = 200.0;
+
+    if (currentScroll > maxScroll - triggerOffset) {
+      final GetProofsParameters parameters = _getProofsParameters();
+      _scrollController.loadMore(parameters);
+    }
+  }
+
+  GetProofsParameters _getProofsParameters() {
+    return GetProofsParameters()
+      ..orderBy = <OrderBy<GetProofsOrderField>>[
+        const OrderBy<GetProofsOrderField>(
+          field: GetProofsOrderField.created,
+          ascending: false,
+        ),
+      ]
+      ..pageSize = _pageSize;
+  }
+
+  @override
+  void dispose() {
+    _gridScrollController.removeListener(_scrollListener);
+    _gridScrollController.dispose();
+    _deleteSession();
+    super.dispose();
+  }
+
+  Future<void> _authenticate() async {
+    final User user = ProductQuery.getWriteUser();
+    final MaybeError<String> token =
+        await OpenPricesAPIClient.getAuthenticationToken(
+      username: user.userId,
+      password: user.password,
+      uriHelper: ProductQuery.uriPricesHelper,
+    );
+
+    if (token.isError) {
+      if (mounted) {
+        ScaffoldMessenger.of(context).showSnackBar(
+          SnackBar(content: Text('Authentication error: ${token.error}')),
+        );
+      }
+      return;
+    }
+
+    _bearerToken = token.value;
+
+    if (mounted) {
+      setState(() {});
+    }
+  }
+
+  Future<void> _deleteSession() async {
+    if (_bearerToken != null) {
+      await OpenPricesAPIClient.deleteUserSession(
+        uriHelper: ProductQuery.uriPricesHelper,
+        bearerToken: _bearerToken!,
+      );
+      _bearerToken = null;
+    }
+  }
+
+  Future<List<Proof>> _fetchProofs(
+      GetProofsParameters parameters, int page) async {
+    if (_bearerToken == null) {
+      throw 'Not authenticated yet';
+    }
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: It's not the way it's going to work.
We do need the token. So if it's null, get it first.
created_at: 2025-05-03T11:53:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,149 +174,123 @@ class _PricesProofsPageState extends State<PricesProofsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetProofsResult>>(
-        future: _results,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetProofsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetProofsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final double squareSize = MediaQuery.sizeOf(context).width / _columns;
-
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title = result.numberOfPages == 1
-              ? appLocalizations.prices_proofs_list_length_one_page(
-                  result.items!.length,
-                )
-              : appLocalizations.prices_proofs_list_length_many_pages(
-                  _pageSize,
-                  result.total!,
-                );
-          return Column(
-            children: <Widget>[
-              SmoothCard(
-                child: ListTile(
-                  title: Text(title),
-                ),
-              ),
-              if (result.items!.isNotEmpty)
-                Expanded(
-                  child: CustomScrollView(
-                    slivers: <Widget>[
-                      SliverGrid(
-                        gridDelegate:
-                            const SliverGridDelegateWithFixedCrossAxisCount(
-                          crossAxisCount: _columns,
-                        ),
-                        delegate: SliverChildBuilderDelegate(
-                          (
-                            final BuildContext context,
-                            final int index,
-                          ) {
-                            final Proof proof = result.items![index];
-                            if (proof.filePath == null) {
-                              // highly improbable
-                              return SizedBox(
-                                width: squareSize,
-                                height: squareSize,
-                              );
-                            }
-                            return InkWell(
-                              onTap: () async {
-                                if (widget.selectProof) {
-                                  Navigator.of(context).pop(proof);
-                                  return;
-                                }
-                                return Navigator.push<void>(
-                                  context,
-                                  MaterialPageRoute<void>(
-                                    builder: (BuildContext context) =>
-                                        PriceProofPage(
-                                      proof,
-                                    ),
-                                  ),
-                                );
-                              }, // PriceProofPage
-                              child: _PriceProofImage(proof,
-                                  squareSize: squareSize),
-                            );
-                          },
-                          addAutomaticKeepAlives: false,
-                          childCount: result.items!.length,
-                        ),
-                      ),
-                    ],
-                  ),
-                ),
-            ],
-          );
-        },
-      ),
+      body: _bearerToken == null
+          ? // Show loading while authenticating
+          const Center(child: CircularProgressIndicator())
+          : // Show content once authenticated
+          _buildProofsContent(context, appLocalizations),
     );
   }
 
-  static Future<MaybeError<GetProofsResult>> _download() async {
-    final User user = ProductQuery.getWriteUser();
-    final MaybeError<String> token =
-        await OpenPricesAPIClient.getAuthenticationToken(
-      username: user.userId,
-      password: user.password,
-      uriHelper: ProductQuery.uriPricesHelper,
+  Widget _buildProofsContent(
+    BuildContext context,
+    AppLocalizations appLocalizations,
+  ) {
+    return Column(
+      children: <Widget>[
+        Builder(
+          builder: (BuildContext context) {
+            final int totalItems = _scrollController.totalItems ?? 0;
+            final int totalPages = _scrollController.totalPages ?? 1;
+            final int itemCount = _scrollController.items.length;
+
+            final String title = totalPages <= 1
+                ? appLocalizations.prices_proofs_list_length_one_page(itemCount)
+                : appLocalizations.prices_proofs_list_length_many_pages(
+                    _pageSize,
+                    totalItems,
+                  );
+
+            return SmoothCard(
+              child: ListTile(
+                title: Text(title),
+              ),
+            );
+          },
+        ),
+        Expanded(
+          child: _buildProofsGrid(context, appLocalizations),
+        ),
+      ],
     );
+  }
 
-    if (token.isError) {
-      return MaybeError<GetProofsResult>.error(
-        error: token.error ?? 'Could not authenticate with the server',
-        statusCode: token.statusCode ?? 500,
-      );
+  Widget _buildProofsGrid(
+    BuildContext context,
+    AppLocalizations appLocalizations,
+  ) {
+    if (_scrollController.items.isEmpty) {
+      if (_scrollController.isLoading) {
+        return const Center(child: CircularProgressIndicator());
+      } else {
+        return const Center(child: Text('No Result'));
+      }
     }
 
-    final String bearerToken = token.value;
+    return CustomScrollView(
+      controller: _gridScrollController,
+      slivers: <Widget>[
+        SliverGrid(
+          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+            crossAxisCount: _columns,
+          ),
+          delegate: SliverChildBuilderDelegate(
+            (BuildContext context, int index) {
+              final Proof proof = _scrollController.items[index];
+              final double squareSize =
+                  MediaQuery.of(context).size.width / _columns;
 
-    final MaybeError<GetProofsResult> result =
-        await OpenPricesAPIClient.getProofs(
-      GetProofsParameters()
-        ..orderBy = <OrderBy<GetProofsOrderField>>[
-          const OrderBy<GetProofsOrderField>(
-            field: GetProofsOrderField.created,
-            ascending: false,
+              if (proof.filePath == null) {
+                return SizedBox(
+                  width: squareSize,
+                  height: squareSize,
+                );
+              }
+
+              return InkWell(
+                onTap: () async {
+                  if (widget.selectProof) {
+                    Navigator.of(context).pop(proof);
+                    return;
+                  }
+                  return Navigator.push<void>(
+                    context,
+                    MaterialPageRoute<void>(
+                      builder: (BuildContext context) => PriceProofPage(proof),
+                    ),
+                  );
+                },
+                child: _PriceProofImage(proof, squareSize: squareSize),
+              );
+            },
+            childCount: _scrollController.items.length,
+            addAutomaticKeepAlives: false,
           ),
-        ]
-        ..owner = user.userId
-        ..pageSize = _pageSize
-        ..pageNumber = 1,
-      uriHelper: ProductQuery.uriPricesHelper,
-      bearerToken: bearerToken,
+        ),
+        if (_scrollController.isLoading)
+          const SliverToBoxAdapter(
+            child: Padding(
+              padding: EdgeInsets.all(16.0),
+              child: Center(child: CircularProgressIndicator()),
+            ),
+          ),
+      ],
     );
+  }
 
-    await OpenPricesAPIClient.deleteUserSession(
-      uriHelper: ProductQuery.uriPricesHelper,
-      bearerToken: bearerToken,
-    );
+  @override
+  void didChangeDependencies() {
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: Not sure I understand what's going on there.
The scroll limit of 200 pixels won't work here?
created_at: 2025-05-03T11:55:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,99 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: I guess we should add `[R] is the type of result we get from the parameters`.
So that we don't have to dance tango with the result:
* we call a method with the [P]arameters, to get a [R]esult.
* from the result, we compute the pagination data to be displayed
* and we get the items of type [T] to be displayed
created_at: 2025-05-03T12:01:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,131 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic controller for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+/// [P] is the type of parameters used for pagination.
+/// [R] is the type of result returned from the fetch operation.
+class InfiniteScrollController<T, P, R> {
+  InfiniteScrollController({
+    required Future<R> Function(P parameters, int page,
+            {void Function(int? totalItems, int? totalPages)?
+                onPageInfoUpdated})
+        fetchResult,
+    required List<T> Function(R result) extractItems,
+    Iterable<T>? initialItems,
+  })  : _fetchResult = fetchResult,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_controller.dart
user: login: monsieurtanuki
body: @AshutoshKhadse23 That's getting too hard to read.

I think that the initial problem is trying to use lambda methods.
When I said OOP I meant OOP _à la Java_, with extended classes.

Let me show you what I had in mind
```dart
/// A generic controller for handling infinite scrolling in lists.
abstract class InfiniteScrollManager<T> {
  InfiniteScrollManager({
    final List<T>? initialItems,
  }) : _items = initialItems ?? <T>[];

  @protected
  Future<void> download(int pageNumber);

  @protected
  Widget showItem({
    required BuildContext context,
    required T item,
  });

  static const int _initialPage = 1;

  /// Current items in the list
  final List<T> _items;

  List<T> get items => _items;

  /// Current page being fetched
  int _currentPage = _initialPage;

  /// Whether currently loading more items
  bool _isLoading = false;

  bool get isLoading => _isLoading;

  /// Additional pagination information
  int? _totalItems;

  int? _totalPages;

  @protected
  void addPage({
    required List<T>? items,
    required int? pageNumber,
    required int? totalItems,
    required int? totalPages,
  }) {
    if (items == null || pageNumber == null) {
      return;
    }
    _items.addAll(items);
    _currentPage = pageNumber;
    _totalItems = totalItems;
    _totalPages = totalPages;
  }

  /// Load initial data only if the list is empty
  Future<void> loadInitiallyIfNeeded(BuildContext context) async {
    if (_items.isNotEmpty) {
      return;
    }
    await _load(context: context, pageNumber: _initialPage);
  }

  /// Load more items (next page)
  Future<void> loadMore(BuildContext context) async {
    if (_totalPages != null && !(_currentPage < _totalPages!)) {
      return;
    }
    await _load(context: context, pageNumber: _currentPage + 1);
  }

  /// Load more items
  Future<void> _load({
    required BuildContext context,
    required int pageNumber,
  }) async {
    if (_isLoading) {
      return;
    }

    _isLoading = true;

    try {
      await download(pageNumber);
    } catch (e) {
      if (context.mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(
              AppLocalizations.of(context).prices_error_loading_more_items,
            ),
          ),
        );
      }
    } finally {
      _isLoading = false;
    }
  }

  /// Returns a formatted page indicator (e.g., "Page 1 / 5")
  String formattedPageIndicator(final AppLocalizations appLocalizations) =>
      _totalPages != null
          ? appLocalizations.page_indicator_with_total(
              _currentPage, _totalPages!)
          : appLocalizations.page_indicator(_currentPage);

  /// Returns a formatted item count (e.g., "25 of 100 items")
  String formattedItemCount(final AppLocalizations appLocalizations) =>
      _totalItems != null
          ? appLocalizations.item_count_with_total(_items.length, _totalItems!)
          : appLocalizations.item_count(_items.length);
}
```

And then, just extend that class like that:
```dart
class InfiniteScrollLocationManager extends InfiniteScrollManager<Location> {
  InfiniteScrollLocationManager({
    super.initialItems,
  });

  @override
  Future<void> download(final int pageNumber) async {
    final MaybeError<GetLocationsResult> result =
        await OpenPricesAPIClient.getLocations(
      GetLocationsParameters()
        ..pageNumber = pageNumber
        ..pageSize = 20,
    );
    if (result.isError) {
      throw result.detailError;
    }
    final GetLocationsResult value = result.value;
    addPage(
      items: value.items,
      totalItems: value.total,
      totalPages: value.numberOfPages,
      pageNumber: value.pageNumber,
    );
  }

  @override
  Widget showItem({
    required BuildContext context,
    required Location item,
  }) {
    return const SmoothCard(
      child: ListTile(
        title: Text('TODO'),
        subtitle: Text('this is a location'),
      ),
    );
  }
}
```

What do you think of that?
created_at: 2025-05-04T10:27:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,16 +22,28 @@ class PricesProductsPage extends StatefulWidget {
 
 class _PricesProductsPageState extends State<PricesProductsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetPriceProductsResult>> _products =
-      _showTopProducts();
-
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
   static const int _pageSize = 10;
+  late final InfiniteScrollProductManager _productManager;
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: ```suggestion
  final InfiniteScrollProductManager _productManager =
    InfiniteScrollProductManager(
      initialItems: const <PriceProduct>[],
      orderBy: <OrderBy<GetPriceProductsOrderField>>[
        const OrderBy<GetPriceProductsOrderField>(
          field: GetPriceProductsOrderField.priceCount,
          ascending: false,
        ),
      ],
      pageSize: _pageSize,
    );
```
created_at: 2025-05-04T15:37:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,16 +22,28 @@ class PricesProductsPage extends StatefulWidget {
 
 class _PricesProductsPageState extends State<PricesProductsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetPriceProductsResult>> _products =
-      _showTopProducts();
-
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
   static const int _pageSize = 10;
+  late final InfiniteScrollProductManager _productManager;
 
   @override
-  Widget build(BuildContext context) {
+  void initState() {
+    super.initState();
+    _productManager = InfiniteScrollProductManager(
+      initialItems: const <PriceProduct>[],
+      orderBy: <OrderBy<GetPriceProductsOrderField>>[
+        const OrderBy<GetPriceProductsOrderField>(
+          field: GetPriceProductsOrderField.priceCount,
+          ascending: false,
+        ),
+      ],
+      pageSize: _pageSize,
+    );
+  }
+
+  @override
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-05-04T15:37:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,146 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = List<T>.from(initialItems ?? <T>[]),
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  /// Abstract method to implement the data fetching logic for a specific page
+  /// This method must be implemented by subclasses to handle API calls or data fetching
+  @protected
+  Future<void> fetchData(int pageNumber);
+
+  /// Optional method to implement custom item rendering
+  /// Subclasses can override this to provide custom item display logic
+  @protected
+  Widget buildItem({
+    required BuildContext context,
+    required T item,
+    required int index,
+  }) {
+    // Default implementation returns a simple ListTile
+    // Subclasses should override this with their specific UI
+    return ListTile(
+      title: Text(item.toString()),
+    );
+  }
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: ```suggestion
  });
```
No default implementation.
created_at: 2025-05-04T15:39:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,146 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = List<T>.from(initialItems ?? <T>[]),
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  /// Abstract method to implement the data fetching logic for a specific page
+  /// This method must be implemented by subclasses to handle API calls or data fetching
+  @protected
+  Future<void> fetchData(int pageNumber);
+
+  /// Optional method to implement custom item rendering
+  /// Subclasses can override this to provide custom item display logic
+  @protected
+  Widget buildItem({
+    required BuildContext context,
+    required T item,
+    required int index,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: Please remove `index`, that is never used.
created_at: 2025-05-04T15:41:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,146 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = List<T>.from(initialItems ?? <T>[]),
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  /// Abstract method to implement the data fetching logic for a specific page
+  /// This method must be implemented by subclasses to handle API calls or data fetching
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: Please remove that comment.
_Of course_ it needs to be implemented.
created_at: 2025-05-04T15:41:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,146 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = List<T>.from(initialItems ?? <T>[]),
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  /// Abstract method to implement the data fetching logic for a specific page
+  /// This method must be implemented by subclasses to handle API calls or data fetching
+  @protected
+  Future<void> fetchData(int pageNumber);
+
+  /// Optional method to implement custom item rendering
+  /// Subclasses can override this to provide custom item display logic
+  @protected
+  Widget buildItem({
+    required BuildContext context,
+    required T item,
+    required int index,
+  }) {
+    // Default implementation returns a simple ListTile
+    // Subclasses should override this with their specific UI
+    return ListTile(
+      title: Text(item.toString()),
+    );
+  }
+
+  /// Protected method for subclasses to update the list with new items and pagination info
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: ```suggestion
  /// Updates the list with new items and pagination info
```
`Protected method for subclasses` goes without saying with `@protected`, doesn't it?
created_at: 2025-05-04T15:43:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,146 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = List<T>.from(initialItems ?? <T>[]),
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  /// Abstract method to implement the data fetching logic for a specific page
+  /// This method must be implemented by subclasses to handle API calls or data fetching
+  @protected
+  Future<void> fetchData(int pageNumber);
+
+  /// Optional method to implement custom item rendering
+  /// Subclasses can override this to provide custom item display logic
+  @protected
+  Widget buildItem({
+    required BuildContext context,
+    required T item,
+    required int index,
+  }) {
+    // Default implementation returns a simple ListTile
+    // Subclasses should override this with their specific UI
+    return ListTile(
+      title: Text(item.toString()),
+    );
+  }
+
+  /// Protected method for subclasses to update the list with new items and pagination info
+  @protected
+  void updateItems({
+    required List<T> newItems,
+    required int pageNumber,
+    int? totalItems,
+    int? totalPages,
+  }) {
+    if (pageNumber == _initialPage) {
+      _items.clear();
+    }
+
+    _items.addAll(newItems);
+    _currentPage = pageNumber;
+    _totalItems = totalItems ?? _totalItems;
+    _totalPages = totalPages ?? _totalPages;
+  }
+
+  /// Load initial data only if the list is empty
+  Future<void> loadInitiallyIfNeeded(BuildContext context) async {
+    if (_items.isNotEmpty) {
+      return;
+    }
+    await _load(context: context, pageNumber: _initialPage);
+  }
+
+  /// Load more items (next page)
+  Future<void> loadMore(BuildContext context) async {
+    if (_totalPages != null && _currentPage >= _totalPages!) {
+      return;
+    }
+    await _load(context: context, pageNumber: _currentPage + 1);
+  }
+
+  /// Reload data from the first page
+  Future<void> refresh(BuildContext context) async {
+    await _load(context: context, pageNumber: _initialPage);
+  }
+
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: Please do not put methods that we don't use.
created_at: 2025-05-04T15:44:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +28,89 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final InfiniteScrollPriceManager _priceManager;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
+    final List<Price> initialItems = widget.pricesResult?.items ?? <Price>[];
+
+    _priceManager = InfiniteScrollPriceManager(
+      initialItems: initialItems,
       model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
-      pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
-      },
     );
   }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
   @override
   Widget build(BuildContext context) {
     context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
-    }
+    return InfiniteScrollList<Price>(
+      manager: _priceManager,
+      itemBuilder: (BuildContext context, Price price) =>
+          _buildPriceItem(context, price),
+    );
+  }
 
-    return _ActualList(
-      model: widget.model,
-      result: _productPriceRefresher.pricesResult!,
+  Widget _buildPriceItem(BuildContext context, Price price) {
+    final PriceProduct? priceProduct = price.product;
+
+    return SmoothCard(
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.start,
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: <Widget>[
+          if (widget.model.displayEachProduct && priceProduct != null)
+            PriceProductWidget(
+              priceProduct,
+              enableCountButton: widget.model.enableCountButton,
+            ),
+          PriceDataWidget(
+            price,
+            model: widget.model,
+          ),
+        ],
+      ),
     );
   }
 }
 
-class _ActualList extends StatelessWidget {
-  const _ActualList({
+/// A manager for handling price data with infinite scrolling
+class InfiniteScrollPriceManager extends InfiniteScrollManager<Price> {
+  InfiniteScrollPriceManager({
+    required List<Price> initialItems,
     required this.model,
-    required this.result,
-  });
+  }) : super(initialItems: initialItems);
 
+  /// The model containing price query parameters
   final GetPricesModel model;
-  final GetPricesResult result;
 
   @override
-  Widget build(BuildContext context) {
-    final List<Widget> children = <Widget>[];
-
-    if (!model.displayEachProduct) {
-      // in that case we display the product only once, if possible.
-      for (final Price price in result.items!) {
-        final PriceProduct? priceProduct = price.product;
-        if (priceProduct == null) {
-          continue;
-        }
-        children.add(
-          SmoothCard(
-            child: PriceProductWidget(
-              priceProduct,
-              enableCountButton: model.enableCountButton,
-            ),
-          ),
-        );
-        break;
-      }
-    }
-    if (!model.displayEachLocation) {
-      // in that case we display the location only once, if possible.
-      for (final Price price in result.items!) {
-        final Location? location = price.location;
-        if (location == null) {
-          continue;
-        }
-        children.add(
-          SmoothCard(
-            child: PriceLocationWidget(location),
-          ),
-        );
-        break;
-      }
-    }
+  Future<void> fetchData(int pageNumber) async {
+    final GetPricesParameters parameters = model.parameters;
+    parameters.pageNumber = pageNumber;
 
-    for (final Price price in result.items!) {
-      final PriceProduct? priceProduct = price.product;
-      children.add(
-        SmoothCard(
-          child: Column(
-            mainAxisAlignment: MainAxisAlignment.start,
-            crossAxisAlignment: CrossAxisAlignment.start,
-            children: <Widget>[
-              if (model.displayEachProduct && priceProduct != null)
-                PriceProductWidget(
-                  priceProduct,
-                  enableCountButton: model.enableCountButton,
-                ),
-              PriceDataWidget(
-                price,
-                model: model,
-              ),
-            ],
-          ),
-        ),
-      );
+    final MaybeError<GetPricesResult> result =
+        await OpenPricesAPIClient.getPrices(parameters);
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: You need `uriHelper` too.
created_at: 2025-05-04T15:47:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +28,89 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final InfiniteScrollPriceManager _priceManager;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
+    final List<Price> initialItems = widget.pricesResult?.items ?? <Price>[];
+
+    _priceManager = InfiniteScrollPriceManager(
+      initialItems: initialItems,
       model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
-      pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
-      },
     );
   }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
   @override
   Widget build(BuildContext context) {
     context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
-    }
+    return InfiniteScrollList<Price>(
+      manager: _priceManager,
+      itemBuilder: (BuildContext context, Price price) =>
+          _buildPriceItem(context, price),
+    );
+  }
 
-    return _ActualList(
-      model: widget.model,
-      result: _productPriceRefresher.pricesResult!,
+  Widget _buildPriceItem(BuildContext context, Price price) {
+    final PriceProduct? priceProduct = price.product;
+
+    return SmoothCard(
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.start,
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: <Widget>[
+          if (widget.model.displayEachProduct && priceProduct != null)
+            PriceProductWidget(
+              priceProduct,
+              enableCountButton: widget.model.enableCountButton,
+            ),
+          PriceDataWidget(
+            price,
+            model: widget.model,
+          ),
+        ],
+      ),
     );
   }
 }
 
-class _ActualList extends StatelessWidget {
-  const _ActualList({
+/// A manager for handling price data with infinite scrolling
+class InfiniteScrollPriceManager extends InfiniteScrollManager<Price> {
+  InfiniteScrollPriceManager({
+    required List<Price> initialItems,
     required this.model,
-    required this.result,
-  });
+  }) : super(initialItems: initialItems);
 
+  /// The model containing price query parameters
   final GetPricesModel model;
-  final GetPricesResult result;
 
   @override
-  Widget build(BuildContext context) {
-    final List<Widget> children = <Widget>[];
-
-    if (!model.displayEachProduct) {
-      // in that case we display the product only once, if possible.
-      for (final Price price in result.items!) {
-        final PriceProduct? priceProduct = price.product;
-        if (priceProduct == null) {
-          continue;
-        }
-        children.add(
-          SmoothCard(
-            child: PriceProductWidget(
-              priceProduct,
-              enableCountButton: model.enableCountButton,
-            ),
-          ),
-        );
-        break;
-      }
-    }
-    if (!model.displayEachLocation) {
-      // in that case we display the location only once, if possible.
-      for (final Price price in result.items!) {
-        final Location? location = price.location;
-        if (location == null) {
-          continue;
-        }
-        children.add(
-          SmoothCard(
-            child: PriceLocationWidget(location),
-          ),
-        );
-        break;
-      }
-    }
+  Future<void> fetchData(int pageNumber) async {
+    final GetPricesParameters parameters = model.parameters;
+    parameters.pageNumber = pageNumber;
 
-    for (final Price price in result.items!) {
-      final PriceProduct? priceProduct = price.product;
-      children.add(
-        SmoothCard(
-          child: Column(
-            mainAxisAlignment: MainAxisAlignment.start,
-            crossAxisAlignment: CrossAxisAlignment.start,
-            children: <Widget>[
-              if (model.displayEachProduct && priceProduct != null)
-                PriceProductWidget(
-                  priceProduct,
-                  enableCountButton: model.enableCountButton,
-                ),
-              PriceDataWidget(
-                price,
-                model: model,
-              ),
-            ],
-          ),
-        ),
-      );
+    final MaybeError<GetPricesResult> result =
+        await OpenPricesAPIClient.getPrices(parameters);
+
+    if (result.isError) {
+      throw result.detailError;
     }
-    final AppLocalizations appLocalizations = AppLocalizations.of(context);
-    final String title =
-        result.numberOfPages != null && result.numberOfPages! <= 1
-            ? appLocalizations.prices_list_length_one_page(
-                result.items!.length,
-              )
-            : appLocalizations.prices_list_length_many_pages(
-                model.parameters.pageSize!,
-                result.total!,
-              );
-    children.insert(
-      0,
-      SmoothCard(child: ListTile(title: Text(title))),
-    );
-    // so that the last content gets not hidden by the FAB
-    children.add(
-      const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-    );
-    return ListView(
-      children: children,
+
+    final GetPricesResult value = result.value;
+    updateItems(
+      newItems: value.items ?? <Price>[],
+      pageNumber: value.pageNumber ?? pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
     );
   }
+
+  @override
+  Widget buildItem({
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: Obviously something doesn't work here: I expected to see the code of `_buildPriceItem` here. What's the point of this `SizedBox`?
created_at: 2025-05-04T15:48:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,171 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/generic_lib/widgets/smooth_card.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_manager.dart';
+
+/// A generic stateful widget for infinite scrolling lists that works with InfiniteScrollManager.
+class InfiniteScrollList<T> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.manager,
+    required this.itemBuilder,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: No, the `itemBuilder` is provided by the `manager`.

created_at: 2025-05-04T15:49:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,171 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/generic_lib/widgets/smooth_card.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_manager.dart';
+
+/// A generic stateful widget for infinite scrolling lists that works with InfiniteScrollManager.
+class InfiniteScrollList<T> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.manager,
+    required this.itemBuilder,
+    super.key,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: No, we don't use `key`.
created_at: 2025-05-04T15:50:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,117 +59,94 @@ class _PricesLocationsPageState extends State<PricesLocationsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetLocationsResult>>(
-        future: _locations,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetLocationsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetLocationsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-
-          for (final Location item in result.items!) {
-            final int priceCount = item.priceCount ?? 0;
-            children.add(
-              SmoothCard(
-                child: Wrap(
-                  spacing: VERY_SMALL_SPACE,
-                  children: <Widget>[
-                    PriceLocationWidget(item),
-                    PriceCountWidget(
-                      count: priceCount,
-                      onPressed: () async =>
-                          PriceLocationWidget.showLocationPrices(
-                        locationId: item.locationId,
-                        context: context,
-                      ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.userCount}',
-                      iconData: PriceButton.userIconData,
-                      tooltip: item.userCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_user(
-                              item.userCount!,
-                            ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.productCount}',
-                      iconData: PriceButton.productIconData,
-                      tooltip: item.productCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_product(
-                              item.productCount!,
-                            ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.proofCount}',
-                      iconData: PriceButton.proofIconData,
-                      tooltip: item.proofCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_proof(
-                              item.proofCount!,
-                            ),
-                    ),
-                  ],
-                ),
-              ),
-            );
-          }
-          final String title =
-              appLocalizations.prices_locations_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<Location>(
+        manager: _locationManager,
+        itemBuilder: (BuildContext context, Location item) =>
+            _locationManager.buildItem(context: context, item: item, index: 0),
       ),
     );
   }
+}
+
+/// A manager for handling location data with infinite scrolling
+class InfiniteScrollLocationManager extends InfiniteScrollManager<Location> {
+  InfiniteScrollLocationManager({
+    super.initialItems,
+  });
+
+  @override
+  Future<void> fetchData(final int pageNumber) async {
+    final MaybeError<GetLocationsResult> result =
+        await OpenPricesAPIClient.getLocations(
+      GetLocationsParameters()
+        ..pageNumber = pageNumber
+        ..pageSize = 10,
+    );
+    if (result.isError) {
+      throw result.detailError;
+    }
+    final GetLocationsResult value = result.value;
+    updateItems(
+      newItems: value.items ?? <Location>[],
+      pageNumber: value.pageNumber ?? pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
+    );
path: packages/smooth_app/lib/pages/prices/prices_locations_page.dart
user: login: monsieurtanuki
body: ```suggestion
    updateItems(
      newItems: value.items,
      pageNumber: value.pageNumber,
      totalItems: value.total,
      totalPages: value.numberOfPages,
    );
```
I would let `updateItems` handle the unexpected cases of null input data.
created_at: 2025-05-04T15:51:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +28,89 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final InfiniteScrollPriceManager _priceManager;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
+    final List<Price> initialItems = widget.pricesResult?.items ?? <Price>[];
+
+    _priceManager = InfiniteScrollPriceManager(
+      initialItems: initialItems,
       model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
-      pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
-      },
     );
   }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
   @override
   Widget build(BuildContext context) {
     context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
-    }
+    return InfiniteScrollList<Price>(
+      manager: _priceManager,
+      itemBuilder: (BuildContext context, Price price) =>
+          _buildPriceItem(context, price),
+    );
+  }
 
-    return _ActualList(
-      model: widget.model,
-      result: _productPriceRefresher.pricesResult!,
+  Widget _buildPriceItem(BuildContext context, Price price) {
+    final PriceProduct? priceProduct = price.product;
+
+    return SmoothCard(
+      child: Column(
+        mainAxisAlignment: MainAxisAlignment.start,
+        crossAxisAlignment: CrossAxisAlignment.start,
+        children: <Widget>[
+          if (widget.model.displayEachProduct && priceProduct != null)
+            PriceProductWidget(
+              priceProduct,
+              enableCountButton: widget.model.enableCountButton,
+            ),
+          PriceDataWidget(
+            price,
+            model: widget.model,
+          ),
+        ],
+      ),
     );
   }
 }
 
-class _ActualList extends StatelessWidget {
-  const _ActualList({
+/// A manager for handling price data with infinite scrolling
+class InfiniteScrollPriceManager extends InfiniteScrollManager<Price> {
+  InfiniteScrollPriceManager({
+    required List<Price> initialItems,
     required this.model,
-    required this.result,
-  });
+  }) : super(initialItems: initialItems);
 
+  /// The model containing price query parameters
   final GetPricesModel model;
-  final GetPricesResult result;
 
   @override
-  Widget build(BuildContext context) {
-    final List<Widget> children = <Widget>[];
-
-    if (!model.displayEachProduct) {
-      // in that case we display the product only once, if possible.
-      for (final Price price in result.items!) {
-        final PriceProduct? priceProduct = price.product;
-        if (priceProduct == null) {
-          continue;
-        }
-        children.add(
-          SmoothCard(
-            child: PriceProductWidget(
-              priceProduct,
-              enableCountButton: model.enableCountButton,
-            ),
-          ),
-        );
-        break;
-      }
-    }
-    if (!model.displayEachLocation) {
-      // in that case we display the location only once, if possible.
-      for (final Price price in result.items!) {
-        final Location? location = price.location;
-        if (location == null) {
-          continue;
-        }
-        children.add(
-          SmoothCard(
-            child: PriceLocationWidget(location),
-          ),
-        );
-        break;
-      }
-    }
+  Future<void> fetchData(int pageNumber) async {
+    final GetPricesParameters parameters = model.parameters;
+    parameters.pageNumber = pageNumber;
 
-    for (final Price price in result.items!) {
-      final PriceProduct? priceProduct = price.product;
-      children.add(
-        SmoothCard(
-          child: Column(
-            mainAxisAlignment: MainAxisAlignment.start,
-            crossAxisAlignment: CrossAxisAlignment.start,
-            children: <Widget>[
-              if (model.displayEachProduct && priceProduct != null)
-                PriceProductWidget(
-                  priceProduct,
-                  enableCountButton: model.enableCountButton,
-                ),
-              PriceDataWidget(
-                price,
-                model: model,
-              ),
-            ],
-          ),
-        ),
-      );
+    final MaybeError<GetPricesResult> result =
+        await OpenPricesAPIClient.getPrices(parameters);
+
+    if (result.isError) {
+      throw result.detailError;
     }
-    final AppLocalizations appLocalizations = AppLocalizations.of(context);
-    final String title =
-        result.numberOfPages != null && result.numberOfPages! <= 1
-            ? appLocalizations.prices_list_length_one_page(
-                result.items!.length,
-              )
-            : appLocalizations.prices_list_length_many_pages(
-                model.parameters.pageSize!,
-                result.total!,
-              );
-    children.insert(
-      0,
-      SmoothCard(child: ListTile(title: Text(title))),
-    );
-    // so that the last content gets not hidden by the FAB
-    children.add(
-      const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-    );
-    return ListView(
-      children: children,
+
+    final GetPricesResult value = result.value;
+    updateItems(
+      newItems: value.items ?? <Price>[],
+      pageNumber: value.pageNumber ?? pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: ```suggestion
    updateItems(
      newItems: value.items,
      pageNumber: value.pageNumber,
      totalItems: value.total,
      totalPages: value.numberOfPages,
```
created_at: 2025-05-04T15:52:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,146 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = List<T>.from(initialItems ?? <T>[]),
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: ```suggestion
  })  : _items = initialItems ?? <T>[],
```
created_at: 2025-05-04T15:54:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,16 +25,20 @@ class PricesLocationsPage extends StatefulWidget {
 
 class _PricesLocationsPageState extends State<PricesLocationsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetLocationsResult>> _locations =
-      _showTopLocations();
+  late final InfiniteScrollLocationManager _locationManager;
path: packages/smooth_app/lib/pages/prices/prices_locations_page.dart
user: login: monsieurtanuki
body: ```suggestion
  final InfiniteScrollLocationManager _locationManager =
    InfiniteScrollLocationManager();
```
created_at: 2025-05-04T15:55:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,16 +25,20 @@ class PricesLocationsPage extends StatefulWidget {
 
 class _PricesLocationsPageState extends State<PricesLocationsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetLocationsResult>> _locations =
-      _showTopLocations();
+  late final InfiniteScrollLocationManager _locationManager;
 
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
-  static const int _pageSize = 10;
+  @override
+  void initState() {
+    super.initState();
+    _locationManager = InfiniteScrollLocationManager(
+      initialItems: const <Location>[],
+    );
+  }
path: packages/smooth_app/lib/pages/prices/prices_locations_page.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-05-04T15:55:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,117 +59,94 @@ class _PricesLocationsPageState extends State<PricesLocationsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetLocationsResult>>(
-        future: _locations,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetLocationsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetLocationsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-
-          for (final Location item in result.items!) {
-            final int priceCount = item.priceCount ?? 0;
-            children.add(
-              SmoothCard(
-                child: Wrap(
-                  spacing: VERY_SMALL_SPACE,
-                  children: <Widget>[
-                    PriceLocationWidget(item),
-                    PriceCountWidget(
-                      count: priceCount,
-                      onPressed: () async =>
-                          PriceLocationWidget.showLocationPrices(
-                        locationId: item.locationId,
-                        context: context,
-                      ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.userCount}',
-                      iconData: PriceButton.userIconData,
-                      tooltip: item.userCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_user(
-                              item.userCount!,
-                            ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.productCount}',
-                      iconData: PriceButton.productIconData,
-                      tooltip: item.productCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_product(
-                              item.productCount!,
-                            ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.proofCount}',
-                      iconData: PriceButton.proofIconData,
-                      tooltip: item.proofCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_proof(
-                              item.proofCount!,
-                            ),
-                    ),
-                  ],
-                ),
-              ),
-            );
-          }
-          final String title =
-              appLocalizations.prices_locations_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<Location>(
+        manager: _locationManager,
+        itemBuilder: (BuildContext context, Location item) =>
+            _locationManager.buildItem(context: context, item: item, index: 0),
path: packages/smooth_app/lib/pages/prices/prices_locations_page.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-05-04T15:56:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,117 +59,94 @@ class _PricesLocationsPageState extends State<PricesLocationsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetLocationsResult>>(
-        future: _locations,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetLocationsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetLocationsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-
-          for (final Location item in result.items!) {
-            final int priceCount = item.priceCount ?? 0;
-            children.add(
-              SmoothCard(
-                child: Wrap(
-                  spacing: VERY_SMALL_SPACE,
-                  children: <Widget>[
-                    PriceLocationWidget(item),
-                    PriceCountWidget(
-                      count: priceCount,
-                      onPressed: () async =>
-                          PriceLocationWidget.showLocationPrices(
-                        locationId: item.locationId,
-                        context: context,
-                      ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.userCount}',
-                      iconData: PriceButton.userIconData,
-                      tooltip: item.userCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_user(
-                              item.userCount!,
-                            ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.productCount}',
-                      iconData: PriceButton.productIconData,
-                      tooltip: item.productCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_product(
-                              item.productCount!,
-                            ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.proofCount}',
-                      iconData: PriceButton.proofIconData,
-                      tooltip: item.proofCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_proof(
-                              item.proofCount!,
-                            ),
-                    ),
-                  ],
-                ),
-              ),
-            );
-          }
-          final String title =
-              appLocalizations.prices_locations_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<Location>(
+        manager: _locationManager,
+        itemBuilder: (BuildContext context, Location item) =>
+            _locationManager.buildItem(context: context, item: item, index: 0),
       ),
     );
   }
+}
+
+/// A manager for handling location data with infinite scrolling
+class InfiniteScrollLocationManager extends InfiniteScrollManager<Location> {
+  InfiniteScrollLocationManager({
+    super.initialItems,
+  });
+
+  @override
+  Future<void> fetchData(final int pageNumber) async {
+    final MaybeError<GetLocationsResult> result =
+        await OpenPricesAPIClient.getLocations(
+      GetLocationsParameters()
+        ..pageNumber = pageNumber
+        ..pageSize = 10,
+    );
+    if (result.isError) {
+      throw result.detailError;
+    }
+    final GetLocationsResult value = result.value;
+    updateItems(
+      newItems: value.items ?? <Location>[],
+      pageNumber: value.pageNumber ?? pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
path: packages/smooth_app/lib/pages/prices/prices_locations_page.dart
user: login: monsieurtanuki
body: ```suggestion
      newItems: value.items,
      pageNumber: value.pageNumber,
      totalItems: value.total,
      totalPages: value.numberOfPages,
```
created_at: 2025-05-04T15:56:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -53,117 +59,94 @@ class _PricesLocationsPageState extends State<PricesLocationsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetLocationsResult>>(
-        future: _locations,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetLocationsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetLocationsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-
-          for (final Location item in result.items!) {
-            final int priceCount = item.priceCount ?? 0;
-            children.add(
-              SmoothCard(
-                child: Wrap(
-                  spacing: VERY_SMALL_SPACE,
-                  children: <Widget>[
-                    PriceLocationWidget(item),
-                    PriceCountWidget(
-                      count: priceCount,
-                      onPressed: () async =>
-                          PriceLocationWidget.showLocationPrices(
-                        locationId: item.locationId,
-                        context: context,
-                      ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.userCount}',
-                      iconData: PriceButton.userIconData,
-                      tooltip: item.userCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_user(
-                              item.userCount!,
-                            ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.productCount}',
-                      iconData: PriceButton.productIconData,
-                      tooltip: item.productCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_product(
-                              item.productCount!,
-                            ),
-                    ),
-                    PriceButton(
-                      onPressed: () {},
-                      title: '${item.proofCount}',
-                      iconData: PriceButton.proofIconData,
-                      tooltip: item.proofCount == null
-                          ? null
-                          : appLocalizations.prices_button_count_proof(
-                              item.proofCount!,
-                            ),
-                    ),
-                  ],
-                ),
-              ),
-            );
-          }
-          final String title =
-              appLocalizations.prices_locations_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<Location>(
+        manager: _locationManager,
+        itemBuilder: (BuildContext context, Location item) =>
+            _locationManager.buildItem(context: context, item: item, index: 0),
       ),
     );
   }
+}
+
+/// A manager for handling location data with infinite scrolling
+class InfiniteScrollLocationManager extends InfiniteScrollManager<Location> {
+  InfiniteScrollLocationManager({
+    super.initialItems,
+  });
+
+  @override
+  Future<void> fetchData(final int pageNumber) async {
+    final MaybeError<GetLocationsResult> result =
+        await OpenPricesAPIClient.getLocations(
+      GetLocationsParameters()
+        ..pageNumber = pageNumber
+        ..pageSize = 10,
+    );
+    if (result.isError) {
+      throw result.detailError;
+    }
+    final GetLocationsResult value = result.value;
+    updateItems(
+      newItems: value.items ?? <Location>[],
+      pageNumber: value.pageNumber ?? pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
+    );
+  }
 
-  static Future<MaybeError<GetLocationsResult>> _showTopLocations() async =>
-      OpenPricesAPIClient.getLocations(
-        GetLocationsParameters()
-          ..orderBy = <OrderBy<GetLocationsOrderField>>[
-            const OrderBy<GetLocationsOrderField>(
-              field: GetLocationsOrderField.priceCount,
-              ascending: false,
+  @override
+  Widget buildItem({
+    required BuildContext context,
+    required Location item,
+    required int index,
path: packages/smooth_app/lib/pages/prices/prices_locations_page.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-05-04T15:57:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,76 +64,65 @@ class _PricesProductsPageState extends State<PricesProductsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetPriceProductsResult>>(
-        future: _products,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetPriceProductsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetPriceProductsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-
-          for (final PriceProduct item in result.items!) {
-            children.add(
-              SmoothCard(
-                child: PriceProductWidget(
-                  item,
-                  enableCountButton: true,
-                ),
-              ),
-            );
-          }
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title =
-              appLocalizations.prices_products_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
+      body: InfiniteScrollList<PriceProduct>(
+        manager: _productManager,
+        itemBuilder: (BuildContext context, PriceProduct product) {
+          return SmoothCard(
+            child: PriceProductWidget(
+              product,
+              enableCountButton: true,
+            ),
           );
         },
       ),
     );
   }
+}
 
-  static Future<MaybeError<GetPriceProductsResult>> _showTopProducts() async =>
-      OpenPricesAPIClient.getPriceProducts(
-        GetPriceProductsParameters()
-          ..orderBy = <OrderBy<GetPriceProductsOrderField>>[
-            const OrderBy<GetPriceProductsOrderField>(
-              field: GetPriceProductsOrderField.priceCount,
-              ascending: false,
-            ),
-          ]
-          ..pageSize = _pageSize
-          ..pageNumber = 1,
-        uriHelper: ProductQuery.uriPricesHelper,
-      );
+/// A manager for handling product data with infinite scrolling
+class InfiniteScrollProductManager extends InfiniteScrollManager<PriceProduct> {
+  InfiniteScrollProductManager({
+    super.initialItems,
+    required this.orderBy,
+    required this.pageSize,
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: What's the point? They always have the same values, don't they?
created_at: 2025-05-04T15:58:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,15 +31,94 @@ class PricesProofsPage extends StatefulWidget {
 
 class _PricesProofsPageState extends State<PricesProofsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetProofsResult>> _results = _download();
-
   static const int _columns = 3;
   static const int _rows = 5;
   static const int _pageSize = _columns * _rows;
 
+  late final InfiniteScrollProofManager _proofManager;
+  String? _bearerToken;
+  final ScrollController _gridScrollController = ScrollController();
+
+  @override
+  void initState() {
+    super.initState();
+    _proofManager = InfiniteScrollProofManager(
+      initialItems: const <Proof>[],
+      bearerTokenCallback: () => _bearerToken,
+      onAuthenticateNeeded: _authenticate,
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: Not sure it's the page's job to deal with the token, but let's focus on the easier classes first.
created_at: 2025-05-04T16:00:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,149 +139,182 @@ class _PricesProofsPageState extends State<PricesProofsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetProofsResult>>(
-        future: _results,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetProofsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetProofsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final double squareSize = MediaQuery.sizeOf(context).width / _columns;
-
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title = result.numberOfPages == 1
-              ? appLocalizations.prices_proofs_list_length_one_page(
-                  result.items!.length,
-                )
-              : appLocalizations.prices_proofs_list_length_many_pages(
-                  _pageSize,
-                  result.total!,
-                );
-          return Column(
-            children: <Widget>[
-              SmoothCard(
-                child: ListTile(
-                  title: Text(title),
-                ),
+      body: _bearerToken == null
+          ? // Show loading while authenticating
+          const Center(child: CircularProgressIndicator())
+          : // Show content once authenticated
+          _buildProofsContent(context, appLocalizations),
+    );
+  }
+
+  Widget _buildProofsContent(
+    BuildContext context,
+    AppLocalizations appLocalizations,
+  ) {
+    return Column(
+      children: <Widget>[
+        Builder(
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: I don't get the point of the `Builder` layer.
Please comment it or get rid of it.
created_at: 2025-05-04T16:01:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,149 +139,182 @@ class _PricesProofsPageState extends State<PricesProofsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetProofsResult>>(
-        future: _results,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetProofsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetProofsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final double squareSize = MediaQuery.sizeOf(context).width / _columns;
-
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title = result.numberOfPages == 1
-              ? appLocalizations.prices_proofs_list_length_one_page(
-                  result.items!.length,
-                )
-              : appLocalizations.prices_proofs_list_length_many_pages(
-                  _pageSize,
-                  result.total!,
-                );
-          return Column(
-            children: <Widget>[
-              SmoothCard(
-                child: ListTile(
-                  title: Text(title),
-                ),
+      body: _bearerToken == null
+          ? // Show loading while authenticating
+          const Center(child: CircularProgressIndicator())
+          : // Show content once authenticated
+          _buildProofsContent(context, appLocalizations),
+    );
+  }
+
+  Widget _buildProofsContent(
+    BuildContext context,
+    AppLocalizations appLocalizations,
+  ) {
+    return Column(
+      children: <Widget>[
+        Builder(
+          builder: (BuildContext context) {
+            final int totalItems = _proofManager.totalItems ?? 0;
+            final int totalPages = _proofManager.totalPages ?? 1;
+            final int itemCount = _proofManager.items.length;
+
+            final String title = totalPages <= 1
+                ? appLocalizations.prices_proofs_list_length_one_page(itemCount)
+                : appLocalizations.prices_proofs_list_length_many_pages(
+                    _pageSize,
+                    totalItems,
+                  );
+
+            return SmoothCard(
+              child: ListTile(
+                title: Text(title),
               ),
-              if (result.items!.isNotEmpty)
-                Expanded(
-                  child: CustomScrollView(
-                    slivers: <Widget>[
-                      SliverGrid(
-                        gridDelegate:
-                            const SliverGridDelegateWithFixedCrossAxisCount(
-                          crossAxisCount: _columns,
-                        ),
-                        delegate: SliverChildBuilderDelegate(
-                          (
-                            final BuildContext context,
-                            final int index,
-                          ) {
-                            final Proof proof = result.items![index];
-                            if (proof.filePath == null) {
-                              // highly improbable
-                              return SizedBox(
-                                width: squareSize,
-                                height: squareSize,
-                              );
-                            }
-                            return InkWell(
-                              onTap: () async {
-                                if (widget.selectProof) {
-                                  Navigator.of(context).pop(proof);
-                                  return;
-                                }
-                                return Navigator.push<void>(
-                                  context,
-                                  MaterialPageRoute<void>(
-                                    builder: (BuildContext context) =>
-                                        PriceProofPage(
-                                      proof,
-                                    ),
-                                  ),
-                                );
-                              }, // PriceProofPage
-                              child: _PriceProofImage(proof,
-                                  squareSize: squareSize),
-                            );
-                          },
-                          addAutomaticKeepAlives: false,
-                          childCount: result.items!.length,
-                        ),
-                      ),
-                    ],
-                  ),
-                ),
-            ],
-          );
-        },
-      ),
+            );
+          },
+        ),
+        Expanded(
+          child: _buildProofsGrid(context, appLocalizations),
+        ),
+      ],
     );
   }
 
-  static Future<MaybeError<GetProofsResult>> _download() async {
-    final User user = ProductQuery.getWriteUser();
-    final MaybeError<String> token =
-        await OpenPricesAPIClient.getAuthenticationToken(
-      username: user.userId,
-      password: user.password,
-      uriHelper: ProductQuery.uriPricesHelper,
+  Widget _buildProofsGrid(
+    BuildContext context,
+    AppLocalizations appLocalizations,
+  ) {
+    if (_proofManager.items.isEmpty) {
+      if (_proofManager.isLoading) {
+        return const Center(child: CircularProgressIndicator());
+      } else {
+        return const Center(child: Text('No Result'));
+      }
+    }
+
+    return CustomScrollView(
+      controller: _gridScrollController,
+      slivers: <Widget>[
+        SliverGrid(
+          gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+            crossAxisCount: _columns,
+          ),
+          delegate: SliverChildBuilderDelegate(
+            (BuildContext context, int index) {
+              final Proof proof = _proofManager.items[index];
+              final double squareSize =
+                  MediaQuery.of(context).size.width / _columns;
+
+              if (proof.filePath == null) {
+                return SizedBox(
+                  width: squareSize,
+                  height: squareSize,
+                );
+              }
+
+              return InkWell(
+                onTap: () async {
+                  if (widget.selectProof) {
+                    Navigator.of(context).pop(proof);
+                    return;
+                  }
+                  return Navigator.push<void>(
+                    context,
+                    MaterialPageRoute<void>(
+                      builder: (BuildContext context) => PriceProofPage(proof),
+                    ),
+                  );
+                },
+                child: _PriceProofImage(proof, squareSize: squareSize),
+              );
+            },
+            childCount: _proofManager.items.length,
+            addAutomaticKeepAlives: false,
+          ),
+        ),
+        if (_proofManager.isLoading)
+          const SliverToBoxAdapter(
+            child: Padding(
+              padding: EdgeInsets.all(16.0),
+              child: Center(child: CircularProgressIndicator()),
+            ),
+          ),
+      ],
     );
+  }
+}
 
-    if (token.isError) {
-      return MaybeError<GetProofsResult>.error(
-        error: token.error ?? 'Could not authenticate with the server',
-        statusCode: token.statusCode ?? 500,
-      );
+/// A manager for handling proof data with infinite scrolling
+class InfiniteScrollProofManager extends InfiniteScrollManager<Proof> {
+  InfiniteScrollProofManager({
+    required super.initialItems,
+    required this.bearerTokenCallback,
+    required this.onAuthenticateNeeded,
+    required this.pageSize,
+  });
+
+  /// Callback to get the bearer token for API requests
+  final String? Function() bearerTokenCallback;
+
+  /// Callback to authenticate when token is not available
+  final Future<void> Function() onAuthenticateNeeded;
+
+  /// Number of items per page
+  final int pageSize;
+
+  @override
+  Future<void> fetchData(final int pageNumber) async {
+    final String? bearerToken = bearerTokenCallback();
+    if (bearerToken == null) {
+      await onAuthenticateNeeded();
+      if (bearerTokenCallback() == null) {
+        throw Exception('Authentication failed');
+      }
     }
 
-    final String bearerToken = token.value;
+    final User user = ProductQuery.getWriteUser();
+
+    final GetProofsParameters parameters = GetProofsParameters()
+      ..orderBy = <OrderBy<GetProofsOrderField>>[
+        const OrderBy<GetProofsOrderField>(
+          field: GetProofsOrderField.created,
+          ascending: false,
+        ),
+      ]
+      ..pageSize = pageSize
+      ..owner = user.userId
+      ..pageNumber = pageNumber;
 
     final MaybeError<GetProofsResult> result =
         await OpenPricesAPIClient.getProofs(
-      GetProofsParameters()
-        ..orderBy = <OrderBy<GetProofsOrderField>>[
-          const OrderBy<GetProofsOrderField>(
-            field: GetProofsOrderField.created,
-            ascending: false,
-          ),
-        ]
-        ..owner = user.userId
-        ..pageSize = _pageSize
-        ..pageNumber = 1,
+      parameters,
       uriHelper: ProductQuery.uriPricesHelper,
-      bearerToken: bearerToken,
+      bearerToken: bearerTokenCallback()!,
     );
 
-    await OpenPricesAPIClient.deleteUserSession(
-      uriHelper: ProductQuery.uriPricesHelper,
-      bearerToken: bearerToken,
+    if (result.isError) {
+      throw result.detailError;
+    }
+
+    final GetProofsResult value = result.value;
+    updateItems(
+      newItems: value.items ?? <Proof>[],
+      pageNumber: value.pageNumber ?? pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
     );
+  }
 
-    return result;
+  @override
+  Widget buildItem({
+    required BuildContext context,
+    required Proof item,
+    required int index,
+  }) {
+    return const SizedBox();
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: Nonsense.
created_at: 2025-05-04T16:02:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +28,89 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final InfiniteScrollPriceManager _priceManager;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
+    final List<Price> initialItems = widget.pricesResult?.items ?? <Price>[];
+
+    _priceManager = InfiniteScrollPriceManager(
+      initialItems: initialItems,
       model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
-      pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
-      },
     );
   }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
   @override
   Widget build(BuildContext context) {
     context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
-    }
+    return InfiniteScrollList<Price>(
+      manager: _priceManager,
+      itemBuilder: (BuildContext context, Price price) =>
+          _buildPriceItem(context, price),
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-05-04T16:03:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,169 @@
+import 'dart:async';
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+import 'package:smooth_app/generic_lib/design_constants.dart';
+import 'package:smooth_app/generic_lib/widgets/smooth_card.dart';
+import 'package:smooth_app/pages/prices/infinite_scroll_manager.dart';
+
+/// A generic stateful widget for infinite scrolling lists that works with InfiniteScrollManager.
+class InfiniteScrollList<T> extends StatefulWidget {
+  const InfiniteScrollList({
+    required this.manager,
+  });
+
+  /// Manager for handling the infinite scroll behavior
+  final InfiniteScrollManager<T> manager;
+
+  @override
+  State<InfiniteScrollList<T>> createState() => _InfiniteScrollListState<T>();
+}
+
+class _InfiniteScrollListState<T> extends State<InfiniteScrollList<T>> {
+  static const double _loadMoreTriggerOffset = 200.0;
+
+  late final ScrollController _scrollController;
+  Object? _error;
+  bool _isInitialLoading = false;
+
+  @override
+  void initState() {
+    super.initState();
+    _scrollController = ScrollController();
+    _scrollController.addListener(_scrollListener);
+    unawaited(_initialLoad());
+  }
+
+  @override
+  void dispose() {
+    _scrollController.removeListener(_scrollListener);
+    _scrollController.dispose();
+    super.dispose();
+  }
+
+  Future<void> _initialLoad() async {
+    setState(() {
+      _isInitialLoading = true;
+      _error = null;
+    });
+
+    try {
+      await widget.manager.loadInitiallyIfNeeded(context);
+    } catch (e) {
+      _error = e;
+    } finally {
+      if (mounted) {
+        setState(() {
+          _isInitialLoading = false;
+        });
+      }
+    }
+  }
+
+  void _scrollListener() {
+    if (widget.manager.isLoading ||
+        !(widget.manager.totalPages == null ||
+            widget.manager.currentPage < widget.manager.totalPages!)) {
+      return;
+    }
path: packages/smooth_app/lib/pages/prices/infinite_scroll_list.dart
user: login: monsieurtanuki
body: Should be a manager method.
created_at: 2025-05-05T13:16:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,137 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = initialItems ?? <T>[],
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  /// Abstract method to implement the data fetching logic for a specific page
+  @protected
+  Future<void> fetchData(int pageNumber);
+
+  /// Optional method to implement custom item rendering
+  /// Subclasses can override this to provide custom item display logic
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-05-05T13:17:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,16 +22,23 @@ class PricesProductsPage extends StatefulWidget {
 
 class _PricesProductsPageState extends State<PricesProductsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetPriceProductsResult>> _products =
-      _showTopProducts();
+  late final InfiniteScrollProductManager _productManager =
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: ```suggestion
  final InfiniteScrollProductManager _productManager =
```
created_at: 2025-05-05T13:19:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,16 +22,23 @@ class PricesProductsPage extends StatefulWidget {
 
 class _PricesProductsPageState extends State<PricesProductsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetPriceProductsResult>> _products =
-      _showTopProducts();
+  late final InfiniteScrollProductManager _productManager =
+      InfiniteScrollProductManager(
+    initialItems: <PriceProduct>[],
+  );
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: ```suggestion
      InfiniteScrollProductManager();
```
created_at: 2025-05-05T13:19:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -21,16 +22,23 @@ class PricesProductsPage extends StatefulWidget {
 
 class _PricesProductsPageState extends State<PricesProductsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetPriceProductsResult>> _products =
-      _showTopProducts();
+  late final InfiniteScrollProductManager _productManager =
+      InfiniteScrollProductManager(
+    initialItems: <PriceProduct>[],
+  );
 
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
-  static const int _pageSize = 10;
+  @override
+  void initState() {
+    super.initState();
+    WidgetsBinding.instance.addPostFrameCallback((_) {
+      _productManager.fetchData(1);
+    });
+  }
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-05-05T13:23:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,76 +59,62 @@ class _PricesProductsPageState extends State<PricesProductsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetPriceProductsResult>>(
-        future: _products,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetPriceProductsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetPriceProductsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-
-          for (final PriceProduct item in result.items!) {
-            children.add(
-              SmoothCard(
-                child: PriceProductWidget(
-                  item,
-                  enableCountButton: true,
-                ),
-              ),
-            );
-          }
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title =
-              appLocalizations.prices_products_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<PriceProduct>(
+        manager: _productManager,
       ),
     );
   }
+}
 
-  static Future<MaybeError<GetPriceProductsResult>> _showTopProducts() async =>
-      OpenPricesAPIClient.getPriceProducts(
-        GetPriceProductsParameters()
-          ..orderBy = <OrderBy<GetPriceProductsOrderField>>[
-            const OrderBy<GetPriceProductsOrderField>(
-              field: GetPriceProductsOrderField.priceCount,
-              ascending: false,
-            ),
-          ]
-          ..pageSize = _pageSize
-          ..pageNumber = 1,
-        uriHelper: ProductQuery.uriPricesHelper,
-      );
+/// A manager for handling product data with infinite scrolling
+class InfiniteScrollProductManager extends InfiniteScrollManager<PriceProduct> {
+  InfiniteScrollProductManager({
+    required super.initialItems,
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: ```suggestion
    super.initialItems,
```
created_at: 2025-05-05T13:23:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,76 +59,62 @@ class _PricesProductsPageState extends State<PricesProductsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetPriceProductsResult>>(
-        future: _products,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetPriceProductsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetPriceProductsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-
-          for (final PriceProduct item in result.items!) {
-            children.add(
-              SmoothCard(
-                child: PriceProductWidget(
-                  item,
-                  enableCountButton: true,
-                ),
-              ),
-            );
-          }
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title =
-              appLocalizations.prices_products_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<PriceProduct>(
+        manager: _productManager,
       ),
     );
   }
+}
 
-  static Future<MaybeError<GetPriceProductsResult>> _showTopProducts() async =>
-      OpenPricesAPIClient.getPriceProducts(
-        GetPriceProductsParameters()
-          ..orderBy = <OrderBy<GetPriceProductsOrderField>>[
-            const OrderBy<GetPriceProductsOrderField>(
-              field: GetPriceProductsOrderField.priceCount,
-              ascending: false,
-            ),
-          ]
-          ..pageSize = _pageSize
-          ..pageNumber = 1,
-        uriHelper: ProductQuery.uriPricesHelper,
-      );
+/// A manager for handling product data with infinite scrolling
+class InfiniteScrollProductManager extends InfiniteScrollManager<PriceProduct> {
+  InfiniteScrollProductManager({
+    required super.initialItems,
+  });
+
+  static const int _pageSize = 10;
+  static const List<OrderBy<GetPriceProductsOrderField>> orderBy =
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: Private.
created_at: 2025-05-05T13:25:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -22,15 +24,45 @@ class PricesUsersPage extends StatefulWidget {
 
 class _PricesUsersPageState extends State<PricesUsersPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetUsersResult>> _users = _showTopUsers();
-
-  // In this specific page, let's never try to go beyond the top 10.
-  // cf. https://github.com/openfoodfacts/smooth-app/pull/5383#issuecomment-2171117141
   static const int _pageSize = 10;
 
+  final InfiniteScrollUserManager _userManager = InfiniteScrollUserManager(
+    initialItems: <PriceUser>[], // Use non-const empty list
+    pageSize: _pageSize,
+    itemBuilder: (BuildContext context, PriceUser user) {
path: packages/smooth_app/lib/pages/prices/prices_users_page.dart
user: login: monsieurtanuki
body: What the hell?
created_at: 2025-05-05T13:25:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +29,81 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final InfiniteScrollPriceManager _priceManager;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
+    _priceManager = InfiniteScrollPriceManager(
+      initialItems: widget.pricesResult?.items ?? <Price>[],
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: ```suggestion
      initialItems: widget.pricesResult?.items,
```
created_at: 2025-05-06T11:43:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +29,81 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final InfiniteScrollPriceManager _priceManager;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
+    _priceManager = InfiniteScrollPriceManager(
+      initialItems: widget.pricesResult?.items ?? <Price>[],
       model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
-      pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
-      },
     );
   }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
   @override
   Widget build(BuildContext context) {
     context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
-    }
-
-    return _ActualList(
-      model: widget.model,
-      result: _productPriceRefresher.pricesResult!,
+    return InfiniteScrollList<Price>(
+      manager: _priceManager,
     );
   }
 }
 
-class _ActualList extends StatelessWidget {
-  const _ActualList({
+/// A manager for handling price data with infinite scrolling
+class InfiniteScrollPriceManager extends InfiniteScrollManager<Price> {
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: Make it private.
created_at: 2025-05-06T11:44:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,76 +52,64 @@ class _PricesProductsPageState extends State<PricesProductsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetPriceProductsResult>>(
-        future: _products,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetPriceProductsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetPriceProductsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-
-          for (final PriceProduct item in result.items!) {
-            children.add(
-              SmoothCard(
-                child: PriceProductWidget(
-                  item,
-                  enableCountButton: true,
-                ),
-              ),
-            );
-          }
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title =
-              appLocalizations.prices_products_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<PriceProduct>(
+        manager: _productManager,
       ),
     );
   }
+}
 
-  static Future<MaybeError<GetPriceProductsResult>> _showTopProducts() async =>
-      OpenPricesAPIClient.getPriceProducts(
-        GetPriceProductsParameters()
-          ..orderBy = <OrderBy<GetPriceProductsOrderField>>[
-            const OrderBy<GetPriceProductsOrderField>(
-              field: GetPriceProductsOrderField.priceCount,
-              ascending: false,
-            ),
-          ]
-          ..pageSize = _pageSize
-          ..pageNumber = 1,
-        uriHelper: ProductQuery.uriPricesHelper,
-      );
+/// A manager for handling product data with infinite scrolling
+class InfiniteScrollProductManager extends InfiniteScrollManager<PriceProduct> {
+  InfiniteScrollProductManager({
+    super.initialItems,
+    required this.pageSize,
+  });
+
+  final int pageSize;
+
+  static const List<OrderBy<GetPriceProductsOrderField>> _orderBy =
+      <OrderBy<GetPriceProductsOrderField>>[
+    OrderBy<GetPriceProductsOrderField>(
+      field: GetPriceProductsOrderField.priceCount,
+      ascending: false,
+    ),
+  ];
+
+  @override
+  Future<void> fetchData(final int pageNumber) async {
+    final MaybeError<GetPriceProductsResult> result =
+        await OpenPricesAPIClient.getPriceProducts(
+      GetPriceProductsParameters()
+        ..pageNumber = pageNumber
+        ..pageSize = pageSize
+        ..orderBy = _orderBy,
+      uriHelper: ProductQuery.uriPricesHelper,
+    );
+
+    if (result.isError) {
+      throw result.detailError;
+    }
+
+    final GetPriceProductsResult value = result.value;
+    updateItems(
+      newItems: value.items!,
+      pageNumber: value.pageNumber!,
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: ```suggestion
      newItems: value.items,
      pageNumber: value.pageNumber,
```
created_at: 2025-05-06T11:45:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +29,81 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final InfiniteScrollPriceManager _priceManager;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
+    _priceManager = InfiniteScrollPriceManager(
+      initialItems: widget.pricesResult?.items ?? <Price>[],
       model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
-      pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
-      },
     );
   }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
   @override
   Widget build(BuildContext context) {
     context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
-    }
-
-    return _ActualList(
-      model: widget.model,
-      result: _productPriceRefresher.pricesResult!,
+    return InfiniteScrollList<Price>(
+      manager: _priceManager,
     );
   }
 }
 
-class _ActualList extends StatelessWidget {
-  const _ActualList({
+/// A manager for handling price data with infinite scrolling
+class InfiniteScrollPriceManager extends InfiniteScrollManager<Price> {
+  InfiniteScrollPriceManager({
+    super.initialItems,
     required this.model,
-    required this.result,
   });
 
+  /// The model containing price query parameters
   final GetPricesModel model;
-  final GetPricesResult result;
 
   @override
-  Widget build(BuildContext context) {
-    final List<Widget> children = <Widget>[];
+  Future<void> fetchData(int pageNumber) async {
+    final GetPricesParameters parameters = model.parameters;
+    parameters.pageNumber = pageNumber;
 
-    if (!model.displayEachProduct) {
-      // in that case we display the product only once, if possible.
-      for (final Price price in result.items!) {
-        final PriceProduct? priceProduct = price.product;
-        if (priceProduct == null) {
-          continue;
-        }
-        children.add(
-          SmoothCard(
-            child: PriceProductWidget(
+    // Use ProductQuery.uriPricesHelper as shown in the example
+    final MaybeError<GetPricesResult> result =
+        await OpenPricesAPIClient.getPrices(parameters,
+            uriHelper: ProductQuery.uriPricesHelper);
+
+    if (result.isError) {
+      throw result.detailError;
+    }
+
+    final GetPricesResult value = result.value;
+    updateItems(
+      newItems: value.items!,
+      pageNumber: value.pageNumber!,
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: ```suggestion
      newItems: value.items,
      pageNumber: value.pageNumber,
```
created_at: 2025-05-06T11:46:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,86 +54,73 @@ class _PricesUsersPageState extends State<PricesUsersPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetUsersResult>>(
-        future: _users,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetUsersResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetUsersResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-
-          for (final PriceUser item in result.items!) {
-            final int priceCount = item.priceCount ?? 0;
-            children.add(
-              SmoothCard(
-                child: Wrap(
-                  spacing: VERY_SMALL_SPACE,
-                  children: <Widget>[
-                    PriceUserButton(item.userId),
-                    PriceCountWidget(
-                      count: priceCount,
-                      onPressed: () async => PriceUserButton.showUserPrices(
-                        user: item.userId,
-                        context: context,
-                      ),
-                    ),
-                  ],
-                ),
-              ),
-            );
-          }
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title =
-              appLocalizations.prices_users_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<PriceUser>(
+        manager: _userManager,
       ),
     );
   }
+}
+
+/// A manager for handling user data with infinite scrolling
+class InfiniteScrollUserManager extends InfiniteScrollManager<PriceUser> {
+  InfiniteScrollUserManager({
+    super.initialItems,
+    required this.pageSize,
+  });
+
+  final int pageSize;
+
+  @override
+  Future<void> fetchData(final int pageNumber) async {
+    final GetUsersParameters parameters = GetUsersParameters()
+      ..orderBy = <OrderBy<GetUsersOrderField>>[
+        const OrderBy<GetUsersOrderField>(
+          field: GetUsersOrderField.priceCount,
+          ascending: false,
+        ),
+      ]
+      ..pageSize = pageSize
+      ..pageNumber = pageNumber;
+
+    final MaybeError<GetUsersResult> result =
+        await OpenPricesAPIClient.getUsers(
+      parameters,
+      uriHelper: ProductQuery.uriPricesHelper,
+    );
+
+    if (result.isError) {
+      throw result.detailError;
+    }
+
+    final GetUsersResult value = result.value;
+    updateItems(
+      newItems: value.items!,
+      pageNumber: value.pageNumber!,
path: packages/smooth_app/lib/pages/prices/prices_users_page.dart
user: login: monsieurtanuki
body: ```suggestion
      newItems: value.items,
      pageNumber: value.pageNumber,
```
created_at: 2025-05-06T11:47:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,86 +54,73 @@ class _PricesUsersPageState extends State<PricesUsersPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetUsersResult>>(
-        future: _users,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetUsersResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetUsersResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-
-          for (final PriceUser item in result.items!) {
-            final int priceCount = item.priceCount ?? 0;
-            children.add(
-              SmoothCard(
-                child: Wrap(
-                  spacing: VERY_SMALL_SPACE,
-                  children: <Widget>[
-                    PriceUserButton(item.userId),
-                    PriceCountWidget(
-                      count: priceCount,
-                      onPressed: () async => PriceUserButton.showUserPrices(
-                        user: item.userId,
-                        context: context,
-                      ),
-                    ),
-                  ],
-                ),
-              ),
-            );
-          }
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title =
-              appLocalizations.prices_users_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<PriceUser>(
+        manager: _userManager,
       ),
     );
   }
+}
+
+/// A manager for handling user data with infinite scrolling
+class InfiniteScrollUserManager extends InfiniteScrollManager<PriceUser> {
+  InfiniteScrollUserManager({
+    super.initialItems,
+    required this.pageSize,
+  });
+
+  final int pageSize;
path: packages/smooth_app/lib/pages/prices/prices_users_page.dart
user: login: monsieurtanuki
body: No point in making it a parameter, is it?
created_at: 2025-05-06T11:47:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -51,76 +52,64 @@ class _PricesProductsPageState extends State<PricesProductsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetPriceProductsResult>>(
-        future: _products,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetPriceProductsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetPriceProductsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final List<Widget> children = <Widget>[];
-
-          for (final PriceProduct item in result.items!) {
-            children.add(
-              SmoothCard(
-                child: PriceProductWidget(
-                  item,
-                  enableCountButton: true,
-                ),
-              ),
-            );
-          }
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title =
-              appLocalizations.prices_products_list_length_many_pages(
-            _pageSize,
-            result.total!,
-          );
-          children.insert(
-            0,
-            SmoothCard(child: ListTile(title: Text(title))),
-          );
-          // so that the last content gets not hidden by the FAB
-          children.add(
-            const SizedBox(height: 2 * MINIMUM_TOUCH_SIZE),
-          );
-          return ListView(
-            children: children,
-          );
-        },
+      body: InfiniteScrollList<PriceProduct>(
+        manager: _productManager,
       ),
     );
   }
+}
 
-  static Future<MaybeError<GetPriceProductsResult>> _showTopProducts() async =>
-      OpenPricesAPIClient.getPriceProducts(
-        GetPriceProductsParameters()
-          ..orderBy = <OrderBy<GetPriceProductsOrderField>>[
-            const OrderBy<GetPriceProductsOrderField>(
-              field: GetPriceProductsOrderField.priceCount,
-              ascending: false,
-            ),
-          ]
-          ..pageSize = _pageSize
-          ..pageNumber = 1,
-        uriHelper: ProductQuery.uriPricesHelper,
-      );
+/// A manager for handling product data with infinite scrolling
+class InfiniteScrollProductManager extends InfiniteScrollManager<PriceProduct> {
+  InfiniteScrollProductManager({
+    super.initialItems,
+    required this.pageSize,
+  });
+
+  final int pageSize;
path: packages/smooth_app/lib/pages/prices/prices_products_page.dart
user: login: monsieurtanuki
body: No point in making it a parameter, is it?
created_at: 2025-05-06T11:48:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,140 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = initialItems ?? <T>[],
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  /// Abstract method to implement the data fetching logic for a specific page
+  @protected
+  Future<void> fetchData(int pageNumber);
+
+  /// Optional method to implement custom item rendering
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: ```suggestion
  /// Displays an item.
```
No it's not optional.

created_at: 2025-05-06T11:49:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,140 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = initialItems ?? <T>[],
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  /// Abstract method to implement the data fetching logic for a specific page
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: ```suggestion
  /// Fetches data for a specific page
```
We know it's an abstract method.
created_at: 2025-05-06T11:50:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,140 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = initialItems ?? <T>[],
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  /// Abstract method to implement the data fetching logic for a specific page
+  @protected
+  Future<void> fetchData(int pageNumber);
+
+  /// Optional method to implement custom item rendering
+  @protected
+  Widget buildItem({
+    required BuildContext context,
+    required T item,
+  });
+
+  Widget getItemWidget({required BuildContext context, required T item}) {
+    return buildItem(context: context, item: item);
+  }
+
+  /// Update the list with new items and pagination info
+  @protected
+  void updateItems({
+    required List<T> newItems,
+    required int pageNumber,
+    int? totalItems,
+    int? totalPages,
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: ```suggestion
    required List<T>? newItems,
    required int? pageNumber,
    required int? totalItems,
    required int? totalPages,
```
created_at: 2025-05-06T11:50:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,140 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = initialItems ?? <T>[],
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  /// Abstract method to implement the data fetching logic for a specific page
+  @protected
+  Future<void> fetchData(int pageNumber);
+
+  /// Optional method to implement custom item rendering
+  @protected
+  Widget buildItem({
+    required BuildContext context,
+    required T item,
+  });
+
+  Widget getItemWidget({required BuildContext context, required T item}) {
+    return buildItem(context: context, item: item);
+  }
+
+  /// Update the list with new items and pagination info
+  @protected
+  void updateItems({
+    required List<T> newItems,
+    required int pageNumber,
+    int? totalItems,
+    int? totalPages,
+  }) {
+    if (pageNumber == _initialPage) {
+      _items.clear();
+    }
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: I'm afraid it's never supposed to happen, so please don't "just-in-case" it.
created_at: 2025-05-06T11:51:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,140 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = initialItems ?? <T>[],
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  /// Abstract method to implement the data fetching logic for a specific page
+  @protected
+  Future<void> fetchData(int pageNumber);
+
+  /// Optional method to implement custom item rendering
+  @protected
+  Widget buildItem({
+    required BuildContext context,
+    required T item,
+  });
+
+  Widget getItemWidget({required BuildContext context, required T item}) {
+    return buildItem(context: context, item: item);
+  }
+
+  /// Update the list with new items and pagination info
+  @protected
+  void updateItems({
+    required List<T> newItems,
+    required int pageNumber,
+    int? totalItems,
+    int? totalPages,
+  }) {
+    if (pageNumber == _initialPage) {
+      _items.clear();
+    }
+
+    _items.addAll(newItems);
+    _currentPage = pageNumber;
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: Handle here the case when `newItems` and `pageNumber` are `null` (probably just `return`ing).
created_at: 2025-05-06T11:52:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +29,81 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final _InfiniteScrollPriceManager _priceManager;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
+    _priceManager = _InfiniteScrollPriceManager(
+      initialItems: widget.pricesResult?.items,
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: __In a next PR__: what about passing the priceResult as a parameter, in order to populate the items AND the page number, total items and total pages?
created_at: 2025-05-07T05:41:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -58,106 +87,116 @@ class _PricesProofsPageState extends State<PricesProofsPage>
           ),
         ],
       ),
-      body: FutureBuilder<MaybeError<GetProofsResult>>(
-        future: _results,
-        builder: (
-          final BuildContext context,
-          final AsyncSnapshot<MaybeError<GetProofsResult>> snapshot,
-        ) {
-          if (snapshot.connectionState != ConnectionState.done) {
-            return const Center(child: CircularProgressIndicator());
-          }
-          if (snapshot.hasError) {
-            return Text(snapshot.error!.toString());
-          }
-          // highly improbable
-          if (!snapshot.hasData) {
-            return const Text('no data');
-          }
-          if (snapshot.data!.isError) {
-            return Text(snapshot.data!.error!);
-          }
-          final GetProofsResult result = snapshot.data!.value;
-          // highly improbable
-          if (result.items == null) {
-            return const Text('empty list');
-          }
-          final double squareSize = MediaQuery.sizeOf(context).width / _columns;
-
-          final AppLocalizations appLocalizations =
-              AppLocalizations.of(context);
-          final String title = result.numberOfPages == 1
-              ? appLocalizations.prices_proofs_list_length_one_page(
-                  result.items!.length,
-                )
-              : appLocalizations.prices_proofs_list_length_many_pages(
-                  _pageSize,
-                  result.total!,
+      body: _bearerToken == null
+          ? // Show loading while authenticating
+          const Center(child: CircularProgressIndicator())
+          : // Show content once authenticated
+          _buildProofsContent(context, appLocalizations),
+    );
+  }
+
+  Widget _buildProofsContent(
+    BuildContext context,
+    AppLocalizations appLocalizations,
+  ) {
+    return Column(
+      children: <Widget>[
+        SmoothCard(
+          child: ListTile(
+            title: Text(
+              (_proofManager.totalPages ?? 1) <= 1
+                  ? appLocalizations.prices_proofs_list_length_one_page(
+                      _proofManager.items.length)
+                  : appLocalizations.prices_proofs_list_length_many_pages(
+                      _pageSize,
+                      _proofManager.totalItems ?? 0,
+                    ),
+            ),
+          ),
+        ),
+        Expanded(
+          child: _buildProofsGrid(context, appLocalizations),
+        ),
+      ],
+    );
+  }
+
+  Widget _buildProofsGrid(
+    BuildContext context,
+    AppLocalizations appLocalizations,
+  ) {
+    if (_proofManager.items.isEmpty) {
+      if (_proofManager.isLoading) {
+        return const Center(child: CircularProgressIndicator());
+      } else {
+        return const Center(child: Text('No Result'));
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: To be localized.
created_at: 2025-05-07T05:44:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -166,41 +205,89 @@ class _PricesProofsPageState extends State<PricesProofsPage>
       uriHelper: ProductQuery.uriPricesHelper,
     );
 
-    if (token.isError) {
-      return MaybeError<GetProofsResult>.error(
-        error: token.error ?? 'Could not authenticate with the server',
-        statusCode: token.statusCode ?? 500,
-      );
+    _bearerToken = token.value;
+  }
+
+  @override
+  Future<void> fetchData(final int pageNumber) async {
+    if (_bearerToken == null) {
+      await fetchInit();
+      if (_bearerToken == null) {
+        throw Exception('Authentication failed');
+      }
     }
 
-    final String bearerToken = token.value;
+    final User user = ProductQuery.getWriteUser();
+
+    final GetProofsParameters parameters = GetProofsParameters()
+      ..orderBy = <OrderBy<GetProofsOrderField>>[
+        const OrderBy<GetProofsOrderField>(
+          field: GetProofsOrderField.created,
+          ascending: false,
+        ),
+      ]
+      ..pageSize = pageSize
+      ..owner = user.userId
+      ..pageNumber = pageNumber;
 
     final MaybeError<GetProofsResult> result =
         await OpenPricesAPIClient.getProofs(
-      GetProofsParameters()
-        ..orderBy = <OrderBy<GetProofsOrderField>>[
-          const OrderBy<GetProofsOrderField>(
-            field: GetProofsOrderField.created,
-            ascending: false,
-          ),
-        ]
-        ..owner = user.userId
-        ..pageSize = _pageSize
-        ..pageNumber = 1,
+      parameters,
       uriHelper: ProductQuery.uriPricesHelper,
-      bearerToken: bearerToken,
+      bearerToken: _bearerToken!,
     );
 
-    await OpenPricesAPIClient.deleteUserSession(
-      uriHelper: ProductQuery.uriPricesHelper,
-      bearerToken: bearerToken,
+    if (result.isError) {
+      throw result.detailError;
+    }
+
+    final GetProofsResult value = result.value;
+    updateItems(
+      newItems: value.items,
+      pageNumber: value.pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
     );
+  }
+
+  Future<void> deleteSession() async {
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: Never called, is it?
To be called in the `dispose` of the scroll list.
created_at: 2025-05-07T05:49:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,149 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = initialItems ?? <T>[],
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  @protected
+  Future<void> fetchInit() async {}
+
+  /// Fetches data for a specific page
+  @protected
+  Future<void> fetchData(int pageNumber);
+
+  /// Displays an item.
+  @protected
+  Widget buildItem({
+    required BuildContext context,
+    required T item,
+  });
+
+  Widget getItemWidget({required BuildContext context, required T item}) {
+    return buildItem(context: context, item: item);
+  }
+
+  /// Update the list with new items and pagination info
+  @protected
+  void updateItems({
+    required List<T>? newItems,
+    required int? pageNumber,
+    required int? totalItems,
+    required int? totalPages,
+  }) {
+    if (newItems == null && pageNumber == null) {
+      return;
+    }
+    if (newItems != null) {
+      _items.addAll(newItems);
+    }
+    if (pageNumber != null) {
+      _currentPage = pageNumber;
+    }
+    _totalItems = totalItems ?? _totalItems;
+    _totalPages = totalPages ?? _totalPages;
+  }
+
+  /// Load initial data only if the list is empty
+  Future<void> loadInitiallyIfNeeded(BuildContext context) async {
+    if (_items.isNotEmpty) {
+      return;
+    }
+    await fetchInit();
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: ```suggestion
    await fetchInit();
    if (_items.isNotEmpty) {
      return;
    }
```
created_at: 2025-05-07T06:02:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,15 +31,42 @@ class PricesProofsPage extends StatefulWidget {
 
 class _PricesProofsPageState extends State<PricesProofsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetProofsResult>> _results = _download();
-
   static const int _columns = 3;
   static const int _rows = 5;
   static const int _pageSize = _columns * _rows;
 
+  final _InfiniteScrollProofManager _proofManager =
+      _InfiniteScrollProofManager();
+
+  String? _bearerToken;
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: This _bearerToken is never set, is it?
created_at: 2025-05-07T06:07:50Z
author_association: CONTRIBUTOR
diff_hunk: @@ -32,140 +29,81 @@ class ProductPricesList extends StatefulWidget {
 
 class _ProductPricesListState extends State<ProductPricesList>
     with TraceableClientMixin {
-  late final ProductPriceRefresher _productPriceRefresher;
+  late final _InfiniteScrollPriceManager _priceManager;
 
   @override
   void initState() {
     super.initState();
-    _productPriceRefresher = ProductPriceRefresher(
-      model: widget.model,
-      userPreferences: context.read<UserPreferences>(),
+    _priceManager = _InfiniteScrollPriceManager(
       pricesResult: widget.pricesResult,
-      refreshDisplay: () {
-        if (mounted) {
-          setState(() {});
-        }
-      },
+      model: widget.model,
     );
   }
 
-  // TODO(monsieurtanuki): add a refresh gesture
-  // TODO(monsieurtanuki): add a "download the next 10" items
   @override
   Widget build(BuildContext context) {
     context.watch<LocalDatabase>();
-    unawaited(_productPriceRefresher.runIfNeeded());
-
-    switch (_productPriceRefresher.loadingStatus) {
-      case null:
-      case LoadingStatus.LOADING:
-        return const Center(child: CircularProgressIndicator());
-      case LoadingStatus.ERROR:
-        return Text(_productPriceRefresher.loadingError.toString());
-      case LoadingStatus.LOADED:
-        break;
-    }
-    // highly improbable
-    if (_productPriceRefresher.pricesResult!.items == null) {
-      return const Text('empty list');
-    }
-
-    return _ActualList(
-      model: widget.model,
-      result: _productPriceRefresher.pricesResult!,
+    return InfiniteScrollList<Price>(
+      manager: _priceManager,
     );
   }
 }
 
-class _ActualList extends StatelessWidget {
-  const _ActualList({
+/// A manager for handling price data with infinite scrolling
+class _InfiniteScrollPriceManager extends InfiniteScrollManager<Price> {
+  _InfiniteScrollPriceManager({
+    GetPricesResult? pricesResult,
     required this.model,
-    required this.result,
-  });
+  }) : super(initialItems: pricesResult?.items);
 
+  /// The model containing price query parameters
   final GetPricesModel model;
-  final GetPricesResult result;
 
   @override
-  Widget build(BuildContext context) {
-    final List<Widget> children = <Widget>[];
+  Future<void> fetchData(int pageNumber) async {
+    final GetPricesParameters parameters = model.parameters;
+    parameters.pageNumber = pageNumber;
+
+    // Use ProductQuery.uriPricesHelper as shown in the example
path: packages/smooth_app/lib/pages/prices/product_prices_list.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-05-08T06:36:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -196,61 +107,94 @@ class _PricesProofsPageState extends State<PricesProofsPage>
       bearerToken: bearerToken,
     );
 
-    return result;
+    if (result.isError) {
+      throw result.detailError;
+    }
+
+    final GetProofsResult value = result.value;
+    updateItems(
+      newItems: value.items,
+      pageNumber: value.pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
+    );
+  }
+
+  @override
+  Widget buildItem({
+    required BuildContext context,
+    required Proof item,
+  }) {
+    if (item.filePath == null) {
+      return const SizedBox.shrink();
+    }
+
+    return SmoothCard(
+      child: InkWell(
+        onTap: () async {
+          if (selectProof) {
+            Navigator.of(context).pop(item);
+            return;
+          }
+          return Navigator.push<void>(
+            context,
+            MaterialPageRoute<void>(
+              builder: (BuildContext context) => PriceProofPage(item),
+            ),
+          );
+        },
+        child: _PriceProofListItem(item),
+      ),
+    );
   }
 }
 
-// TODO(monsieurtanuki): reuse whatever will be coded in https://github.com/openfoodfacts/smooth-app/pull/5366
-class _PriceProofImage extends StatelessWidget {
-  const _PriceProofImage(
-    this.proof, {
-    required this.squareSize,
-  });
+class _PriceProofListItem extends StatelessWidget {
+  const _PriceProofListItem(this.proof);
 
   final Proof proof;
-  final double squareSize;
 
   @override
   Widget build(BuildContext context) {
     final DateFormat dateFormat =
         DateFormat.yMd(ProductQuery.getLocaleString());
-    final String date = dateFormat.format(proof.created);
-    return Stack(
-      children: <Widget>[
-        SmoothImage(
-          width: squareSize,
-          height: squareSize,
-          imageProvider: NetworkImage(
-            proof
-                .getFileUrl(
-                  uriProductHelper: ProductQuery.uriPricesHelper,
-                  isThumbnail: true,
-                )
-                .toString(),
+    final String date = dateFormat.format(proof.date ?? proof.created);
+
+    final double screenWidth = MediaQuery.of(context).size.width;
+    final double imageSize = screenWidth * 0.3;
+
+    return Padding(
+      padding: const EdgeInsets.all(SMALL_SPACE),
+      child: Row(
+        children: <Widget>[
+          SmoothImage(
+            width: imageSize,
+            height: imageSize,
+            imageProvider: NetworkImage(
+              proof
+                  .getFileUrl(
+                    uriProductHelper: ProductQuery.uriPricesHelper,
+                    isThumbnail: true,
+                  )
+                  .toString(),
+            ),
+            rounded: false,
           ),
-          rounded: false,
-        ),
-        SizedBox(
-          width: squareSize,
-          height: squareSize,
-          child: Align(
-            alignment: Alignment.bottomCenter,
-            child: Padding(
-              padding: const EdgeInsets.all(SMALL_SPACE),
-              child: Container(
-                height: VERY_LARGE_SPACE,
-                color: Colors.white.withAlpha(128),
-                child: Center(
-                  child: AutoSizeText(
-                    date,
-                    maxLines: 1,
-                  ),
+          const SizedBox(width: MEDIUM_SPACE),
+          Expanded(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.center,
+              children: <Widget>[
+                Text(
+                  date,
+                  style: Theme.of(context).textTheme.titleMedium,
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-05-08T06:38:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -196,61 +107,94 @@ class _PricesProofsPageState extends State<PricesProofsPage>
       bearerToken: bearerToken,
     );
 
-    return result;
+    if (result.isError) {
+      throw result.detailError;
+    }
+
+    final GetProofsResult value = result.value;
+    updateItems(
+      newItems: value.items,
+      pageNumber: value.pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
+    );
+  }
+
+  @override
+  Widget buildItem({
+    required BuildContext context,
+    required Proof item,
+  }) {
+    if (item.filePath == null) {
+      return const SizedBox.shrink();
+    }
+
+    return SmoothCard(
+      child: InkWell(
+        onTap: () async {
+          if (selectProof) {
+            Navigator.of(context).pop(item);
+            return;
+          }
+          return Navigator.push<void>(
+            context,
+            MaterialPageRoute<void>(
+              builder: (BuildContext context) => PriceProofPage(item),
+            ),
+          );
+        },
+        child: _PriceProofListItem(item),
+      ),
+    );
   }
 }
 
-// TODO(monsieurtanuki): reuse whatever will be coded in https://github.com/openfoodfacts/smooth-app/pull/5366
-class _PriceProofImage extends StatelessWidget {
-  const _PriceProofImage(
-    this.proof, {
-    required this.squareSize,
-  });
+class _PriceProofListItem extends StatelessWidget {
+  const _PriceProofListItem(this.proof);
 
   final Proof proof;
-  final double squareSize;
 
   @override
   Widget build(BuildContext context) {
     final DateFormat dateFormat =
         DateFormat.yMd(ProductQuery.getLocaleString());
-    final String date = dateFormat.format(proof.created);
-    return Stack(
-      children: <Widget>[
-        SmoothImage(
-          width: squareSize,
-          height: squareSize,
-          imageProvider: NetworkImage(
-            proof
-                .getFileUrl(
-                  uriProductHelper: ProductQuery.uriPricesHelper,
-                  isThumbnail: true,
-                )
-                .toString(),
+    final String date = dateFormat.format(proof.date ?? proof.created);
+
+    final double screenWidth = MediaQuery.of(context).size.width;
+    final double imageSize = screenWidth * 0.3;
+
+    return Padding(
+      padding: const EdgeInsets.all(SMALL_SPACE),
+      child: Row(
+        children: <Widget>[
+          SmoothImage(
+            width: imageSize,
+            height: imageSize,
+            imageProvider: NetworkImage(
+              proof
+                  .getFileUrl(
+                    uriProductHelper: ProductQuery.uriPricesHelper,
+                    isThumbnail: true,
+                  )
+                  .toString(),
+            ),
+            rounded: false,
           ),
-          rounded: false,
-        ),
-        SizedBox(
-          width: squareSize,
-          height: squareSize,
-          child: Align(
-            alignment: Alignment.bottomCenter,
-            child: Padding(
-              padding: const EdgeInsets.all(SMALL_SPACE),
-              child: Container(
-                height: VERY_LARGE_SPACE,
-                color: Colors.white.withAlpha(128),
-                child: Center(
-                  child: AutoSizeText(
-                    date,
-                    maxLines: 1,
-                  ),
+          const SizedBox(width: MEDIUM_SPACE),
+          Expanded(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.center,
+              children: <Widget>[
+                Text(
+                  date,
+                  style: Theme.of(context).textTheme.titleMedium,
                 ),
-              ),
+              ],
             ),
           ),
-        ),
-      ],
+          const Icon(Icons.chevron_right),
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-05-08T06:39:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -196,61 +107,94 @@ class _PricesProofsPageState extends State<PricesProofsPage>
       bearerToken: bearerToken,
     );
 
-    return result;
+    if (result.isError) {
+      throw result.detailError;
+    }
+
+    final GetProofsResult value = result.value;
+    updateItems(
+      newItems: value.items,
+      pageNumber: value.pageNumber,
+      totalItems: value.total,
+      totalPages: value.numberOfPages,
+    );
+  }
+
+  @override
+  Widget buildItem({
+    required BuildContext context,
+    required Proof item,
+  }) {
+    if (item.filePath == null) {
+      return const SizedBox.shrink();
+    }
+
+    return SmoothCard(
+      child: InkWell(
+        onTap: () async {
+          if (selectProof) {
+            Navigator.of(context).pop(item);
+            return;
+          }
+          return Navigator.push<void>(
+            context,
+            MaterialPageRoute<void>(
+              builder: (BuildContext context) => PriceProofPage(item),
+            ),
+          );
+        },
+        child: _PriceProofListItem(item),
+      ),
+    );
   }
 }
 
-// TODO(monsieurtanuki): reuse whatever will be coded in https://github.com/openfoodfacts/smooth-app/pull/5366
-class _PriceProofImage extends StatelessWidget {
-  const _PriceProofImage(
-    this.proof, {
-    required this.squareSize,
-  });
+class _PriceProofListItem extends StatelessWidget {
+  const _PriceProofListItem(this.proof);
 
   final Proof proof;
-  final double squareSize;
 
   @override
   Widget build(BuildContext context) {
     final DateFormat dateFormat =
         DateFormat.yMd(ProductQuery.getLocaleString());
-    final String date = dateFormat.format(proof.created);
-    return Stack(
-      children: <Widget>[
-        SmoothImage(
-          width: squareSize,
-          height: squareSize,
-          imageProvider: NetworkImage(
-            proof
-                .getFileUrl(
-                  uriProductHelper: ProductQuery.uriPricesHelper,
-                  isThumbnail: true,
-                )
-                .toString(),
+    final String date = dateFormat.format(proof.date ?? proof.created);
+
+    final double screenWidth = MediaQuery.of(context).size.width;
+    final double imageSize = screenWidth * 0.3;
+
+    return Padding(
+      padding: const EdgeInsets.all(SMALL_SPACE),
+      child: Row(
+        children: <Widget>[
+          SmoothImage(
+            width: imageSize,
+            height: imageSize,
+            imageProvider: NetworkImage(
+              proof
+                  .getFileUrl(
+                    uriProductHelper: ProductQuery.uriPricesHelper,
+                    isThumbnail: true,
+                  )
+                  .toString(),
+            ),
+            rounded: false,
           ),
-          rounded: false,
-        ),
-        SizedBox(
-          width: squareSize,
-          height: squareSize,
-          child: Align(
-            alignment: Alignment.bottomCenter,
-            child: Padding(
-              padding: const EdgeInsets.all(SMALL_SPACE),
-              child: Container(
-                height: VERY_LARGE_SPACE,
-                color: Colors.white.withAlpha(128),
-                child: Center(
-                  child: AutoSizeText(
-                    date,
-                    maxLines: 1,
-                  ),
+          const SizedBox(width: MEDIUM_SPACE),
+          Expanded(
+            child: Column(
+              crossAxisAlignment: CrossAxisAlignment.center,
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: ```suggestion
```
created_at: 2025-05-08T06:39:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,11 +30,10 @@ class PricesProofsPage extends StatefulWidget {
 
 class _PricesProofsPageState extends State<PricesProofsPage>
     with TraceableClientMixin {
-  late final Future<MaybeError<GetProofsResult>> _results = _download();
-
-  static const int _columns = 3;
-  static const int _rows = 5;
-  static const int _pageSize = _columns * _rows;
+  late final _InfiniteScrollProofManager _proofManager =
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: ```suggestion
  final _InfiniteScrollProofManager _proofManager =
```
created_at: 2025-05-08T06:43:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,149 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = initialItems ?? <T>[],
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  @protected
+  Future<void> fetchInit() async {}
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: monsieurtanuki
body: So now it's never used, right?
created_at: 2025-05-08T06:44:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -166,13 +84,6 @@ class _PricesProofsPageState extends State<PricesProofsPage>
       uriHelper: ProductQuery.uriPricesHelper,
     );
 
-    if (token.isError) {
-      return MaybeError<GetProofsResult>.error(
-        error: token.error ?? 'Could not authenticate with the server',
-        statusCode: token.statusCode ?? 500,
-      );
-    }
-
path: packages/smooth_app/lib/pages/prices/prices_proofs_page.dart
user: login: monsieurtanuki
body: No, we do need to check that error. Throwing an exception for instance.
created_at: 2025-05-08T06:45:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,149 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_gen/gen_l10n/app_localizations.dart';
+
+/// A generic abstract class for handling infinite scrolling in lists.
+/// [T] is the type of items being displayed.
+abstract class InfiniteScrollManager<T> {
+  /// Creates an instance of [InfiniteScrollManager] with optional initial items.
+  InfiniteScrollManager({
+    List<T>? initialItems,
+  })  : _items = initialItems ?? <T>[],
+        _currentPage =
+            initialItems != null && initialItems.isNotEmpty ? _initialPage : 0;
+
+  static const int _initialPage = 1;
+
+  /// Current items in the list
+  final List<T> _items;
+
+  /// Current page being fetched
+  int _currentPage;
+
+  /// Whether currently loading more items
+  bool _isLoading = false;
+
+  /// Additional pagination information
+  int? _totalItems;
+  int? _totalPages;
+
+  /// Getter for items
+  List<T> get items => _items;
+
+  /// Getter for current page
+  int get currentPage => _currentPage;
+
+  /// Getter for loading state
+  bool get isLoading => _isLoading;
+
+  /// Getter for total items
+  int? get totalItems => _totalItems;
+
+  /// Getter for total pages
+  int? get totalPages => _totalPages;
+
+  @protected
+  Future<void> fetchInit() async {}
path: packages/smooth_app/lib/pages/prices/infinite_scroll_manager.dart
user: login: AshutoshKhadse23
body: Right!

created_at: 2025-05-08T09:16:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: codecov-commenter
created_at: 2025-04-21T19:40:32Z
body: ## [Codecov](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts) Report
:x: Patch coverage is `0%` with `285 lines` in your changes missing coverage. Please review.
:white_check_mark: Project coverage is 5.74%. Comparing base ([`4d9c7fc`](https://app.codecov.io/gh/openfoodfacts/smooth-app/commit/4d9c7fc48c4d4c1f1a5b3f2b62d48d3f044e2200?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts)) to head ([`2c1d096`](https://app.codecov.io/gh/openfoodfacts/smooth-app/commit/2c1d09681910f4592f7957795f30ac2f1526b9fb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts)).
:warning: Report is 1359 commits behind head on develop.

| [Files with missing lines](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts) | Patch % | Lines |
|---|---|---|
| [...oth\_app/lib/pages/prices/infinite\_scroll\_list.dart](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&filepath=packages%2Fsmooth_app%2Flib%2Fpages%2Fprices%2Finfinite_scroll_list.dart&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts#diff-cGFja2FnZXMvc21vb3RoX2FwcC9saWIvcGFnZXMvcHJpY2VzL2luZmluaXRlX3Njcm9sbF9saXN0LmRhcnQ=) | 0.00% | [67 Missing :warning: ](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts) |
| [...mooth\_app/lib/pages/prices/prices\_proofs\_page.dart](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&filepath=packages%2Fsmooth_app%2Flib%2Fpages%2Fprices%2Fprices_proofs_page.dart&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts#diff-cGFja2FnZXMvc21vb3RoX2FwcC9saWIvcGFnZXMvcHJpY2VzL3ByaWNlc19wcm9vZnNfcGFnZS5kYXJ0) | 0.00% | [56 Missing :warning: ](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts) |
| [...\_app/lib/pages/prices/infinite\_scroll\_manager.dart](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&filepath=packages%2Fsmooth_app%2Flib%2Fpages%2Fprices%2Finfinite_scroll_manager.dart&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts#diff-cGFja2FnZXMvc21vb3RoX2FwcC9saWIvcGFnZXMvcHJpY2VzL2luZmluaXRlX3Njcm9sbF9tYW5hZ2VyLmRhcnQ=) | 0.00% | [46 Missing :warning: ](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts) |
| [...th\_app/lib/pages/prices/prices\_locations\_page.dart](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&filepath=packages%2Fsmooth_app%2Flib%2Fpages%2Fprices%2Fprices_locations_page.dart&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts#diff-cGFja2FnZXMvc21vb3RoX2FwcC9saWIvcGFnZXMvcHJpY2VzL3ByaWNlc19sb2NhdGlvbnNfcGFnZS5kYXJ0) | 0.00% | [43 Missing :warning: ](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts) |
| [...ooth\_app/lib/pages/prices/product\_prices\_list.dart](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&filepath=packages%2Fsmooth_app%2Flib%2Fpages%2Fprices%2Fproduct_prices_list.dart&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts#diff-cGFja2FnZXMvc21vb3RoX2FwcC9saWIvcGFnZXMvcHJpY2VzL3Byb2R1Y3RfcHJpY2VzX2xpc3QuZGFydA==) | 0.00% | [27 Missing :warning: ](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts) |
| [...smooth\_app/lib/pages/prices/prices\_users\_page.dart](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&filepath=packages%2Fsmooth_app%2Flib%2Fpages%2Fprices%2Fprices_users_page.dart&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts#diff-cGFja2FnZXMvc21vb3RoX2FwcC9saWIvcGFnZXMvcHJpY2VzL3ByaWNlc191c2Vyc19wYWdlLmRhcnQ=) | 0.00% | [26 Missing :warning: ](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts) |
| [...oth\_app/lib/pages/prices/prices\_products\_page.dart](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&filepath=packages%2Fsmooth_app%2Flib%2Fpages%2Fprices%2Fprices_products_page.dart&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts#diff-cGFja2FnZXMvc21vb3RoX2FwcC9saWIvcGFnZXMvcHJpY2VzL3ByaWNlc19wcm9kdWN0c19wYWdlLmRhcnQ=) | 0.00% | [20 Missing :warning: ](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts) |

<details><summary>Additional details and impacted files</summary>



```diff
@@            Coverage Diff             @@
##           develop   #6561      +/-   ##
==========================================
- Coverage     9.54%   5.74%   -3.80%     
==========================================
  Files          325     511     +186     
  Lines        16411   30331   +13920     
==========================================
+ Hits          1567    1744     +177     
- Misses       14844   28587   +13743     
```
</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/openfoodfacts/smooth-app/pull/6561?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=openfoodfacts).
<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: AshutoshKhadse23
created_at: 2025-04-23T05:54:30Z
body: @monsieurtanuki, I tried to implement what you suggested—looking forward to your feedback!
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-04-24T18:49:39Z
body: Hello @monsieurtanuki ,
Updated code according to the comments. And is ready for review.
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-04-25T19:55:10Z
body: @monsieurtanuki, changed the code according to the comment.
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-04-26T18:48:07Z
body: @monsieurtanuki, changed the code according to the comment.
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-04-27T11:30:27Z
body: @monsieurtanuki, changed the code according to the comment.

author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-03T21:09:47Z
body: > Hi @AshutoshKhadse23! Very interesting PR, isn't it? I found a lot of inspiration reading your code. For the better I hope.

@monsieurtanuki, I’m also finding this PR really interesting! It’s been a great way to learn more about the codebase.
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-04T13:44:53Z
body: @monsieurtanuki,
You are right !! 
So changed the code according to the concept of OPP Java
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-05T10:18:38Z
body: > Hi @AshutoshKhadse23! I cannot comment everything, but on your way to OOP you experienced some confusion (like casually returning empty `SizedBox()`) and "just-in-case"ism (like a `refresh` method that is NEVER used).

I am extremely embarrassed by it.
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-06T10:58:16Z
body: @monsieurtanuki,
Could you have a look?(just not sure about the ```bearerToken```)
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-06T14:05:50Z
body: @monsieurtanuki,
Take a final look, I think this would close it. (Maybe wrong)
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-07T07:41:48Z
body: Hello @monsieurtanuki,
I will work on "changing the proof display as explained, which wouldn't take you that long (but means creating many proofs first for testing)"
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-07T11:12:11Z
body: Hello @monsieurtanuki,
Could you confirm this UI?
I know you told only to add the date, but I thought proof ID could also be added.


https://github.com/user-attachments/assets/0972a259-8fff-476b-894d-82c6c8d91bbb


author_association: CONTRIBUTOR
user: login: monsieurtanuki
created_at: 2025-05-07T11:24:11Z
body: Hi @AshutoshKhadse23
No proof ID please, just the date.
I know it looks a bit void, but that will populated in another issue/PR.
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-07T11:31:18Z
body: @monsieurtanuki,
Do you think this is alright?
<img width="380" alt="Screenshot 2025-05-07 at 5 00 36 PM" src="https://github.com/user-attachments/assets/3fbababb-611a-43d6-b144-60920d6c93de" />

author_association: CONTRIBUTOR
user: login: monsieurtanuki
created_at: 2025-05-07T12:39:19Z
body: @AshutoshKhadse23 I would say it's good enough. A bit surprised by the "All 3 proofs" on top, referring specifically to "proof" when it's supposed to be generic.
Regardless, please push your changes so I can review.
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-07T12:43:32Z
body: @monsieurtanuki,
I have completed the implementation of the list in the proof page with a date, and also applied an infinite list.
Looking forward to your feedback?

https://github.com/user-attachments/assets/40adad4a-0e26-4ccc-8637-df34a49b4b98


author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-07T12:45:04Z
body: > @AshutoshKhadse23 I would say it's good enough. A bit surprised by the "All 3 proofs" on top, referring specifically to "proof" when it's supposed to be generic. Regardless, please push your changes so I can review.

That was before I made the change, in that  I just want to ask about the UI from the grid to list.
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-08T09:15:00Z
body: > Hi @AshutoshKhadse23! Besides minor UI comments that I don't want you to work on, there's something wrong with the bearerToken:
> 
> 1. you never use `fetchInit` - it's precisely for the bearerToken that `fetchInit` was introduced
> 2. you dismissed the test of the error when retrieving the bearerToken - you should not dismiss error tests
> 3. you compute the bearerToken for every page - the bearerToken is to be computed only once for all pages

Hello @monsieurtanuki,
For the consistency of the code overall, I changed the code accordingly. If you want, I can still use ```fetchInit``` in the code.
author_association: CONTRIBUTOR
user: login: monsieurtanuki
created_at: 2025-05-08T09:26:31Z
body: > Hello @monsieurtanuki, For the consistency of the code overall, I changed the code accordingly. If you want, I can still use `fetchInit` in the code.

@AshutoshKhadse23 It has nothing to do with consistency.
For proofs, there's a special case: at init time, we must perform a specific action, once.
It's something that can be understood as "there's an init phase for all subclasses, but luckily most of the time we do nothing".
What would be the point of `fetchInit` otherwise?
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-08T10:12:26Z
body: > > Hello @monsieurtanuki, For the consistency of the code overall, I changed the code accordingly. If you want, I can still use `fetchInit` in the code.
> 
> @AshutoshKhadse23 It has nothing to do with consistency. For proofs, there's a special case: at init time, we must perform a specific action, once. It's something that can be understood as "there's an init phase for all subclasses, but luckily most of the time we do nothing". What would be the point of `fetchInit` otherwise?

@monsieurtanuki,
What do you think about this approach ?
```dart
class _InfiniteScrollProofManager extends InfiniteScrollManager<Proof> {
  _InfiniteScrollProofManager({
    required this.selectProof,
  });

  static const int _pageSize = 10;
  final bool selectProof;
  String? _bearerToken;

  @override
  Future<void> fetchInit() async {
    final User user = ProductQuery.getWriteUser();
    final MaybeError<String> token =
    await OpenPricesAPIClient.getAuthenticationToken(
      username: user.userId,
      password: user.password,
      uriHelper: ProductQuery.uriPricesHelper,
    );

    if (token.isError) {
      throw MaybeError<GetProofsResult>.error(
        error: token.error ?? 'Could not authenticate with the server',
        statusCode: token.statusCode ?? 500,
      );
    }

    _bearerToken = token.value;
  }

  @override
  Future<void> fetchData(final int pageNumber) async {
    if (_bearerToken == null) {
      await fetchInit();
      if (_bearerToken == null) {
        throw MaybeError<GetProofsResult>.error(
          error: 'Failed to obtain authentication token',
          statusCode: 500,
        );
      }
    }

    final User user = ProductQuery.getWriteUser();
    final MaybeError<GetProofsResult> result =
    await OpenPricesAPIClient.getProofs(
      GetProofsParameters()
        ..orderBy = <OrderBy<GetProofsOrderField>>[
          const OrderBy<GetProofsOrderField>(
            field: GetProofsOrderField.created,
            ascending: false,
          ),
        ]
        ..owner = user.userId
        ..pageSize = _pageSize
        ..pageNumber = pageNumber,
      uriHelper: ProductQuery.uriPricesHelper,
      bearerToken: _bearerToken!,
    );

    await OpenPricesAPIClient.deleteUserSession(
      uriHelper: ProductQuery.uriPricesHelper,
      bearerToken: _bearerToken!,
    );

    if (result.isError) {
      throw result.detailError;
    }

    final GetProofsResult value = result.value;
    updateItems(
      newItems: value.items,
      pageNumber: value.pageNumber,
      totalItems: value.total,
      totalPages: value.numberOfPages,
    );
  }
  ```
author_association: CONTRIBUTOR
user: login: monsieurtanuki
created_at: 2025-05-08T10:20:55Z
body: @AshutoshKhadse23
* you're not supposed to `throw` a `MaybeError`. Just throw an exception in `fetchInit`
* and you don't have to test `if (_bearerToken == null)` after `fetchInit` within `fetchData`, because it would have thrown an exception already
* you're __NOT__ supposed to close the session (`deleteUserSession`) after each page. Just do it in the `dispose` of the list. __ONCE__. And we don't care if the `deleteUserSession` is not successful.
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-08T11:02:09Z
body: > @AshutoshKhadse23
> 
> * you're not supposed to `throw` a `MaybeError`. Just throw an exception in `fetchInit`
> * and you don't have to test `if (_bearerToken == null)` after `fetchInit` within `fetchData`, because it would have thrown an exception already
> * you're **NOT** supposed to close the session (`deleteUserSession`) after each page. Just do it in the `dispose` of the list. **ONCE**. And we don't care if the `deleteUserSession` is not successful.

How about this? I just wanna to complete work perfectly so if you have time could you check it?
```dart
class _InfiniteScrollProofManager extends InfiniteScrollManager<Proof> {
  _InfiniteScrollProofManager({
    required this.selectProof,
  });

  static const int _pageSize = 10;
  final bool selectProof;
  String? _bearerToken;

  @override
  Future<void> fetchInit() async {
    final User user = ProductQuery.getWriteUser();
    final MaybeError<String> token =
    await OpenPricesAPIClient.getAuthenticationToken(
      username: user.userId,
      password: user.password,
      uriHelper: ProductQuery.uriPricesHelper,
    );

    if (token.isError) {
      throw Exception(token.error ?? 'Could not authenticate with the server');
    }

    _bearerToken = token.value;
  }

  @override
  Future<void> fetchData(final int pageNumber) async {
    if (_bearerToken == null) {
      await fetchInit();
    }

    final User user = ProductQuery.getWriteUser();
    final MaybeError<GetProofsResult> result =
    await OpenPricesAPIClient.getProofs(
      GetProofsParameters()
        ..orderBy = <OrderBy<GetProofsOrderField>>[
          const OrderBy<GetProofsOrderField>(
            field: GetProofsOrderField.created,
            ascending: false,
          ),
        ]
        ..owner = user.userId
        ..pageSize = _pageSize
        ..pageNumber = pageNumber,
      uriHelper: ProductQuery.uriPricesHelper,
      bearerToken: _bearerToken!,
    );

    if (result.isError) {
      throw Exception(result.error ?? 'Failed to fetch proofs');
    }

    final GetProofsResult value = result.value;
    updateItems(
      newItems: value.items,
      pageNumber: value.pageNumber,
      totalItems: value.total,
      totalPages: value.numberOfPages,
    );
  }

  /// Properly dispose of the session when the manager is no longer needed
  void dispose() {
    if (_bearerToken != null) {
      OpenPricesAPIClient.deleteUserSession(
        uriHelper: ProductQuery.uriPricesHelper,
        bearerToken: _bearerToken!,
      );
    }
  }

  @override
  Widget buildItem({
    required BuildContext context,
    required Proof item,
  }) {
    if (item.filePath == null) {
      return const SizedBox.shrink();
    }

    return SmoothCard(
      child: InkWell(
        onTap: () async {
          if (selectProof) {
            Navigator.of(context).pop(item);
            return;
          }
          return Navigator.push<void>(
            context,
            MaterialPageRoute<void>(
              builder: (BuildContext context) => PriceProofPage(item),
            ),
          );
        },
        child: _PriceProofListItem(item),
      ),
    );
  }
}
```
author_association: CONTRIBUTOR
user: login: monsieurtanuki
created_at: 2025-05-08T11:21:14Z
body: @AshutoshKhadse23 That's the general idea. But if you want your code to be reviewed, you have to push it, not comment. Besides, `dispose` is never called.
Waiting for your push.
author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-08T12:23:25Z
body: @monsieurtanuki,
Check once!!

author_association: CONTRIBUTOR
user: login: AshutoshKhadse23
created_at: 2025-05-08T13:17:13Z
body: > Thank you @AshutoshKhadse23! I do believe the code looks better that way, what do you think? 😉

@monsieurtanuki,
Yes, I agree, and thank you for your patience.
author_association: CONTRIBUTOR