Skip to content
This repository has been archived by the owner on Jul 2, 2024. It is now read-only.

Commit

Permalink
Merge pull request #234 from blazern/pull-to-refresh-news
Browse files Browse the repository at this point in the history
Implement pull to refresh for News Feed
  • Loading branch information
blazern authored Apr 21, 2022
2 parents eaaa2ff + a9a4e97 commit 7becf6d
Show file tree
Hide file tree
Showing 6 changed files with 139 additions and 35 deletions.
13 changes: 10 additions & 3 deletions lib/ui/base/components/animated_list_simple_plante.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,14 @@ import 'package:flutter/material.dart';
class AnimatedListSimplePlante extends StatefulWidget {
final List<Widget> children;
final EdgeInsets padding;
final bool shrinkWrap;
final ScrollPhysics? physics;
const AnimatedListSimplePlante(
{Key? key, required this.children, this.padding = EdgeInsets.zero})
{Key? key,
required this.children,
this.physics,
this.shrinkWrap = false,
this.padding = EdgeInsets.zero})
: super(key: key);

@override
Expand Down Expand Up @@ -36,7 +42,7 @@ class _AnimatedListSimplePlanteState extends State<AnimatedListSimplePlante> {
}
},
remove: (pos, count) {
for (var index = pos; index < pos + count; ++index) {
for (var index = pos + count - 1; pos <= index; --index) {
listState.removeItem(
index,
(context, animation) =>
Expand Down Expand Up @@ -72,7 +78,8 @@ class _AnimatedListSimplePlanteState extends State<AnimatedListSimplePlante> {
behavior: _ScrollBehavior(),
child: AnimatedList(
key: _listKey,
shrinkWrap: true,
physics: widget.physics,
shrinkWrap: widget.shrinkWrap,
initialItemCount: widget.children.length,
itemBuilder: (context, index, animation) {
return _wrapChild(widget.children[index], animation);
Expand Down
3 changes: 2 additions & 1 deletion lib/ui/map/map_page/map_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,7 @@ class _MapPageState extends PageStatePlante<MapPage>
MapBottomHint(_bottomHint.watch(ref))),
Consumer(
builder: (context, ref, _) => AnimatedListSimplePlante(
shrinkWrap: true,
children: _mode.watch(ref).buildBottomActions())),
])),
Align(
Expand Down Expand Up @@ -508,7 +509,7 @@ class _MapPageState extends PageStatePlante<MapPage>
padding: const EdgeInsets.only(right: 24, bottom: 24),
child: e))
.toList();
return AnimatedListSimplePlante(children: fabs);
return AnimatedListSimplePlante(shrinkWrap: true, children: fabs);
});
}

Expand Down
44 changes: 29 additions & 15 deletions lib/ui/news/news_feed_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import 'package:plante/outside/map/ui_list_addresses_obtainer.dart';
import 'package:plante/products/products_obtainer.dart';
import 'package:plante/ui/base/colors_plante.dart';
import 'package:plante/ui/base/components/address_widget.dart';
import 'package:plante/ui/base/components/animated_list_simple_plante.dart';
import 'package:plante/ui/base/components/button_filled_plante.dart';
import 'package:plante/ui/base/components/circular_progress_indicator_plante.dart';
import 'package:plante/ui/base/components/licence_label.dart';
import 'package:plante/ui/base/components/visibility_detector_plante.dart';
import 'package:plante/ui/base/page_state_plante.dart';
import 'package:plante/ui/base/text_styles.dart';
import 'package:plante/ui/base/ui_utils.dart';
import 'package:plante/ui/base/ui_value.dart';
import 'package:plante/ui/news/news_feed_page_model.dart';
import 'package:plante/ui/product/product_header_widget.dart';
import 'package:plante/ui/product/product_page_wrapper.dart';
Expand All @@ -44,6 +46,7 @@ class _NewsFeedPageState extends PageStatePlante<NewsFeedPage> {
UiListAddressesObtainer<Shop>(GetIt.I.get<AddressObtainer>());

final _visibleShops = <Shop>{};
late final _loadingByPullToRefresh = UIValue(false, ref);

_NewsFeedPageState() : super('PageStatePlante');

Expand All @@ -55,7 +58,7 @@ class _NewsFeedPageState extends PageStatePlante<NewsFeedPage> {

void _initAsync() async {
await nextFrame();
_model.maybeLoadNextNews();
await _model.maybeLoadNextNews();
}

@override
Expand All @@ -67,20 +70,28 @@ class _NewsFeedPageState extends PageStatePlante<NewsFeedPage> {
consumer((ref) {
final newsWidgets =
_newsPiecesWidgets(_model.newsPieces.watch(ref));
return ListView(
key: const Key('news_pieces_list'),
children: [
Padding(
padding:
const EdgeInsets.symmetric(vertical: 16, horizontal: 21),
child: Text(context.strings.news_feed_page_new_events_title,
style: TextStyles.newsTitle),
),
...newsWidgets,
if (_model.newsPieces.watch(ref).isNotEmpty)
_loadingOrErrorOrNothing(),
],
);
return RefreshIndicator(
onRefresh: () async {
_loadingByPullToRefresh.setValue(true);
await _model.reloadNews();
_loadingByPullToRefresh.setValue(false);
},
child: AnimatedListSimplePlante(
key: const Key('news_pieces_list'),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: 16, horizontal: 21),
child: Text(
context.strings.news_feed_page_new_events_title,
style: TextStyles.newsTitle),
),
...newsWidgets,
if (_model.newsPieces.watch(ref).isNotEmpty)
_loadingOrErrorOrNothing(),
],
));
}),
if (_model.newsPieces.watch(ref).isEmpty) _loadingOrErrorOrNothing(),
])));
Expand All @@ -89,6 +100,9 @@ class _NewsFeedPageState extends PageStatePlante<NewsFeedPage> {
Widget _loadingOrErrorOrNothing() {
return consumer((ref) {
if (_model.loading.watch(ref)) {
if (_loadingByPullToRefresh.watch(ref)) {
return const SizedBox();
}
return const _LoadingWidget();
}
final error = _model.lastError.watch(ref);
Expand Down
42 changes: 32 additions & 10 deletions lib/ui/news/news_feed_page_model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,16 +40,42 @@ class NewsFeedPageModel {
Shop? getShopWith(OsmUID uid) => _loadedShops[uid];
Iterable<Shop> getAllLoadedShops() => _loadedShops.values;

void maybeLoadNextNews() async {
if (_allNewsLoaded) {
Future<void> reloadNews() async {
await _maybeLoadNextNews(clearOldNews: true);
}

Future<void> maybeLoadNextNews() async {
await _maybeLoadNextNews();
}

Future<void> _maybeLoadNextNews({bool clearOldNews = false}) async {
if (loading.cachedVal) {
return;
}
if (_allNewsLoaded && !clearOldNews) {
return;
}

_loading.setValue(true);
_lastError.setValue(null);
try {
if (clearOldNews) {
_allNewsLoaded = false;
_lastLoadedNewsPage = -1;
}
final result = await _loadNewsImpl();
if (result.isOk) {
if (clearOldNews) {
_newsPieces.setValue(const []);
}
_lastLoadedNewsPage += 1;
final newNews = result.unwrap();
if (newNews.isEmpty) {
_allNewsLoaded = true;
}
final allNews = _newsPieces.cachedVal.toList();
allNews.addAll(newNews);
_newsPieces.setValue(allNews);
} else {
_lastError.setValue(result.unwrapErr());
}
Expand All @@ -58,7 +84,8 @@ class NewsFeedPageModel {
}
}

Future<Result<None, GeneralError>> _loadNewsImpl() async {
// Returns loaded news
Future<Result<List<NewsPiece>, GeneralError>> _loadNewsImpl() async {
// Get news
final newsRes =
await _newsFeedManager.obtainNews(page: _lastLoadedNewsPage + 1);
Expand All @@ -67,8 +94,7 @@ class NewsFeedPageModel {
}
final newNews = newsRes.unwrap();
if (newNews.isEmpty) {
_allNewsLoaded = true;
return Ok(None());
return Ok(const []);
}

// Extract barcodes and shops UIDs
Expand Down Expand Up @@ -99,10 +125,6 @@ class NewsFeedPageModel {
}
_loadedShops.addAll(shopsRes.unwrap());

// Finish
final allNews = _newsPieces.cachedVal.toList();
allNews.addAll(newNews);
_newsPieces.setValue(allNews);
return Ok(None());
return Ok(newNews);
}
}
64 changes: 59 additions & 5 deletions test/ui/news/news_feed_page_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ void main() {
GetIt.I.registerSingleton<UserParamsController>(userParamsController);
});

Future<void> scrollDown(WidgetTester tester) async {
Future<void> scroll(WidgetTester tester, double yDiff) async {
// NOTE: we pause products retrieval before the scroll down
// and resume it after.
// This is needed because we expected new batches of products to be
Expand All @@ -101,13 +101,21 @@ void main() {
productsObtainer.pauseProductsRetrieval();
for (var i = 0; i < 50; ++i) {
await tester.drag(
find.byKey(const Key('news_pieces_list')), const Offset(0, -3000));
find.byKey(const Key('news_pieces_list')), Offset(0, yDiff));
await tester.pump();
}
productsObtainer.resumeProductsRetrieval();
await tester.pumpAndSettle();
}

Future<void> scrollDown(WidgetTester tester) async {
await scroll(tester, -3000);
}

Future<void> scrollUp(WidgetTester tester) async {
await scroll(tester, 3000);
}

Product createProduct(String name) {
lastBarcode += 1;
final product = ProductLangSlice((e) => e
Expand Down Expand Up @@ -276,9 +284,11 @@ void main() {
// And an error is also present
expect(
find.text(context.strings.global_something_went_wrong), findsOneWidget);
// And the first product of the second page is not present
// And the first+last products of the second page are not present
expect(find.text('Product ${newsFeedManager.pageSizeTesting + 1}'),
findsNothing);
expect(find.text('Product ${newsFeedManager.pageSizeTesting * 2}'),
findsNothing);

// Remove the error, retry
newsFeedManager.setErrorForPage_testing(1, null);
Expand All @@ -287,10 +297,11 @@ void main() {
// No error
expect(
find.text(context.strings.global_something_went_wrong), findsNothing);
// Both the last and the first product from pages 0 and 1 are present
// Both last products from pages 0 and 1 are present
expect(find.text('Product ${newsFeedManager.pageSizeTesting}'),
findsOneWidget);
expect(find.text('Product ${newsFeedManager.pageSizeTesting + 1}'),
await scrollDown(tester);
expect(find.text('Product ${newsFeedManager.pageSizeTesting * 2}'),
findsOneWidget);
});

Expand Down Expand Up @@ -350,4 +361,47 @@ void main() {
expect(
find.text(context.strings.global_something_went_wrong), findsNothing);
});

testWidgets('pull to refresh behaviour', (WidgetTester tester) async {
// Prepare 2 pages of products' news
for (var index = 1; index <= newsFeedManager.pageSizeTesting * 2; ++index) {
addProductToShop(createProduct('Product $index'),
createShop('Shop $index', OsmAddress((e) => e.road = 'Lenina')));
}

await tester.superPump(const NewsFeedPage());

// Both first of page 0 and last product of page 1 are present
expect(find.text('Product 1'), findsOneWidget);
await scrollDown(tester);
await scrollDown(tester);
expect(find.text('Product ${newsFeedManager.pageSizeTesting * 2}'),
findsOneWidget);

// Prepare absolutely new news
newsFeedManager.deleteAllNews_testing();
addProductToShop(createProduct('New product 1'),
createShop('New shop 1', OsmAddress((e) => e.road = 'Lenina')));
addProductToShop(createProduct('New product 2'),
createShop('New shop 2', OsmAddress((e) => e.road = 'Lenina')));

// Pull to refresh
await scrollUp(tester);
await scrollUp(tester);

// New news are visible
expect(find.text('New product 1'), findsOneWidget);
expect(find.text('New product 2'), findsOneWidget);

// Old news are not present, even from the second page
expect(find.text('Product 1'), findsNothing);
expect(find.text('Product ${newsFeedManager.pageSizeTesting * 2}'),
findsNothing);

// They were not present on the top, they are not present on the bottom
await scrollDown(tester);
expect(find.text('Product 1'), findsNothing);
expect(find.text('Product ${newsFeedManager.pageSizeTesting * 2}'),
findsNothing);
});
}
8 changes: 7 additions & 1 deletion test/z_fakes/fake_news_feed_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import 'package:plante/outside/backend/news/news_piece.dart';
class FakeNewsFeedManager implements NewsFeedManager {
final _news = <NewsPiece>[];
final _errorsForPages = <int, GeneralError>{};
final _obtainedPages = <int>[];
final int pageSizeTesting;

FakeNewsFeedManager({this.pageSizeTesting = 5});
FakeNewsFeedManager({this.pageSizeTesting = 10});

// ignore: non_constant_identifier_names
void setErrorForPage_testing(int page, GeneralError? error) {
Expand All @@ -29,9 +30,14 @@ class FakeNewsFeedManager implements NewsFeedManager {
_news.clear();
}

// ignore: non_constant_identifier_names
List<int> obtainedPages_testing() => List.unmodifiable(_obtainedPages);

@override
Future<Result<List<NewsPiece>, GeneralError>> obtainNews(
{required int page}) async {
_obtainedPages.add(page);

final error = _errorsForPages[page];
if (error != null) {
return Err(error);
Expand Down

0 comments on commit 7becf6d

Please sign in to comment.