diff --git a/lib/blocs/album_list_cubit.dart b/lib/blocs/album_list_cubit.dart index 38698ae9..8ef45d4d 100644 --- a/lib/blocs/album_list_cubit.dart +++ b/lib/blocs/album_list_cubit.dart @@ -19,12 +19,14 @@ class AlbumListCubit extends SingleListCubit { List combineDown( List downResults, ListState oldstate, - ) => oldstate.results + downResults; + ) => getResults(oldstate) + downResults; @override ListState empty(String? query) => switch (query) { - null => const ListState.failure(message: 'No albums found.'), - '' => const ListState.failure(message: 'Start searching for albums'), - var q => ListState.failure(message: 'No albums found found for query "$q"'), + null => const ErrorState('No albums found.'), + '' => const ResultState( + InnerListState.failure(message: 'Start searching for albums'), + ), + var q => ErrorState('No albums found found for query "$q"'), }; } diff --git a/lib/blocs/calendar_cubit.dart b/lib/blocs/calendar_cubit.dart index d19ecc98..64d005a5 100644 --- a/lib/blocs/calendar_cubit.dart +++ b/lib/blocs/calendar_cubit.dart @@ -357,7 +357,10 @@ class CalendarCubit extends ListCubit { ), '' => CalendarState( _truthTime, - const DoubleListState.failure(message: 'Start searching for events'), + const DoubleListState.failure( + message: 'Start searching for events', + retry: false, + ), ), var q => CalendarState( _truthTime, diff --git a/lib/blocs/event_cubit.dart b/lib/blocs/event_cubit.dart index 2254e0a0..134f9c60 100644 --- a/lib/blocs/event_cubit.dart +++ b/lib/blocs/event_cubit.dart @@ -4,96 +4,59 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; +import 'package:reaxit/blocs/detail_state.dart'; +import 'package:reaxit/blocs/list_cubit.dart'; +import 'package:reaxit/blocs/list_state.dart'; import 'package:reaxit/models.dart'; class EventState extends Equatable { /// The event, will only be null if event has not yet been loaded. final Event? event; - /// List of all the registrations. - final List registrations; - /// A message describing why there are no results. final String? message; /// Different results are being loaded. The results are outdated. final bool isLoading; - /// More of the same results are being loaded. The results are not outdated. - final bool isLoadingMore; - - /// The last results have been loaded. There are no more pages left. - final bool isDone; - const EventState({ required this.event, - required this.registrations, required this.isLoading, required this.message, - required this.isLoadingMore, - required this.isDone, }); bool get hasException => message != null; EventState copyWith({ Event? event, - List? registrations, String? message, bool? isLoading, - bool? isLoadingMore, bool? isDone, }) => EventState( event: event ?? this.event, - registrations: registrations ?? this.registrations, message: message ?? this.message, isLoading: isLoading ?? this.isLoading, - isLoadingMore: isLoadingMore ?? this.isLoadingMore, - isDone: isDone ?? this.isDone, ); @override - List get props => [ - event, - registrations, - message, - isLoading, - isLoadingMore, - isDone, - ]; + List get props => [event, message, isLoading]; @override String toString() { - return 'EventState(isLoading: $isLoading, isLoadingMore: $isLoadingMore,' - ' isDone: $isDone, message: $message, event: $event, registrations: $registrations)'; + return 'EventState(isLoading: $isLoading, message: $message, event: $event)'; } - const EventState.loading({this.event, required this.registrations}) - : message = null, - isLoading = true, - isLoadingMore = false, - isDone = true; + const EventState.loading({this.event}) : message = null, isLoading = true; - const EventState.loadingMore({this.event, required this.registrations}) + const EventState.loadingMore({this.event}) : message = null, - isLoading = false, - isLoadingMore = true, - isDone = true; - - const EventState.success({ - this.event, - required this.registrations, - required this.isDone, - }) : message = null, - isLoading = false, - isLoadingMore = false; + isLoading = false; + + const EventState.success({this.event}) : message = null, isLoading = false; const EventState.failure({required String this.message}) : event = null, - registrations = const [], - isLoading = false, - isLoadingMore = false, - isDone = true; + isLoading = false; } class EventCubit extends Cubit { @@ -101,17 +64,11 @@ class EventCubit extends Cubit { final String? _eventSlug; int? _eventPk; - static const int firstPageSize = 60; - static const int pageSize = 30; - - /// The offset to be used for the next paginated request. - int _nextOffset = 0; - EventCubit(this.api, {int? eventPk, String? eventSlug}) : assert(!(eventPk == null && eventSlug == null)), _eventSlug = eventSlug, _eventPk = eventPk, - super(const EventState.loading(registrations: [])); + super(const EventState.loading()); Future load() async { emit(state.copyWith(isLoading: true)); @@ -123,27 +80,11 @@ class EventCubit extends Cubit { _eventPk = event.pk; - final listResponse = await api.getEventRegistrations( - pk: _eventPk!, - limit: firstPageSize, - offset: 0, - ); - if (isClosed) { return; } - final isDone = listResponse.results.length == listResponse.count; - - _nextOffset = firstPageSize; - - emit( - EventState.success( - event: event, - registrations: listResponse.results, - isDone: isDone, - ), - ); + emit(EventState.success(event: event)); } on ApiException catch (exception) { if (isClosed) { // If the cubit is closed, the error does not matter at all @@ -186,39 +127,35 @@ class EventCubit extends Cubit { await api.thaliaPayRegistration(registrationPk: registrationPk); await load(); } +} - Future more() async { - final oldState = state; - - if (oldState.isDone || oldState.isLoading || oldState.isLoadingMore) return; +typedef EventListState = ListState; - emit(oldState.copyWith(isLoadingMore: true)); +class EventListCubit extends SingleListCubit { + final int _eventPk; - try { - var listResponse = await api.getEventRegistrations( - pk: _eventPk!, - limit: pageSize, - offset: _nextOffset, - ); + EventListCubit(super.api, this._eventPk); - final registrations = state.registrations + listResponse.results; - final isDone = registrations.length == listResponse.count; + static const int firstPageSize = 30; - _nextOffset += pageSize; + @override + Future> getDown(int offset) { + assert(searchQuery == null); // We cannot search registrations - emit( - EventState.success( - event: oldState.event, - registrations: registrations, - isDone: isDone, - ), - ); - } on ApiException catch (exception) { - emit( - EventState.failure( - message: exception.getMessage(notFound: 'The event does not exist.'), - ), - ); - } + return api.getEventRegistrations( + pk: _eventPk, + limit: firstPageSize, + offset: offset, + ); } + + @override + List combineDown( + List downResults, + ListState oldstate, + ) => getResults(oldstate) + downResults; + + @override + ListState empty(String? query) => + const ErrorState('No registrations found.'); } diff --git a/lib/blocs/groups_cubit.dart b/lib/blocs/groups_cubit.dart index d9659ed6..5984ba9d 100644 --- a/lib/blocs/groups_cubit.dart +++ b/lib/blocs/groups_cubit.dart @@ -16,13 +16,15 @@ class GroupsCubit extends SingleListCubit { List combineDown( List downResults, ListState oldstate, - ) => oldstate.results + downResults; + ) => getResults(oldstate) + downResults; @override ListState empty(String? query) => switch (query) { - null => const ListState.failure(message: 'No groups found.'), - '' => const ListState.failure(message: 'Start searching for groups'), - var q => ListState.failure(message: 'No groups found found for query "$q"'), + null => const ErrorState('No groups found.'), + '' => const ResultState( + InnerListState.failure(message: 'Start searching for groups'), + ), + var q => ErrorState('No groups found found for query "$q"'), }; @override diff --git a/lib/blocs/liked_photos_cubit.dart b/lib/blocs/liked_photos_cubit.dart index 4078b252..7a2af55e 100644 --- a/lib/blocs/liked_photos_cubit.dart +++ b/lib/blocs/liked_photos_cubit.dart @@ -1,106 +1,77 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; import 'package:reaxit/blocs.dart'; +import 'package:reaxit/blocs/list_cubit.dart'; import 'package:reaxit/models.dart'; import 'package:reaxit/ui/widgets/gallery.dart'; typedef LikedPhotosState = ListState; -class LikedPhotosCubit extends Cubit +class LikedPhotosCubit extends SingleListCubit implements GalleryCubit { static const int firstPageSize = 60; static const int pageSize = 30; - final ApiRepository api; + LikedPhotosCubit(super.api); - int _nextOffset = 0; + int extraOffset = 0; - LikedPhotosCubit(this.api) - : super(const LikedPhotosState.loading(results: [])); - - Future load() async { - emit(state.copyWith(isLoading: true)); - try { - final photos = await api.getLikedPhotos(limit: firstPageSize, offset: 0); - - final isDone = photos.results.length == photos.count; - - _nextOffset = firstPageSize; - - emit( - LikedPhotosState.success( - results: photos.results, - isDone: isDone, - count: photos.count, - ), - ); - } on ApiException catch (exception) { - emit(LikedPhotosState.failure(message: exception.message)); - } + @override + Future> getDown(int offset) { + assert(searchQuery == null); // We cannot search photos + return api.getLikedPhotos( + limit: firstPageSize, + offset: offset + extraOffset, + ); } @override - Future more() async { - final oldState = state; - - // Ignore calls to `more()` if there is no data, or already more coming. - if (oldState.isDone || oldState.isLoading || oldState.isLoadingMore) return; - - emit(oldState.copyWith(isLoadingMore: true)); - try { - final photosResponse = await api.getLikedPhotos( - limit: pageSize, - offset: _nextOffset, - ); - - final photos = state.results + photosResponse.results; - final isDone = photos.length >= photosResponse.count; - - _nextOffset += pageSize; + List combineDown( + List downResults, + ListState oldstate, + ) => getResults(oldstate) + downResults; - emit( - LikedPhotosState.success( - results: photos, - isDone: isDone, - count: photosResponse.count, - ), - ); - } on ApiException catch (exception) { - emit(LikedPhotosState.failure(message: exception.message)); - } - } + @override + ListState empty(String? query) => + const ErrorState('No liked photos found.'); @override Future updateLike({required bool liked, required int index}) async { - assert(index < state.results.length); - if (state.isLoading) return; - - final oldState = state; - final oldPhoto = oldState.results[index]; - - if (oldPhoto.liked == liked) return; - - // Emit expected state after (un)liking. - AlbumPhoto newphoto = oldPhoto.copyWith( - liked: liked, - numLikes: oldPhoto.numLikes + (liked ? 1 : -1), - ); - - List newphotos = state.results; - newphotos[index] = newphoto; - - emit(state.copyWith(results: newphotos)); - - try { - await api.updateLiked(newphoto.pk, liked); - // If a photo is succesfully unliked, the offset should decrease by 1 - // so the next page is loaded correctly, and vice-versa. - _nextOffset += liked ? 1 : -1; - } on ApiException { - // Revert to state before (un)liking. - emit(oldState); - rethrow; + switch (super.state) { + case LoadingState(): + case ErrorState(): + return; + case ResultState>(result: final state): + assert(index < state.results.length); + + final oldState = super.state; + final oldPhoto = state.results[index]; + + if (oldPhoto.liked == liked) return; + + // Emit expected state after (un)liking. + AlbumPhoto newphoto = oldPhoto.copyWith( + liked: liked, + numLikes: oldPhoto.numLikes + (liked ? 1 : -1), + ); + + List newphotos = state.results; + newphotos[index] = newphoto; + + emit( + ResultState(state.copyWith(results: newphotos, isDone: state.isDone)), + ); + + try { + await api.updateLiked(newphoto.pk, liked); + + // If a photo is succesfully unliked, the offset should decrease by 1 + // so the next page is loaded correctly. + extraOffset += liked ? 1 : -1; + } on ApiException { + // Revert to state before (un)liking. + emit(oldState); + rethrow; + } } } } diff --git a/lib/blocs/list_cubit.dart b/lib/blocs/list_cubit.dart index 9fe97cce..4b114a38 100644 --- a/lib/blocs/list_cubit.dart +++ b/lib/blocs/list_cubit.dart @@ -149,7 +149,7 @@ abstract class ListCubit extends Cubit { } if (upResults.isEmpty && downResults.isEmpty) { - safeEmit(empty(query ?? '')); + safeEmit(empty(query)); } else { safeEmit( newState( @@ -354,8 +354,14 @@ class SingleListCubitSource extends ListCubitSource { SingleListCubit cubit; SingleListCubitSource(this.cubit); + int count = 0; + @override - Future> getDown(int offset) => cubit.getDown(offset); + Future> getDown(int offset) => + cubit.getDown(offset).then((value) { + count = value.count; + return value; + }); @override Future> getUp(int offset) async => ListResponse(0, []); @@ -379,8 +385,7 @@ abstract class SingleListCubit extends ListCubit> { @override List> get sources => _sources; - SingleListCubit(ApiRepository api) - : super(api, const ListState.loading(results: [])); + SingleListCubit(ApiRepository api) : super(api, LoadingState()); Future> getDown(int offset); @@ -395,23 +400,26 @@ abstract class SingleListCubit extends ListCubit> { @override bool canLoadMoreDown(ListState oldstate) => - !oldstate.isDone && !oldstate.isLoading && !oldstate.isLoadingMore; + !isDone(oldstate) && !isLoading(oldstate) && !isLoadingMore(oldstate); @override bool canLoadMoreUp(ListState oldstate) => false; @override - ListState loading() => const ListState.loading(results: []); + ListState loading() => const LoadingState(); @override ListState loadingUp(ListState oldstate) => oldstate; @override - ListState loadingDown(ListState oldstate) => - oldstate.copyWith(isLoadingMore: true); + ListState loadingDown(ListState oldstate) => switch (oldstate) { + ResultState>(result: final state) => + ResultState>(state.copyWith(isLoadingMore: true)), + _ => oldstate, + }; @override - ListState failure(String message) => ListState.failure(message: message); + ListState failure(String message) => ErrorState(message); @override ListState newState({ @@ -419,7 +427,13 @@ abstract class SingleListCubit extends ListCubit> { List resultsDown = const [], required bool isDoneUp, required bool isDoneDown, - }) => ListState.success(results: resultsDown, isDone: isDoneDown); + }) => ResultState( + InnerListState.success( + results: resultsDown, + isDone: isDoneDown, + count: (_sources[0] as SingleListCubitSource).count, + ), + ); @override ListState updateUp( @@ -433,7 +447,13 @@ abstract class SingleListCubit extends ListCubit> { ListState oldstate, List downResults, bool isDoneDown, - ) => oldstate.copyWith(results: downResults, isDone: isDoneDown); + ) => switch (oldstate) { + ResultState>(result: final state) => + ResultState>( + state.copyWith(results: downResults, isDone: isDoneDown), + ), + _ => oldstate, + }; Future more() => moreDown(); @@ -442,4 +462,25 @@ abstract class SingleListCubit extends ListCubit> { @override List mergeDown(List> results) => results[0]; + + static bool isDone(ListState state) => switch (state) { + ResultState(result: final state) => state.isDone, + ErrorState _ => true, + LoadingState _ => false, + }; + + static bool isLoading(ListState state) => switch (state) { + LoadingState _ => true, + _ => false, + }; + + static bool isLoadingMore(ListState state) => switch (state) { + ResultState(result: final state) => state.isLoadingMore, + _ => false, + }; + + List getResults(ListState state) => switch (state) { + ResultState>(result: final state) => state.results, + _ => [], + }; } diff --git a/lib/blocs/list_state.dart b/lib/blocs/list_state.dart index 0d08c6ec..b4abb5f5 100644 --- a/lib/blocs/list_state.dart +++ b/lib/blocs/list_state.dart @@ -1,7 +1,8 @@ import 'package:equatable/equatable.dart'; +import 'package:reaxit/blocs.dart'; /// Generic class to be used as state for paginated lists. -class ListState extends Equatable { +class InnerListState extends Equatable { /// The results to be shown. These are outdated if `isLoading` is true. final List results; @@ -17,11 +18,11 @@ class ListState extends Equatable { /// The last results have been loaded. There are no more pages left. final bool isDone; - final int? count; + final int count; bool get hasException => message != null; - const ListState({ + const InnerListState({ required this.results, required this.message, required this.isLoading, @@ -30,14 +31,15 @@ class ListState extends Equatable { required this.count, }); - ListState copyWith({ + InnerListState copyWith({ List? results, String? message, + bool? retry, bool? isLoading, bool? isLoadingMore, bool? isDone, int? count, - }) => ListState( + }) => InnerListState( results: results ?? this.results, message: message ?? this.message, isLoading: isLoading ?? this.isLoading, @@ -61,28 +63,22 @@ class ListState extends Equatable { ' isDone: $isDone, message: $message, ${results.length} ${T}s)'; } - const ListState.loading({required this.results}) + const InnerListState.loading({required this.results}) : message = null, isLoading = true, isLoadingMore = false, isDone = true, count = 0; - const ListState.loadingMore({this.count, required this.results}) - : message = null, - isLoading = false, - isLoadingMore = true, - isDone = true; - - const ListState.success({ + const InnerListState.success({ required this.results, required this.isDone, - this.count, + required this.count, }) : message = null, isLoading = false, isLoadingMore = false; - const ListState.failure({required String this.message}) + const InnerListState.failure({required String this.message}) : results = const [], isLoading = false, isLoadingMore = false, @@ -90,6 +86,9 @@ class ListState extends Equatable { count = 0; } +typedef ListState = DetailState>; + +// TODO: refactor this into a DetailState and unify all listviews class DoubleListState extends Equatable { /// The results to be shown in the up directoin. These are outdated if /// `isLoading` is true. @@ -102,6 +101,10 @@ class DoubleListState extends Equatable { /// A message describing why there are no results. final String? message; + /// In case of a message, indicate wether or not to show the + /// retry button + final bool? retry; + /// Different results are being loaded. The results are outdated. final bool isLoading; @@ -127,6 +130,7 @@ class DoubleListState extends Equatable { required this.resultsUp, required this.resultsDown, required this.message, + required this.retry, required this.isLoading, required this.isLoadingMoreUp, required this.isLoadingMoreDown, @@ -138,6 +142,7 @@ class DoubleListState extends Equatable { List? resultsUp, List? resultsDown, String? message, + bool? retry, bool? isLoading, bool? isLoadingMoreUp, bool? isLoadingMoreDown, @@ -147,6 +152,7 @@ class DoubleListState extends Equatable { resultsUp: resultsUp ?? this.resultsUp, resultsDown: resultsDown ?? this.resultsDown, message: message ?? this.message, + retry: retry ?? this.retry, isLoading: isLoading ?? this.isLoading, isLoadingMoreUp: isLoadingMoreUp ?? this.isLoadingMoreUp, isLoadingMoreDown: isLoadingMoreDown ?? this.isLoadingMoreDown, @@ -177,6 +183,7 @@ class DoubleListState extends Equatable { : resultsUp = const [], resultsDown = const [], message = null, + retry = null, isLoading = true, isLoadingMoreUp = false, isLoadingMoreDown = false, @@ -189,11 +196,12 @@ class DoubleListState extends Equatable { required this.isDoneUp, required this.isDoneDown, }) : message = null, + retry = null, isLoading = false, isLoadingMoreUp = false, isLoadingMoreDown = false; - const DoubleListState.failure({required String this.message}) + const DoubleListState.failure({required String this.message, this.retry}) : resultsUp = const [], resultsDown = const [], isLoading = false, diff --git a/lib/blocs/member_list_cubit.dart b/lib/blocs/member_list_cubit.dart index 0a84ce32..bcc34186 100644 --- a/lib/blocs/member_list_cubit.dart +++ b/lib/blocs/member_list_cubit.dart @@ -26,15 +26,15 @@ class MemberListCubit extends SingleListCubit { List combineDown( List downResults, ListState oldstate, - ) => oldstate.results + downResults; + ) => getResults(oldstate) + downResults; @override ListState empty(String? query) => switch (query) { - null => const ListState.failure(message: 'No members found.'), - '' => const ListState.failure(message: 'Start searching for members'), - var q => ListState.failure( - message: 'No members found found for query "$q"', + null => const ErrorState('No members found.'), + '' => const ResultState( + InnerListState.failure(message: 'Start searching for members'), ), + var q => ErrorState('No members found found for query "$q"'), }; /// Set this cubit's `searchQuery` and load the albums for that query. @@ -42,7 +42,11 @@ class MemberListCubit extends SingleListCubit { /// Use `null` as argument to remove the search query. void filterYear(int? year) { if (year != this.year) { - safeEmit(ListState.loading(results: state.results)); + if (state is ErrorState) { + safeEmit(LoadingErrorState(state.message!)); + } else { + safeEmit(LoadingState.from(state)); + } this.year = year; load(); } diff --git a/lib/blocs/thabloid_list_cubit.dart b/lib/blocs/thabloid_list_cubit.dart index 32f7d142..f23745da 100644 --- a/lib/blocs/thabloid_list_cubit.dart +++ b/lib/blocs/thabloid_list_cubit.dart @@ -5,7 +5,7 @@ import 'package:reaxit/blocs.dart'; import 'package:reaxit/models.dart'; import 'package:reaxit/models/thabloid.dart'; -typedef ThabloidListState = ListState; +typedef ThabloidListState = InnerListState; class ThabloidListCubit extends SingleListCubit { ThabloidListCubit(super.api); @@ -23,10 +23,10 @@ class ThabloidListCubit extends SingleListCubit { List combineDown( List downResults, ListState oldstate, - ) => oldstate.results + downResults; + ) => getResults(oldstate) + downResults; @override ListState empty(String? query) { - return const ListState.failure(message: 'No thabloids found.'); + return const ErrorState('No thabloids found.'); } } diff --git a/lib/blocs/vacancies_cubit.dart b/lib/blocs/vacancies_cubit.dart index c2bf3a65..ada8eeae 100644 --- a/lib/blocs/vacancies_cubit.dart +++ b/lib/blocs/vacancies_cubit.dart @@ -5,7 +5,7 @@ import 'package:reaxit/blocs/list_cubit.dart'; import 'package:reaxit/models.dart'; import 'package:reaxit/models/vacancie.dart'; -typedef VacanciesState = ListState; +typedef VacanciesState = InnerListState; class VacanciesListCubit extends SingleListCubit { VacanciesListCubit(super.api); @@ -14,15 +14,15 @@ class VacanciesListCubit extends SingleListCubit { List combineDown( List downResults, ListState oldstate, - ) => oldstate.results + downResults; + ) => getResults(oldstate) + downResults; @override ListState empty(String? query) => switch (query) { - null => const ListState.failure(message: 'No vacancies found.'), - '' => const ListState.failure(message: 'Start searching for vacancies'), - var q => ListState.failure( - message: 'No vacancies found found for query "$q"', + null => const ErrorState('No vacancies found.'), + '' => const ResultState( + InnerListState.failure(message: 'Start searching for vacancies'), ), + var q => ErrorState('No vacancies found found for query "$q"'), }; @override diff --git a/lib/routes.dart b/lib/routes.dart index 71804371..bb856d82 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -257,7 +257,7 @@ final List routes = [ path: 'liked-photos', name: 'liked-photos', pageBuilder: (context, state) => - MaterialPage(key: state.pageKey, child: const LikedPhotosScreen()), + MaterialPage(key: state.pageKey, child: LikedPhotosScreen()), ), GoRoute( path: ':albumSlug', diff --git a/lib/ui/screens/albums_screen.dart b/lib/ui/screens/albums_screen.dart index 5a4eb4ec..d32a7f04 100644 --- a/lib/ui/screens/albums_screen.dart +++ b/lib/ui/screens/albums_screen.dart @@ -4,7 +4,9 @@ import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:reaxit/blocs.dart'; import 'package:reaxit/api/api_repository.dart'; +import 'package:reaxit/models/album.dart'; import 'package:reaxit/ui/widgets.dart'; +import 'package:reaxit/ui/widgets/paginated_scroll_view.dart'; class AlbumsScreen extends StatefulWidget { @override @@ -12,32 +14,14 @@ class AlbumsScreen extends StatefulWidget { } class _AlbumsScreenState extends State { - late ScrollController _controller; late AlbumListCubit _cubit; @override void initState() { _cubit = BlocProvider.of(context); - _controller = ScrollController()..addListener(_scrollListener); super.initState(); } - void _scrollListener() { - if (_controller.position.pixels >= - _controller.position.maxScrollExtent - 300) { - // Only request loading more if that's not already happening. - if (!_cubit.state.isLoadingMore) { - _cubit.more(); - } - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { return Scaffold( @@ -68,18 +52,9 @@ class _AlbumsScreenState extends State { onRefresh: () async { await _cubit.load(); }, - child: BlocBuilder( - builder: (context, listState) { - if (listState.hasException) { - return ErrorScrollView(listState.message!, retry: _cubit.load); - } else { - return AlbumListScrollView( - key: const PageStorageKey('albums'), - controller: _controller, - listState: listState, - ); - } - }, + child: AlbumListScrollView( + key: const PageStorageKey('albums'), + cubit: _cubit, ), ), ); @@ -87,25 +62,9 @@ class _AlbumsScreenState extends State { } class AlbumsSearchDelegate extends SearchDelegate { - late final ScrollController _controller; final AlbumListCubit _cubit; - AlbumsSearchDelegate(this._cubit) { - _controller = ScrollController()..addListener(_scrollListener); - } - - void _scrollListener() { - if (!_controller.hasClients) { - return; - } - if (_controller.position.pixels >= - _controller.position.maxScrollExtent - 300) { - // Only request loading more if that's not already happening. - if (!_cubit.state.isLoadingMore) { - _cubit.more(); - } - } - } + AlbumsSearchDelegate(this._cubit); @override ThemeData appBarTheme(BuildContext context) { @@ -144,96 +103,44 @@ class AlbumsSearchDelegate extends SearchDelegate { @override Widget buildResults(BuildContext context) { - return BlocBuilder( - bloc: _cubit..search(query), - builder: (context, listState) { - if (listState.hasException) { - return ErrorScrollView( - listState.message!, - retry: () => _cubit.search(query), - ); - } else { - return AlbumListScrollView( - key: const PageStorageKey('albums-search'), - controller: _controller, - listState: listState, - ); - } - }, + _cubit.search(query); + return AlbumListScrollView( + key: const PageStorageKey('albums-search'), + cubit: _cubit, ); } @override Widget buildSuggestions(BuildContext context) { - return BlocBuilder( - bloc: _cubit..search(query), - builder: (context, listState) { - if (listState.hasException) { - return ErrorScrollView( - listState.message!, - retry: () => _cubit.search(query), - ); - } else { - return AlbumListScrollView( - key: const PageStorageKey('albums-search'), - controller: _controller, - listState: listState, - ); - } - }, - ); + return buildResults(context); } } /// A ScrollView that shows a grid of [AlbumTile]s. -/// -/// This does not take care of communicating with a Bloc. The [controller] -/// should do that. The [listState] also must not have an exception. -class AlbumListScrollView extends StatelessWidget { - final ScrollController controller; - final AlbumListState listState; - - const AlbumListScrollView({ - super.key, - required this.controller, - required this.listState, - }); - - @override - Widget build(BuildContext context) { - return Scrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - physics: const RangeMaintainingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(8), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => AlbumTile(album: listState.results[index]), - childCount: listState.results.length, - ), - ), +class AlbumListScrollView + extends PaginatedScrollView { + const AlbumListScrollView({super.key, super.cubit}) + : super(resultsBuilder: buildResults); + + static List buildResults( + BuildContext context, + List albums, + ) { + return [ + SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, ), - if (listState.isLoadingMore) - const SliverPadding( - padding: EdgeInsets.all(8), - sliver: SliverList( - delegate: SliverChildListDelegate.fixed([ - Center(child: CircularProgressIndicator()), - ]), - ), - ), - ], + delegate: SliverChildBuilderDelegate( + (context, index) => AlbumTile(album: albums[index]), + childCount: albums.length, + ), + ), ), - ); + ]; } } diff --git a/lib/ui/screens/event_screen.dart b/lib/ui/screens/event_screen.dart index fed55db9..17efb351 100644 --- a/lib/ui/screens/event_screen.dart +++ b/lib/ui/screens/event_screen.dart @@ -14,6 +14,7 @@ import 'package:reaxit/routes.dart'; import 'package:reaxit/ui/widgets.dart'; import 'package:reaxit/ui/widgets/dialog.dart'; import 'package:reaxit/ui/widgets/file_button.dart'; +import 'package:reaxit/ui/widgets/paginated_scroll_view.dart'; import 'package:reaxit/ui/widgets/timed_state_enable.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -34,8 +35,6 @@ class EventScreen extends StatefulWidget { class _EventScreenState extends State { static final dateTimeFormatter = DateFormat('E d MMM y, HH:mm'); - late final ScrollController _controller; - late final EventCubit _eventCubit; @override @@ -43,25 +42,12 @@ class _EventScreenState extends State { final api = RepositoryProvider.of(context); _eventCubit = EventCubit(api, eventPk: widget.pk, eventSlug: widget.slug) ..load(); - - _controller = ScrollController()..addListener(_scrollListener); super.initState(); } - void _scrollListener() { - if (_controller.position.pixels >= - _controller.position.maxScrollExtent - 300) { - // Only request loading more if that's not already happening. - if (!_eventCubit.state.isLoadingMore) { - _eventCubit.more(); - } - } - } - @override void dispose() { _eventCubit.close(); - _controller.dispose(); super.dispose(); } @@ -871,7 +857,7 @@ class _EventScreenState extends State { ); } - SliverPadding _makeRegistrations(EventState state) { + SliverPadding _makeRegistrations(List registrations) { return SliverPadding( padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 16), sliver: SliverGrid( @@ -881,12 +867,12 @@ class _EventScreenState extends State { crossAxisSpacing: 8, ), delegate: SliverChildBuilderDelegate((context, index) { - if (state.registrations[index].member != null) { - return MemberTile(member: state.registrations[index].member!); + if (registrations[index].member != null) { + return MemberTile(member: registrations[index].member!); } else { - return DefaultMemberTile(name: state.registrations[index].name!); + return DefaultMemberTile(name: registrations[index].name!); } - }, childCount: state.registrations.length), + }, childCount: registrations.length), ), ); } @@ -901,12 +887,7 @@ class _EventScreenState extends State { appBar: ThaliaAppBar( title: Text(widget.event?.title.toUpperCase() ?? 'EVENT'), ), - body: RefreshIndicator( - onRefresh: () async { - await _eventCubit.load(); - }, - child: ErrorScrollView(state.message!, retry: _eventCubit.load), - ), + body: ErrorScrollView(state.message!, retry: _eventCubit.load), ); } else if (state.isLoading && widget.event == null) { return Scaffold( @@ -915,93 +896,91 @@ class _EventScreenState extends State { ); } else { final event = (state.event ?? widget.event)!; + + final api = RepositoryProvider.of(context); + + final List actions = [ + IconAppbarAction( + 'EXPORT', + Icons.edit_calendar_outlined, + () async { + final exportableEvent = add2calendar.Event( + title: event.title, + location: event.location, + startDate: event.start, + endDate: event.end, + ); + await add2calendar.Add2Calendar.addEvent2Cal(exportableEvent); + }, + tooltip: 'add event to calendar', + ), + IconAppbarAction( + 'SHARE', + Theme.of(context).platform == TargetPlatform.iOS + ? Icons.ios_share + : Icons.share, + () async { + final messenger = ScaffoldMessenger.of(context); + try { + await SharePlus.instance.share( + ShareParams(uri: Uri.tryParse(event.url)), + ); + } catch (_) { + messenger.showSnackBar( + const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text('Could not share the event.'), + ), + ); + } + }, + ), + if (event.userPermissions.manageEvent) + IconAppbarAction( + 'EDIT', + Icons.settings, + () => context.pushNamed( + 'event-admin', + pathParameters: {'eventPk': event.pk.toString()}, + ), + ), + ]; + + final slivers = [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _makeMap(event), + const Divider(height: 0), + _makeEventInfo(event), + const Divider(), + _makeDescription(event), + ], + ), + ), + const SliverToBoxAdapter(child: Divider()), + _makeRegistrationsHeader(), + ]; + return Scaffold( appBar: ThaliaAppBar( title: Text(event.title.toUpperCase()), - collapsingActions: [ - IconAppbarAction( - 'EXPORT', - Icons.edit_calendar_outlined, - () async { - final exportableEvent = add2calendar.Event( - title: event.title, - location: event.location, - startDate: event.start, - endDate: event.end, - ); - await add2calendar.Add2Calendar.addEvent2Cal( - exportableEvent, - ); - }, - tooltip: 'add event to calendar', - ), - IconAppbarAction( - 'SHARE', - Theme.of(context).platform == TargetPlatform.iOS - ? Icons.ios_share - : Icons.share, - () async { - final messenger = ScaffoldMessenger.of(context); - try { - await SharePlus.instance.share( - ShareParams(uri: Uri.tryParse(event.url)), - ); - } catch (_) { - messenger.showSnackBar( - const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Could not share the event.'), - ), - ); - } - }, - ), - if (event.userPermissions.manageEvent) - IconAppbarAction( - 'EDIT', - Icons.settings, - () => context.pushNamed( - 'event-admin', - pathParameters: {'eventPk': event.pk.toString()}, - ), - ), - ], + collapsingActions: actions, ), body: RefreshIndicator( onRefresh: () async { await _eventCubit.load(); }, - child: Scrollbar( - controller: _controller, - child: CustomScrollView( - controller: _controller, - key: const PageStorageKey('event'), - slivers: [ - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _makeMap(event), - const Divider(height: 0), - _makeEventInfo(event), - const Divider(), - _makeDescription(event), - ], - ), - ), - const SliverToBoxAdapter(child: Divider()), - _makeRegistrationsHeader(), - _makeRegistrations(state), - if (state.isLoading || state.isLoadingMore) ...[ - const SliverPadding( - padding: EdgeInsets.all(8), - sliver: SliverList( - delegate: SliverChildListDelegate.fixed([ - Center(child: CircularProgressIndicator()), - ]), - ), - ), - ], + child: BlocProvider( + create: (_) => EventListCubit(api, event.pk)..load(), + lazy: false, + child: PaginatedScrollView( + loadingBuilder: (context) => slivers, + errorBuilder: (context, _) => slivers, + resultsBuilder: (context, registrations) => [ + ...slivers, + _makeRegistrations(registrations), ], ), ), diff --git a/lib/ui/screens/groups_screen.dart b/lib/ui/screens/groups_screen.dart index 4eef3937..512ab5e3 100644 --- a/lib/ui/screens/groups_screen.dart +++ b/lib/ui/screens/groups_screen.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:reaxit/blocs.dart'; import 'package:reaxit/blocs/groups_cubit.dart'; import 'package:reaxit/models/group.dart'; import 'package:reaxit/ui/widgets.dart'; import 'package:collection/collection.dart'; import 'package:reaxit/api/api_repository.dart'; +import 'package:reaxit/ui/widgets/paginated_scroll_view.dart'; class GroupsScreen extends StatefulWidget { final MemberGroupType? currentScreen; @@ -87,102 +89,56 @@ class _GroupsScreenState extends State body: TabBarView( controller: _tabController, children: [ - BlocBuilder( - builder: (context, state) { - final cubit = context.read(); - if (state.message != null) { - return ErrorScrollView(state.message!, retry: cubit.load); - } else if (state.isLoading) { - return const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ); - } else { - return GroupListScrollView(groups: state.results); - } - }, - ), - BlocBuilder( - builder: (context, state) { - final cubit = context.read(); - if (state.message != null) { - return ErrorScrollView(state.message!, retry: cubit.load); - } else if (state.isLoading) { - return const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ); - } else { - return GroupListScrollView(groups: state.results); - } - }, - ), - BlocBuilder( - builder: (context, state) { - final cubit = context.read(); - if (state.message != null) { - return ErrorScrollView(state.message!, retry: cubit.load); - } else if (state.isLoading) { - return const Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ); - } else { - return GroupListScrollView(groups: state.results); - } - }, - ), + GroupListScrollView(), + GroupListScrollView(), + GroupListScrollView(), ], ), ); } } -class GroupListScrollView extends StatelessWidget { - final List groups; - final ListGroup? activeBoard; +/// A ScrollView that shows a grid of [GroupTile]s. - GroupListScrollView({super.key, required List groups}) - : activeBoard = groups.firstWhereOrNull( - (element) => element.isActiveBoard(), - ), - groups = groups.where((element) => !element.isActiveBoard()).toList(); +class GroupListScrollView + extends PaginatedScrollView { + const GroupListScrollView({super.key, super.cubit}) + : super(resultsBuilder: buildResults); - @override - Widget build(BuildContext context) { - return Scrollbar( - child: CustomScrollView( - physics: const RangeMaintainingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - if (activeBoard != null) - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(8), - child: AspectRatio( - aspectRatio: 3 / 2, - child: GroupTile(group: activeBoard!), - ), - ), - ), - SliverPadding( + static List buildResults( + BuildContext context, + List groups, + ) { + final ListGroup? activeBoard = groups.firstWhereOrNull( + (element) => element.isActiveBoard(), + ); + groups = groups.where((element) => !element.isActiveBoard()).toList(); + return [ + if (activeBoard != null) + SliverToBoxAdapter( + child: Padding( padding: const EdgeInsets.all(8), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => GroupTile(group: groups[index]), - childCount: groups.length, - ), + child: AspectRatio( + aspectRatio: 3 / 2, + child: GroupTile(group: activeBoard), ), ), - ], + ), + SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => GroupTile(group: groups[index]), + childCount: groups.length, + ), + ), ), - ); + ]; } } @@ -228,26 +184,10 @@ class GroupSearchDelegate extends SearchDelegate { @override Widget buildResults(BuildContext context) { - return BlocBuilder( - bloc: _cubit..search(query), - builder: (context, state) { - if (state.message != null) { - return ErrorScrollView( - state.message!, - retry: () => _cubit.search(query), - ); - } else if (state.isLoading) { - return GroupListScrollView( - key: const PageStorageKey('groups-search'), - groups: const [], - ); - } else { - return GroupListScrollView( - key: const PageStorageKey('groups-search'), - groups: state.results, - ); - } - }, + _cubit.search(query); + return GroupListScrollView( + cubit: _cubit, + key: const PageStorageKey('groups-search'), ); } diff --git a/lib/ui/screens/liked_photos_screen.dart b/lib/ui/screens/liked_photos_screen.dart index 6dc06874..240f7f0f 100644 --- a/lib/ui/screens/liked_photos_screen.dart +++ b/lib/ui/screens/liked_photos_screen.dart @@ -1,79 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reaxit/api/api_repository.dart'; +import 'package:reaxit/blocs.dart'; import 'package:reaxit/blocs/liked_photos_cubit.dart'; -import 'package:reaxit/ui/widgets.dart'; +import 'package:reaxit/models/photo.dart'; import 'package:reaxit/ui/widgets/gallery.dart'; +import 'package:reaxit/ui/widgets/paginated_scroll_view.dart'; import 'package:reaxit/ui/widgets/photo_tile.dart'; -class LikedPhotosScreen extends StatefulWidget { - const LikedPhotosScreen(); - - @override - State createState() => _LikedPhotosScreenState(); -} - -class _LikedPhotosScreenState extends State { - late ScrollController _controller; - late final LikedPhotosCubit _cubit; - - @override - void initState() { - _controller = ScrollController()..addListener(_scrollListener); - _cubit = LikedPhotosCubit(RepositoryProvider.of(context)) - ..load(); - super.initState(); - } - - void _scrollListener() { - if (_controller.position.pixels >= - _controller.position.maxScrollExtent - 300) { - _cubit.more(); - } - } - - @override - void dispose() { - _controller.dispose(); - _cubit.close(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return BlocProvider.value( - value: _cubit, - child: Scaffold( - appBar: ThaliaAppBar(title: const Text('LIKED PHOTOS')), - body: RefreshIndicator( - onRefresh: _cubit.load, - child: BlocBuilder( - builder: (context, state) { - if (state.hasException) { - return ErrorScrollView(state.message!, retry: _cubit.load); - } else { - return _PhotoGridScrollView( - controller: _controller, - listState: state, - ); - } - }, - ), - ), - ), - ); - } -} - -class _PhotoGridScrollView extends StatelessWidget { - final ScrollController controller; - final LikedPhotosState listState; - - const _PhotoGridScrollView({ - required this.controller, - required this.listState, - }); - +class LikedPhotosScreen extends StatelessWidget { void _openGallery(BuildContext context, int index) { final cubit = BlocProvider.of(context); showDialog( @@ -84,13 +19,28 @@ class _PhotoGridScrollView extends StatelessWidget { return BlocProvider.value( value: cubit, child: BlocBuilder( - buildWhen: (previous, current) => - !current.isLoading && !current.isLoadingMore, + buildWhen: (previous, current) => switch (current) { + LoadingState _ || LoadingResultState _ => false, + _ => true, + }, builder: (context, state) { + late final List results; + late final int count; + switch (state) { + case ResultState( + result: InnerListState(results: final res, count: final cnt), + ): + results = res; + count = cnt; + case _: + results = []; + count = 0; + } + return Gallery( - photos: state.results, + photos: results, initialPage: index, - photoAmount: state.count!, + photoAmount: count, ); }, ), @@ -101,40 +51,26 @@ class _PhotoGridScrollView extends StatelessWidget { @override Widget build(BuildContext context) { - return Scrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - physics: const RangeMaintainingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(8), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => PhotoTile( - photo: listState.results[index], - openGallery: () => _openGallery(context, index), - ), - childCount: listState.results.length, - ), + return BlocProvider( + create: (context) => + LikedPhotosCubit(RepositoryProvider.of(context)) + ..load(), + child: PaginatedScrollView( + resultsBuilder: (context, photos) => [ + SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, ), - ), - if (listState.isLoadingMore) - const SliverPadding( - padding: EdgeInsets.all(8), - sliver: SliverList( - delegate: SliverChildListDelegate.fixed([ - Center(child: CircularProgressIndicator()), - ]), + delegate: SliverChildBuilderDelegate( + (context, index) => PhotoTile( + photo: photos[index], + openGallery: () => _openGallery(context, index), ), + childCount: photos.length, ), + ), ], ), ); diff --git a/lib/ui/screens/members_screen.dart b/lib/ui/screens/members_screen.dart index 361e0e21..39f93250 100644 --- a/lib/ui/screens/members_screen.dart +++ b/lib/ui/screens/members_screen.dart @@ -3,42 +3,14 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/blocs.dart'; +import 'package:reaxit/models/member.dart'; import 'package:reaxit/ui/widgets.dart'; +import 'package:reaxit/ui/widgets/paginated_scroll_view.dart'; -class MembersScreen extends StatefulWidget { - @override - State createState() => _MembersScreenState(); -} - -class _MembersScreenState extends State { - late ScrollController _controller; - late MemberListCubit _cubit; - - @override - void initState() { - _cubit = BlocProvider.of(context); - _controller = ScrollController()..addListener(_scrollListener); - super.initState(); - } - - void _scrollListener() { - if (_controller.position.pixels >= - _controller.position.maxScrollExtent - 300) { - // Only request loading more if that's not already happening. - if (!_cubit.state.isLoadingMore) { - _cubit.more(); - } - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - +class MembersScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final cubit = BlocProvider.of(context); return Scaffold( appBar: ThaliaAppBar( title: const Text('MEMBERS'), @@ -59,21 +31,10 @@ class _MembersScreenState extends State { ), drawer: MenuDrawer(), body: RefreshIndicator( - onRefresh: _cubit.load, - child: BlocBuilder( - builder: (context, listState) { - if (listState.hasException) { - return ErrorScrollView(listState.message!, retry: _cubit.load); - } else { - return MemberListScrollView( - key: const PageStorageKey('members'), - controller: _controller, - listState: listState, - currentYear: _cubit.year, - setYear: _cubit.filterYear, - ); - } - }, + onRefresh: cubit.load, + child: MemberListScrollView( + cubit: cubit, + key: const PageStorageKey('members'), ), ), ); @@ -82,21 +43,8 @@ class _MembersScreenState extends State { class MembersSearchDelegate extends SearchDelegate { final MemberListCubit _cubit; - late final ScrollController _controller; - - MembersSearchDelegate(this._cubit) { - _controller = ScrollController()..addListener(_scrollListener); - } - void _scrollListener() { - if (_controller.position.pixels >= - _controller.position.maxScrollExtent - 300) { - // Only request loading more if that's not already happening. - if (!_cubit.state.isLoadingMore) { - _cubit.more(); - } - } - } + MembersSearchDelegate(this._cubit); @override ThemeData appBarTheme(BuildContext context) { @@ -135,24 +83,10 @@ class MembersSearchDelegate extends SearchDelegate { @override Widget buildResults(BuildContext context) { - return BlocBuilder( - bloc: _cubit..search(query), - builder: (context, listState) { - if (listState.hasException) { - return ErrorScrollView( - listState.message!, - retry: () => _cubit..search(query), - ); - } else { - return MemberListScrollView( - key: const PageStorageKey('members-search'), - controller: _controller, - listState: listState, - currentYear: _cubit.year, - setYear: _cubit.filterYear, - ); - } - }, + _cubit.search(query); + return MemberListScrollView( + cubit: _cubit, + key: const PageStorageKey('albums-search'), ); } @@ -225,62 +159,54 @@ class _SliverAppBarDelegate extends SliverPersistentHeaderDelegate { } /// A ScrollView that shows a grid of [MemberTile]s. -/// -/// This does not take care of communicating with a Cubit. The [controller] -/// should do that. The [listState] also must not have an exception. -class MemberListScrollView extends StatelessWidget { - final ScrollController controller; - final MemberListState listState; - final int? currentYear; - final void Function(int?) setYear; - - const MemberListScrollView({ - super.key, - required this.controller, - required this.listState, - required this.currentYear, - required this.setYear, - }); +class MemberListScrollView + extends PaginatedScrollView { + MemberListScrollView({super.key, super.cubit}) + : super( + resultsBuilder: (context, members) => + buildResults(cubit, context, members), + loadingBuilder: (context) => buildResults(cubit, context, []), + errorBuilder: (context, message) => buildError(cubit, context, message), + ); + + static List buildError( + MemberListCubit? cubit, + BuildContext context, + String message, + ) { + final cubit0 = cubit ?? BlocProvider.of(context); + return [ + SliverPersistentHeader( + delegate: _SliverAppBarDelegate(cubit0.year, cubit0.filterYear), + ), + SliverToBoxAdapter(child: ErrorScrollView(message)), + ]; + } - @override - Widget build(BuildContext context) { - return Scrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - physics: const RangeMaintainingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverPersistentHeader( - delegate: _SliverAppBarDelegate(currentYear, setYear), + static List buildResults( + MemberListCubit? cubit, + BuildContext context, + List members, + ) { + final cubit0 = cubit ?? BlocProvider.of(context); + return [ + SliverPersistentHeader( + delegate: _SliverAppBarDelegate(cubit0.year, cubit0.filterYear), + ), + SliverPadding( + padding: const EdgeInsets.all(8), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, ), - SliverPadding( - padding: const EdgeInsets.all(8), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - ), - delegate: SliverChildBuilderDelegate( - (context, index) => - MemberTile(member: listState.results[index]), - childCount: listState.results.length, - ), - ), + delegate: SliverChildBuilderDelegate( + (context, index) => MemberTile(member: members[index]), + childCount: members.length, ), - if (listState.isLoadingMore) - const SliverPadding( - padding: EdgeInsets.all(8), - sliver: SliverList( - delegate: SliverChildListDelegate.fixed([ - Center(child: CircularProgressIndicator()), - ]), - ), - ), - ], + ), ), - ); + ]; } } diff --git a/lib/ui/screens/thabloids_screen.dart b/lib/ui/screens/thabloids_screen.dart index adb8fd16..0cc7002f 100644 --- a/lib/ui/screens/thabloids_screen.dart +++ b/lib/ui/screens/thabloids_screen.dart @@ -1,64 +1,20 @@ import 'dart:math'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; -import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/blocs/thabloid_list_cubit.dart'; import 'package:reaxit/models/thabloid.dart'; import 'package:reaxit/ui/widgets.dart'; +import 'package:reaxit/ui/widgets/paginated_scroll_view.dart'; import 'package:reaxit/ui/widgets/thabloid_tile.dart'; -class ThabloidScreen extends StatefulWidget { - @override - State createState() => _ThabloidScreenState(); -} - -class _ThabloidScreenState extends State { - late ScrollController _controller; - late ThabloidListCubit _cubit; - - @override - void initState() { - _cubit = BlocProvider.of(context); - _controller = ScrollController()..addListener(_scrollListener); - super.initState(); - } - - void _scrollListener() { - if (_controller.position.pixels >= - _controller.position.maxScrollExtent - 300) { - _cubit.moreDown(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - +class ThabloidScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: ThaliaAppBar(title: const Text('THABLOIDS')), drawer: MenuDrawer(), - body: BlocBuilder( - builder: (context, thabloidsState) { - if (thabloidsState.hasException) { - return ErrorScrollView(thabloidsState.message!, retry: _cubit.load); - } else if (thabloidsState.isLoading) { - return const Center(child: CircularProgressIndicator()); - } else { - return ThabloidsScrollView( - key: const PageStorageKey('thabloids'), - controller: _controller, - thabloidState: thabloidsState, - thabloids: thabloidsState.results, - ); - } - }, - ), + body: ThabloidsScrollView(key: const PageStorageKey('thabloids')), ); } } @@ -66,66 +22,34 @@ class _ThabloidScreenState extends State { /// A ScrollView that shows tabloids with [Thabloid]s. /// /// The tabloids are sorted by date -/// -/// This does not take care of communicating with a Bloc. The [controller] -/// should do that. The [thabloidState] also must not have an exception. -class ThabloidsScrollView extends StatelessWidget { +class ThabloidsScrollView + extends PaginatedScrollView { static final monthFormatter = DateFormat('MMMM'); static final monthYearFormatter = DateFormat('MMMM yyyy'); - final Key centerkey = UniqueKey(); - final ScrollController controller; - final ThabloidListState thabloidState; - final List thabloids; - - ThabloidsScrollView({ - super.key, - required this.controller, - required this.thabloidState, - required this.thabloids, - }); - - @override - Widget build(BuildContext context) { - ApiRepository api = context.read(); - return Column( - children: [ - Expanded( - child: CustomScrollView( - controller: controller, - physics: const RangeMaintainingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - childAspectRatio: 1 / sqrt(2), - ), - delegate: SliverChildBuilderDelegate( - (_, index) => ThabloidDetailCard(thabloids[index], api), - childCount: thabloids.length, - ), - ), - ), - if (thabloidState.isLoadingMore) - const SliverPadding( - padding: EdgeInsets.all(12), - sliver: SliverToBoxAdapter( - child: Center(child: CircularProgressIndicator()), - ), - ), - ], + const ThabloidsScrollView({super.key, super.cubit}) + : super(resultsBuilder: buildResults); + + static List buildResults( + BuildContext context, + List thabloids, + ) { + return [ + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + childAspectRatio: 1 / sqrt(2), + ), + delegate: SliverChildBuilderDelegate( + (_, index) => ThabloidDetailCard(thabloids[index]), + childCount: thabloids.length, ), ), - ], - ); + ), + ]; } } diff --git a/lib/ui/screens/vacancies_screen.dart b/lib/ui/screens/vacancies_screen.dart index 34f74d45..bf37888a 100644 --- a/lib/ui/screens/vacancies_screen.dart +++ b/lib/ui/screens/vacancies_screen.dart @@ -1,110 +1,33 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:intl/intl.dart'; import 'package:reaxit/blocs/vacancies_cubit.dart'; import 'package:reaxit/models/vacancie.dart'; import 'package:reaxit/ui/widgets.dart'; +import 'package:reaxit/ui/widgets/paginated_scroll_view.dart'; import 'package:reaxit/ui/widgets/vacancy.dart'; -class VacanciesScreen extends StatefulWidget { - @override - State createState() => _ThabloidScreenState(); -} - -class _ThabloidScreenState extends State { - late ScrollController _controller; - late VacanciesListCubit _cubit; - - @override - void initState() { - _cubit = BlocProvider.of(context); - _controller = ScrollController()..addListener(_scrollListener); - - _cubit.load(); - super.initState(); - } - - void _scrollListener() { - if (_controller.position.pixels >= - _controller.position.maxScrollExtent - 300) { - _cubit.moreDown(); - } - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - +class VacanciesScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: ThaliaAppBar(title: const Text('VACANCIES')), drawer: MenuDrawer(), - body: BlocBuilder( - builder: (context, thabloidsState) { - if (thabloidsState.hasException) { - return ErrorScrollView(thabloidsState.message!, retry: _cubit.load); - } else if (thabloidsState.isLoading) { - return const Center(child: CircularProgressIndicator()); - } else { - return VacanciesScrollView( - key: const PageStorageKey('vacancies'), - controller: _controller, - thabloidState: thabloidsState, - vacancies: thabloidsState.results, - ); - } - }, - ), + body: VacanciesScrollView(key: const PageStorageKey('vacancies')), ); } } -/// A ScrollView that shows a calendar with [Vacancies]s. -/// -/// The events are grouped by month, and date. -/// -/// This does not take care of communicating with a Bloc. The [controller] -/// should do that. The [thabloidState] also must not have an exception. -class VacanciesScrollView extends StatelessWidget { - static final monthFormatter = DateFormat('MMMM'); - static final monthYearFormatter = DateFormat('MMMM yyyy'); - - final Key centerkey = UniqueKey(); - final ScrollController controller; - final VacanciesState thabloidState; - final List vacancies; - - VacanciesScrollView({ - super.key, - required this.controller, - required this.thabloidState, - required this.vacancies, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Column( - children: [ - Expanded( - child: CustomScrollView( - controller: controller, - physics: const RangeMaintainingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: vacancies - .map( - (v) => SliverToBoxAdapter(child: VacancieCard(vacancie: v)), - ) - .toList(), - ), - ), - ], - ), - ); +/// A ScrollView that shows a calendar with [Vacancy]s. +class VacanciesScrollView + extends PaginatedScrollView { + const VacanciesScrollView({super.key, super.cubit}) + : super(resultsBuilder: buildResults); + + static List buildResults( + BuildContext context, + List vacancies, + ) { + return vacancies + .map((v) => SliverToBoxAdapter(child: VacancieCard(vacancie: v))) + .toList(); } } diff --git a/lib/ui/widgets/error_scroll_view.dart b/lib/ui/widgets/error_scroll_view.dart index 6f2e52e6..8d89c22a 100644 --- a/lib/ui/widgets/error_scroll_view.dart +++ b/lib/ui/widgets/error_scroll_view.dart @@ -3,14 +3,13 @@ import 'package:flutter/material.dart'; class ErrorScrollView extends StatelessWidget { final String message; final void Function()? retry; + final bool? shrinkWrap; - const ErrorScrollView(this.message, {super.key, this.retry}); + const ErrorScrollView(this.message, {super.key, this.retry, this.shrinkWrap}); @override Widget build(BuildContext context) { - return ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(16), + return Column( children: [ Container( height: 100, diff --git a/lib/ui/widgets/paginated_scroll_view.dart b/lib/ui/widgets/paginated_scroll_view.dart new file mode 100644 index 00000000..4525af72 --- /dev/null +++ b/lib/ui/widgets/paginated_scroll_view.dart @@ -0,0 +1,139 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:reaxit/blocs.dart'; +import 'package:reaxit/blocs/list_cubit.dart'; +import 'package:reaxit/ui/widgets/error_scroll_view.dart'; + +class PaginatedScrollView, S> + extends StatefulWidget { + /// The default bloc to use for the blocbuilder. If not provided, + /// the bloc is obtained from a [BlocProvider]. + final C? cubit; + + /// A builder that creates a list of slivers from the results. + /// + /// For example, this could return a list with a single [SliverGrid]. + final List Function(BuildContext context, List results) + resultsBuilder; + + /// An optional builder for a list of slivers to be shown when loading. + /// + /// If this is not provided, nothing will be shown. + final List Function(BuildContext context)? loadingBuilder; + + /// An optional builder for a list of slivers to be shown when an error occured. + /// + /// If this is not provided, the default error page will be shown. + final List Function(BuildContext context, String message)? + errorBuilder; + + const PaginatedScrollView({ + super.key, + this.cubit, + required this.resultsBuilder, + this.loadingBuilder, + this.errorBuilder, + }); + @override + State createState() => _PaginatedScrollViewState(); +} + +class _PaginatedScrollViewState, S> + extends State> { + late ScrollController controller; + + @override + void initState() { + controller = ScrollController()..addListener(_scrollListener); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + void _scrollListener() { + if (!controller.hasClients) { + return; + } + + final position = controller.position; + if (position.pixels >= position.maxScrollExtent - 300) { + final cubit = widget.cubit ?? BlocProvider.of(context); + cubit.more(); + } + } + + @override + Widget build(BuildContext context) { + return BlocBuilder>( + bloc: widget.cubit, + builder: (context, state) { + late final List slivers; + switch (state) { + case ResultState( + result: InnerListState( + results: final results, + message: final message, + isLoadingMore: final isLoadingMore, + ), + ) || + LoadingResultState( + result: InnerListState( + results: final results, + message: final message, + isLoadingMore: final isLoadingMore, + ), + ): + final resultSlivers = widget.resultsBuilder(context, results); + + slivers = [ + ...resultSlivers, + if (results.isEmpty && message != null) + // Just show the message, no retry or anything + // Not an error, just a sad message + SliverToBoxAdapter(child: ErrorScrollView(message)), + + if (isLoadingMore) + const SliverPadding( + padding: EdgeInsets.only(top: 16), + sliver: SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ), + ), + const SliverSafeArea( + minimum: EdgeInsets.only(bottom: 8), + sliver: SliverPadding(padding: EdgeInsets.zero), + ), + ]; + case LoadingState(): + if (widget.loadingBuilder != null) { + slivers = widget.loadingBuilder!(context); + } else { + slivers = []; + } + case ErrorState(message: final message): + if (widget.errorBuilder == null) { + final cubit = widget.cubit ?? BlocProvider.of(context); + return ErrorScrollView(message, retry: cubit.load); + } + + slivers = widget.errorBuilder!(context, message); + } + + return Scrollbar( + controller: controller, + child: CustomScrollView( + controller: controller, + physics: const RangeMaintainingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: slivers, + ), + ); + }, + ); + } +} diff --git a/lib/ui/widgets/thabloid_tile.dart b/lib/ui/widgets/thabloid_tile.dart index a66452a7..232e1e29 100644 --- a/lib/ui/widgets/thabloid_tile.dart +++ b/lib/ui/widgets/thabloid_tile.dart @@ -7,11 +7,12 @@ import 'package:reaxit/ui/widgets/cached_image.dart'; import 'package:url_launcher/url_launcher.dart'; class ThabloidDetailCard extends StatelessWidget { - final ThabloidCubit cubit; - ThabloidDetailCard(Thabloid thabloid, ApiRepository api) - : cubit = ThabloidCubit(api, thabloid); + final Thabloid thabloid; + const ThabloidDetailCard(this.thabloid); + + void _openThabloid(BuildContext context) async { + final cubit = BlocProvider.of(context); - void _openThabloid() async { launchUrl( Uri.parse(await cubit.getFile()), mode: LaunchMode.externalApplication, @@ -20,9 +21,11 @@ class ThabloidDetailCard extends StatelessWidget { @override Widget build(BuildContext context) { + ApiRepository api = context.read(); + final textTheme = Theme.of(context).textTheme; return BlocProvider( - create: (context) => cubit..load(), + create: (context) => ThabloidCubit(api, thabloid)..load(), child: BlocBuilder( builder: (context, thabloidsState) { Thabloid thabloid = thabloidsState; @@ -55,7 +58,7 @@ class ThabloidDetailCard extends StatelessWidget { Positioned.fill( child: Material( color: Colors.transparent, - child: InkWell(onTap: _openThabloid), + child: InkWell(onTap: () => _openThabloid(context)), ), ), ],