diff --git a/lib/blocs/album_list_cubit.dart b/lib/blocs/album_list_cubit.dart index d0c7b1de3..bcffcccbc 100644 --- a/lib/blocs/album_list_cubit.dart +++ b/lib/blocs/album_list_cubit.dart @@ -1,18 +1,13 @@ import 'dart:async'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; +import 'package:reaxit/blocs/list_state.dart'; import 'package:reaxit/config.dart' as config; -import 'package:reaxit/blocs.dart'; import 'package:reaxit/models.dart'; +import 'package:reaxit/ui/widgets.dart'; -typedef AlbumListState = ListState; - -class AlbumListCubit extends Cubit { - static const int firstPageSize = 60; - static const int pageSize = 30; - +class AlbumListCubit extends PaginatedCubit { final ApiRepository api; /// The last used search query. Can be set through `this.search(query)`. @@ -27,10 +22,10 @@ class AlbumListCubit extends Cubit { /// The offset to be used for the next paginated request. int _nextOffset = 0; - AlbumListCubit(this.api) : super(const AlbumListState.loading(results: [])); + AlbumListCubit(this.api) : super(firstPageSize: 60, pageSize: 30); + @override Future load() async { - emit(state.copyWith(isLoading: true)); try { final query = _searchQuery; final albumsResponse = await api.getAlbums( @@ -49,30 +44,29 @@ class AlbumListCubit extends Cubit { if (albumsResponse.results.isEmpty) { if (query?.isEmpty ?? true) { - emit(const AlbumListState.failure(message: 'There are no albums.')); + emit(const ErrorListState('There are no albums.')); } else { - emit(AlbumListState.failure( - message: 'There are no albums found for "$query".', - )); + emit(ErrorListState('There are no albums found for "$query".')); } } else { - emit(AlbumListState.success( - results: albumsResponse.results, - isDone: isDone, - )); + emit(ResultsListState.withDone(albumsResponse.results, isDone)); } } on ApiException catch (exception) { - emit(AlbumListState.failure(message: exception.message)); + emit(ErrorListState(exception.message)); } } + @override Future more() async { + // Ignore calls to `more()` if there is no data, or already more coming. final oldState = state; + if (oldState is! ResultsListState || + oldState is LoadingMoreListState || + oldState is DoneListState) return; - // Ignore calls to `more()` if there is no data, or already more coming. - if (oldState.isDone || oldState.isLoading || oldState.isLoadingMore) return; + final resultsState = oldState as ResultsListState; - emit(oldState.copyWith(isLoadingMore: true)); + emit(LoadingMoreListState.from(resultsState)); try { final query = _searchQuery; @@ -87,17 +81,14 @@ class AlbumListCubit extends Cubit { // changed since the request was made. if (query != _searchQuery) return; - final albums = state.results + albumsResponse.results; + final albums = resultsState.results + albumsResponse.results; final isDone = albums.length == albumsResponse.count; _nextOffset += pageSize; - emit(AlbumListState.success( - results: albums, - isDone: isDone, - )); + emit(ResultsListState.withDone(albums, isDone)); } on ApiException catch (exception) { - emit(AlbumListState.failure(message: exception.message)); + emit(ErrorListState(exception.getMessage())); } } @@ -110,7 +101,7 @@ class AlbumListCubit extends Cubit { _searchDebounceTimer?.cancel(); if (query?.isEmpty ?? false) { /// Don't get results when the query is empty. - emit(const AlbumListState.loading(results: [])); + emit(const LoadingListState()); } else { _searchDebounceTimer = Timer(config.searchDebounceTime, load); } diff --git a/lib/blocs/calendar_cubit.dart b/lib/blocs/calendar_cubit.dart index c0939e367..e66d24132 100644 --- a/lib/blocs/calendar_cubit.dart +++ b/lib/blocs/calendar_cubit.dart @@ -1,10 +1,10 @@ import 'dart:async'; +import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:intl/intl.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; -import 'package:reaxit/blocs.dart'; import 'package:reaxit/config.dart' as config; import 'package:reaxit/models.dart'; @@ -103,7 +103,86 @@ class CalendarEvent { ); } -typedef CalendarState = ListState; +/// Generic class to be used as state for paginated lists. +class CalendarState extends Equatable { + /// The results to be shown. These are outdated if `isLoading` is true. + final List results; + + /// 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; + + bool get hasException => message != null; + + const CalendarState({ + required this.results, + required this.message, + required this.isLoading, + required this.isLoadingMore, + required this.isDone, + }); + + CalendarState copyWith({ + List? results, + String? message, + bool? isLoading, + bool? isLoadingMore, + bool? isDone, + }) => + CalendarState( + results: results ?? this.results, + message: message ?? this.message, + isLoading: isLoading ?? this.isLoading, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + isDone: isDone ?? this.isDone, + ); + + @override + List get props => [ + results, + message, + isLoading, + isLoadingMore, + isDone, + ]; + + @override + String toString() { + return 'ListState<$CalendarEvent>(isLoading: $isLoading, isLoadingMore: $isLoadingMore,' + ' isDone: $isDone, message: $message, ${results.length} ${CalendarEvent}s)'; + } + + const CalendarState.loading({required this.results}) + : message = null, + isLoading = true, + isLoadingMore = false, + isDone = true; + + const CalendarState.loadingMore({required this.results}) + : message = null, + isLoading = false, + isLoadingMore = true, + isDone = true; + + const CalendarState.success({required this.results, required this.isDone}) + : message = null, + isLoading = false, + isLoadingMore = false; + + const CalendarState.failure({required String this.message}) + : results = const [], + isLoading = false, + isLoadingMore = false, + isDone = true; +} class CalendarCubit extends Cubit { static const int firstPageSize = 20; diff --git a/lib/blocs/list_state.dart b/lib/blocs/list_state.dart index 232328d75..05d551c5d 100644 --- a/lib/blocs/list_state.dart +++ b/lib/blocs/list_state.dart @@ -1,82 +1,66 @@ import 'package:equatable/equatable.dart'; -/// Generic class to be used as state for paginated lists. -class ListState extends Equatable { - /// The results to be shown. These are outdated if `isLoading` is true. - final List results; - - /// A message describing why there are no results. - final String? message; +/// Generic type for states with a paginated list of results. +/// +/// There are a number of subtypes: +/// * [ErrorListState] - indicates that there was an error. +/// * [LoadingListState] - indicates that we are loading. +/// * [ResultsListState] - indicates that there are results. +/// * [DoneListState] - indicates that there are no more results. +/// * [LoadingMoreListState] - indicates that we are loading more results. +abstract class ListState extends Equatable { + const ListState(); - /// Different results are being loaded. The results are outdated. - final bool isLoading; + /// A convenience method to get the results if they are available. + /// + /// Returns `[]` if this state is not a (subtype of) [ResultsListState]. + List get results => + this is ResultsListState ? (this as ResultsListState).results : []; - /// More of the same results are being loaded. The results are not outdated. - final bool isLoadingMore; + /// A convenience method to get the error message if there is one. + /// + /// Returns `null` iff this state is not a (subtype of) [ErrorListState]. + String? get message => + this is ErrorListState ? (this as ErrorListState).message : null; - /// The last results have been loaded. There are no more pages left. - final bool isDone; + @override + List get props => []; +} - bool get hasException => message != null; +class LoadingListState extends ListState { + const LoadingListState(); +} - const ListState({ - required this.results, - required this.message, - required this.isLoading, - required this.isLoadingMore, - required this.isDone, - }); +class ErrorListState extends ListState { + @override + final String message; - ListState copyWith({ - List? results, - String? message, - bool? isLoading, - bool? isLoadingMore, - bool? isDone, - }) => - ListState( - results: results ?? this.results, - message: message ?? this.message, - isLoading: isLoading ?? this.isLoading, - isLoadingMore: isLoadingMore ?? this.isLoadingMore, - isDone: isDone ?? this.isDone, - ); + const ErrorListState(this.message); @override - List get props => [ - results, - message, - isLoading, - isLoadingMore, - isDone, - ]; + List get props => [message]; +} +class ResultsListState extends ListState { @override - String toString() { - return 'ListState<$T>(isLoading: $isLoading, isLoadingMore: $isLoadingMore,' - ' isDone: $isDone, message: $message, ${results.length} ${T}s)'; - } + final List results; + + const ResultsListState(this.results); + + factory ResultsListState.withDone(List results, bool isDone) => + isDone ? DoneListState(results) : ResultsListState(results); - const ListState.loading({required this.results}) - : message = null, - isLoading = true, - isLoadingMore = false, - isDone = true; + @override + List get props => [results]; +} - const ListState.loadingMore({required this.results}) - : message = null, - isLoading = false, - isLoadingMore = true, - isDone = true; +class LoadingMoreListState extends ResultsListState { + const LoadingMoreListState(super.results); - const ListState.success({required this.results, required this.isDone}) - : message = null, - isLoading = false, - isLoadingMore = false; + factory LoadingMoreListState.from(ResultsListState state) => + LoadingMoreListState(state.results); +} - const ListState.failure({required String this.message}) - : results = const [], - isLoading = false, - isLoadingMore = false, - isDone = true; +class DoneListState extends ResultsListState { + const DoneListState(super.results); } diff --git a/lib/blocs/member_list_cubit.dart b/lib/blocs/member_list_cubit.dart index 3a2b26922..596ecc333 100644 --- a/lib/blocs/member_list_cubit.dart +++ b/lib/blocs/member_list_cubit.dart @@ -1,18 +1,13 @@ import 'dart:async'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:reaxit/api/api_repository.dart'; import 'package:reaxit/api/exceptions.dart'; import 'package:reaxit/config.dart' as config; import 'package:reaxit/blocs.dart'; import 'package:reaxit/models.dart'; +import 'package:reaxit/ui/widgets.dart'; -typedef MemberListState = ListState; - -class MemberListCubit extends Cubit { - static const int firstPageSize = 60; - static const int pageSize = 30; - +class MemberListCubit extends PaginatedCubit { final ApiRepository api; /// The last used search query. Can be set through `this.search(query)`. @@ -27,10 +22,10 @@ class MemberListCubit extends Cubit { /// The offset to be used for the next paginated request. int _nextOffset = 0; - MemberListCubit(this.api) : super(const MemberListState.loading(results: [])); + MemberListCubit(this.api) : super(firstPageSize: 60, pageSize: 30); + @override Future load() async { - emit(state.copyWith(isLoading: true)); try { final query = _searchQuery; final membersResponse = await api.getMembers( @@ -49,30 +44,29 @@ class MemberListCubit extends Cubit { if (membersResponse.results.isEmpty) { if (query?.isEmpty ?? true) { - emit(const MemberListState.failure(message: 'There are no members.')); + emit(const ErrorListState('There are no members.')); } else { - emit(MemberListState.failure( - message: 'There are no members found for "$query".', - )); + emit(ErrorListState('There are no members found for "$query".')); } } else { - emit(MemberListState.success( - results: membersResponse.results, - isDone: isDone, - )); + emit(ResultsListState.withDone(membersResponse.results, isDone)); } } on ApiException catch (exception) { - emit(MemberListState.failure(message: exception.message)); + emit(ErrorListState(exception.message)); } } + @override Future more() async { + // Ignore calls to `more()` if there is no data, or already more coming. final oldState = state; + if (oldState is! ResultsListState || + oldState is LoadingMoreListState || + oldState is DoneListState) return; - // Ignore calls to `more()` if there is no data, or already more coming. - if (oldState.isDone || oldState.isLoading || oldState.isLoadingMore) return; + final resultsState = oldState as ResultsListState; - emit(oldState.copyWith(isLoadingMore: true)); + emit(LoadingMoreListState.from(resultsState)); try { final query = _searchQuery; @@ -87,17 +81,14 @@ class MemberListCubit extends Cubit { // changed since the request was made. if (query != _searchQuery) return; - final members = state.results + membersResponse.results; + final members = resultsState.results + membersResponse.results; final isDone = members.length == membersResponse.count; _nextOffset += pageSize; - emit(MemberListState.success( - results: members, - isDone: isDone, - )); + emit(ResultsListState.withDone(members, isDone)); } on ApiException catch (exception) { - emit(MemberListState.failure(message: exception.getMessage())); + emit(ErrorListState(exception.getMessage())); } } @@ -110,7 +101,7 @@ class MemberListCubit extends Cubit { _searchDebounceTimer?.cancel(); if (query?.isEmpty ?? false) { /// Don't get results when the query is empty. - emit(const MemberListState.loading(results: [])); + emit(const LoadingListState()); } else { _searchDebounceTimer = Timer(config.searchDebounceTime, load); } diff --git a/lib/blocs/registrations_cubit.dart b/lib/blocs/registrations_cubit.dart index 314198d80..59e58ce91 100644 --- a/lib/blocs/registrations_cubit.dart +++ b/lib/blocs/registrations_cubit.dart @@ -1,26 +1,21 @@ -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/models.dart'; +import 'package:reaxit/ui/widgets.dart'; -typedef RegistrationsState = ListState; - -class RegistrationsCubit extends Cubit { +class RegistrationsCubit extends PaginatedCubit { final ApiRepository api; final 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; RegistrationsCubit(this.api, {required this.eventPk}) - : super(const RegistrationsState.loading(results: [])); + : super(firstPageSize: 60, pageSize: 30); + @override Future load() async { - emit(state.copyWith(isLoading: true)); try { final listResponse = await api.getEventRegistrations( pk: eventPk, limit: firstPageSize, offset: 0); @@ -30,27 +25,28 @@ class RegistrationsCubit extends Cubit { _nextOffset = firstPageSize; if (listResponse.results.isNotEmpty) { - emit(RegistrationsState.success( - results: listResponse.results, isDone: isDone)); + emit(ResultsListState.withDone(listResponse.results, isDone)); } else { - emit(const RegistrationsState.failure( - message: 'There are no registrations yet.', - )); + emit(const ErrorListState('There are no registrations yet.')); } } on ApiException catch (exception) { - emit(RegistrationsState.failure( - message: exception.getMessage(notFound: 'The event does not exist.'), + emit(ErrorListState( + exception.getMessage(notFound: 'The event does not exist.'), )); } } + @override Future more() async { + // Ignore calls to `more()` if there is no data, or already more coming. final oldState = state; + if (oldState is! ResultsListState || + oldState is LoadingMoreListState || + oldState is DoneListState) return; - if (oldState.isDone || oldState.isLoading || oldState.isLoadingMore) return; - - emit(oldState.copyWith(isLoadingMore: true)); + final resultsState = oldState as ResultsListState; + emit(LoadingMoreListState.from(resultsState)); try { var listResponse = await api.getEventRegistrations( pk: eventPk, @@ -58,15 +54,15 @@ class RegistrationsCubit extends Cubit { offset: _nextOffset, ); - final registrations = state.results + listResponse.results; + final registrations = resultsState.results + listResponse.results; final isDone = registrations.length == listResponse.count; _nextOffset += pageSize; - emit(RegistrationsState.success(results: registrations, isDone: isDone)); + emit(ResultsListState.withDone(registrations, isDone)); } on ApiException catch (exception) { - emit(RegistrationsState.failure( - message: exception.getMessage(notFound: 'The event does not exist.'), + emit(ErrorListState( + exception.getMessage(notFound: 'The event does not exist.'), )); } } diff --git a/lib/ui/screens/albums_screen.dart b/lib/ui/screens/albums_screen.dart index 9405f82d5..4ec79744f 100644 --- a/lib/ui/screens/albums_screen.dart +++ b/lib/ui/screens/albums_screen.dart @@ -3,40 +3,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:reaxit/blocs.dart'; import 'package:reaxit/api/api_repository.dart'; +import 'package:reaxit/models.dart'; import 'package:reaxit/ui/widgets.dart'; -class AlbumsScreen extends StatefulWidget { - @override - State createState() => _AlbumsScreenState(); -} - -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(); - } - +class AlbumsScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( @@ -63,21 +33,9 @@ class _AlbumsScreenState extends State { ), drawer: MenuDrawer(), body: RefreshIndicator( - onRefresh: () async { - await _cubit.load(); - }, - child: BlocBuilder( - builder: (context, listState) { - if (listState.hasException) { - return ErrorScrollView(listState.message!); - } else { - return AlbumListScrollView( - key: const PageStorageKey('albums'), - controller: _controller, - listState: listState, - ); - } - }, + onRefresh: () => BlocProvider.of(context).load(), + child: PaginatedScrollView( + resultsBuilder: (context, results) => [_AlbumsGrid(results)], ), ), ); @@ -85,22 +43,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.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) { @@ -141,93 +86,47 @@ 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!); - } else { - return AlbumListScrollView( - key: const PageStorageKey('albums-search'), - controller: _controller, - listState: listState, - ); - } - }, + return BlocProvider.value( + value: _cubit..search(query), + child: PaginatedScrollView( + resultsBuilder: (_, results) => [_AlbumsGrid(results)], + ), ); } @override Widget buildSuggestions(BuildContext context) { - return BlocBuilder( - bloc: _cubit..search(query), - builder: (context, listState) { - if (listState.hasException) { - return ErrorScrollView(listState.message!); - } else { - return AlbumListScrollView( - key: const PageStorageKey('albums-search'), - controller: _controller, - listState: listState, - ); - } - }, + return BlocProvider.value( + value: _cubit..search(query), + child: PaginatedScrollView( + resultsBuilder: (_, results) => [_AlbumsGrid(results)], + ), ); } } -/// 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; +class _AlbumsGrid extends StatelessWidget { + const _AlbumsGrid(this.results); - const AlbumListScrollView({ - Key? key, - required this.controller, - required this.listState, - }) : super(key: key); + final List results; @override Widget build(BuildContext context) { - return Scrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - physics: const RangeMaintainingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), + return SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => AlbumTile( + album: results[index], ), - 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, - ), - ), - ), - if (listState.isLoadingMore) - const SliverPadding( - padding: EdgeInsets.all(8), - sliver: SliverList( - delegate: SliverChildListDelegate.fixed([ - Center( - child: CircularProgressIndicator(), - ) - ]), - ), - ), - ], - )); + childCount: results.length, + ), + ), + ); } } diff --git a/lib/ui/screens/event_screen.dart b/lib/ui/screens/event_screen.dart index 535e17470..2e7bca893 100644 --- a/lib/ui/screens/event_screen.dart +++ b/lib/ui/screens/event_screen.dart @@ -46,7 +46,8 @@ class _EventScreenState extends State { if (_controller.position.pixels >= _controller.position.maxScrollExtent - 300) { // Only request loading more if that's not already happening. - if (!_registrationsCubit.state.isLoadingMore) { + if (_registrationsCubit.state is ResultsListState && + _registrationsCubit.state is! LoadingMoreListState) { _registrationsCubit.more(); } } @@ -59,40 +60,6 @@ class _EventScreenState extends State { super.dispose(); } - Widget _makeMap(Event event) { - return Stack( - fit: StackFit.loose, - children: [ - CachedImage( - imageUrl: event.mapsUrl, - placeholder: 'assets/img/map_placeholder.png', - fit: BoxFit.cover, - ), - Positioned.fill( - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - Uri url = Theme.of(context).platform == TargetPlatform.iOS - ? Uri( - scheme: 'maps', - queryParameters: {'daddr': event.location}, - ) - : Uri( - scheme: 'https', - host: 'maps.google.com', - path: 'maps', - queryParameters: {'daddr': event.location}, - ); - launchUrl(url, mode: LaunchMode.externalNonBrowserApplication); - }, - ), - ), - ), - ], - ); - } - /// Create all info of an event until the description, including buttons. Widget _makeEventInfo(Event event) { return Padding( @@ -100,106 +67,19 @@ class _EventScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _makeBasicEventInfo(event), + _BasicEventInfo(event), if (event.registrationIsRequired) _makeRequiredRegistrationInfo(event) else if (event.registrationIsOptional) _makeOptionalRegistrationInfo(event) else _makeNoRegistrationInfo(event), - if (event.hasFoodEvent) _makeFoodButton(event), + if (event.hasFoodEvent) _FoodButton(event), ], ), ); } - /// Create the title, start, end, location and price of an event. - Widget _makeBasicEventInfo(Event event) { - final textTheme = Theme.of(context).textTheme; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const SizedBox(height: 8), - Text( - event.title.toUpperCase(), - style: textTheme.headline6, - ), - const Divider(height: 24), - Row( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Flexible( - fit: FlexFit.tight, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('FROM', style: textTheme.caption), - const SizedBox(height: 4), - Text( - dateTimeFormatter.format(event.start.toLocal()), - style: textTheme.subtitle2, - ), - ], - ), - ), - const SizedBox(width: 8), - Flexible( - fit: FlexFit.tight, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('UNTIL', style: textTheme.caption), - const SizedBox(height: 4), - Text( - dateTimeFormatter.format(event.end.toLocal()), - style: textTheme.subtitle2, - ), - ], - ), - ) - ], - ), - const SizedBox(height: 12), - Row( - mainAxisSize: MainAxisSize.max, - children: [ - Flexible( - fit: FlexFit.tight, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('LOCATION', style: textTheme.caption), - const SizedBox(height: 4), - Text( - event.location, - style: textTheme.subtitle2, - ), - ], - ), - ), - const SizedBox(width: 8), - Flexible( - fit: FlexFit.tight, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('PRICE', style: textTheme.caption), - const SizedBox(height: 4), - Text( - '€${event.price}', - style: textTheme.subtitle2, - ), - ], - ), - ) - ], - ), - const Divider(height: 24), - ], - ); - } - // Create the info for events with required registration. Widget _makeRequiredRegistrationInfo(Event event) { assert(event.registrationIsRequired); @@ -216,9 +96,9 @@ class _EventScreenState extends State { if (event.canCreateRegistration) { if (event.reachedMaxParticipants) { - registrationButton = _makeJoinQueueButton(event); + registrationButton = _JoinQueueButton(event); } else { - registrationButton = _makeCreateRegistrationButton(event); + registrationButton = _CreateRegistrationButton(event); } } else if (event.canCancelRegistration) { if (event.cancelDeadlinePassed()) { @@ -226,19 +106,23 @@ class _EventScreenState extends State { textSpans.add(TextSpan( text: event.cancelTooLateMessage, )); - final text = 'The deadline has passed, are you sure you want ' - 'to cancel your registration and pay the estimated full costs of ' - '€${event.fine}? You will not be able to undo this!'; - registrationButton = _makeCancelRegistrationButton(event, text); + registrationButton = _CancelRegistrationButton( + event: event, + warningText: 'The deadline has passed, are you sure you want ' + 'to cancel your registration and pay the estimated full costs of ' + '€${event.fine}? You will not be able to undo this!', + ); } else { // Cancel button. - const text = 'Are you sure you want to cancel your registration?'; - registrationButton = _makeCancelRegistrationButton(event, text); + registrationButton = _CancelRegistrationButton( + event: event, + warningText: 'Are you sure you want to cancel your registration?', + ); } } if (event.canUpdateRegistration) { - updateButton = _makeUpdateButton(event); + updateButton = _UpdateRegistrationButton(event); } if (event.canCreateRegistration || !event.isRegistered) { @@ -436,7 +320,7 @@ class _EventScreenState extends State { final textSpans = []; Widget registrationButton = const SizedBox.shrink(); if (event.canCancelRegistration) { - registrationButton = _makeIWontBeThereButton(event); + registrationButton = _IWontBeThereButton(event); } if (event.isInvited) { @@ -447,7 +331,7 @@ class _EventScreenState extends State { 'can still register to give an indication of who will be there, as ' 'well as mark the event as "registered" in your calendar. ', )); - registrationButton = _makeIllBeThereButton(event); + registrationButton = _IllBeThereButton(event); } if (event.noRegistrationMessage?.isNotEmpty ?? false) { @@ -459,7 +343,7 @@ class _EventScreenState extends State { Widget updateButton = const SizedBox.shrink(); if (event.canUpdateRegistration) { - updateButton = _makeUpdateButton(event); + updateButton = _UpdateRegistrationButton(event); } return Column( @@ -502,95 +386,265 @@ class _EventScreenState extends State { ); } - Widget _makeIllBeThereButton(Event event) { - return ElevatedButton.icon( - onPressed: () async { - final messenger = ScaffoldMessenger.of(context); - try { - final calendarCubit = BlocProvider.of(context); - await _eventCubit.register(); - await _registrationsCubit.load(); - calendarCubit.load(); - } on ApiException { - messenger.showSnackBar(const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Could not register for the event.'), - )); - } - }, - icon: const Icon(Icons.check), - label: const Text("I'LL BE THERE"), + TextSpan _makeTermsAndConditions(Event event) { + final url = config.termsAndConditionsUrl; + return TextSpan( + children: [ + const TextSpan( + text: 'By registering, you confirm that you have read the ', + ), + TextSpan( + text: 'terms and conditions', + recognizer: TapGestureRecognizer() + ..onTap = () async { + final messenger = ScaffoldMessenger.of(context); + try { + await launchUrl(url, mode: LaunchMode.externalApplication); + } catch (_) { + messenger.showSnackBar(SnackBar( + behavior: SnackBarBehavior.floating, + content: Text('Could not open "${url.toString()}".'), + )); + } + }, + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + const TextSpan( + text: ', that you understand them and ' + 'that you agree to be bound by them.', + ), + ], + ); + } + + SliverPadding _makeRegistrationsHeader() { + return SliverPadding( + padding: const EdgeInsets.only(left: 16), + sliver: SliverToBoxAdapter( + child: Text( + 'REGISTRATIONS', + style: Theme.of(context).textTheme.caption, + ), + ), + ); + } + + SliverPadding _makeRegistrations(ListState state) { + if (state is ErrorListState) { + return SliverPadding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 0, bottom: 16), + sliver: SliverToBoxAdapter(child: Text(state.message!)), + ); + } else if (state is LoadingListState) { + return const SliverPadding( + padding: EdgeInsets.all(16), + sliver: SliverToBoxAdapter( + child: Center(child: CircularProgressIndicator()), + ), + ); + } else { + final results = state.results; + return SliverPadding( + padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 16), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + delegate: SliverChildBuilderDelegate( + childCount: results.length, + (context, index) => results[index].member != null + ? MemberTile(member: results[index].member!) + : DefaultMemberTile(name: results[index].name!), + ), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _eventCubit, + child: BlocBuilder( + builder: (context, state) { + if (state is ErrorState) { + return Scaffold( + appBar: ThaliaAppBar( + title: Text(widget.event?.title.toUpperCase() ?? 'EVENT'), + actions: [_ShareEventButton(widget.pk)], + ), + body: RefreshIndicator( + onRefresh: () async { + // Await only the event info. + _registrationsCubit.load(); + await _eventCubit.load(); + }, + child: ErrorScrollView(state.message!), + ), + ); + } else if (state is LoadingState && + state is! ResultState && + widget.event == null) { + return Scaffold( + appBar: ThaliaAppBar( + title: const Text('EVENT'), + actions: [_ShareEventButton(widget.pk)], + ), + body: const Center(child: CircularProgressIndicator()), + ); + } else { + final event = (state.result ?? widget.event)!; + return Scaffold( + appBar: ThaliaAppBar( + title: Text(event.title.toUpperCase()), + actions: [ + _CalendarExportButton(event), + _ShareEventButton(widget.pk), + if (event.userPermissions.manageEvent) + IconButton( + padding: const EdgeInsets.all(16), + icon: const Icon(Icons.settings), + onPressed: () => context.pushNamed( + 'event-admin', + params: {'eventPk': event.pk.toString()}, + ), + ), + ], + ), + body: RefreshIndicator( + onRefresh: () async { + // Await only the event info. + _registrationsCubit.load(); + await _eventCubit.load(); + }, + child: BlocBuilder>( + bloc: _registrationsCubit, + builder: (context, listState) { + return Scrollbar( + controller: _controller, + child: CustomScrollView( + controller: _controller, + key: const PageStorageKey('event'), + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _EventMap(event), + const Divider(height: 0), + _makeEventInfo(event), + const Divider(), + _EventDescription(event), + ], + ), + ), + if (event.registrationIsOptional || + event.registrationIsRequired) ...[ + const SliverToBoxAdapter(child: Divider()), + _makeRegistrationsHeader(), + _makeRegistrations(listState), + if (listState is LoadingMoreListState) + 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), + ), + ], + ), + ); + }, + ), + ), + ); + } + }, + ), ); } +} + +class _JoinQueueButton extends StatelessWidget { + const _JoinQueueButton(this.event); - Widget _makeIWontBeThereButton(Event event) { + final Event event; + + @override + Widget build(BuildContext context) { return ElevatedButton.icon( onPressed: () async { + final router = GoRouter.of(context); final messenger = ScaffoldMessenger.of(context); + final calendarCubit = BlocProvider.of(context); + final eventCubit = BlocProvider.of(context); + final registrationsCubit = BlocProvider.of(context); + try { - final calendarCubit = BlocProvider.of(context); - await _eventCubit.cancelRegistration( - registrationPk: event.registration!.pk, - ); - await _registrationsCubit.load(); + final registration = await eventCubit.register(); + if (event.hasFields) { + router.pushNamed( + 'event-registration', + params: { + 'eventPk': event.pk.toString(), + 'registrationPk': registration.pk.toString(), + }, + ); + } calendarCubit.load(); } on ApiException { messenger.showSnackBar(const SnackBar( behavior: SnackBarBehavior.floating, - content: Text('Could not cancel your registration.'), + content: Text('Could not join the waiting list for the event.'), )); } + registrationsCubit.load(); }, - icon: const Icon(Icons.clear), - label: const Text("I WON'T BE THERE"), + icon: const Icon(Icons.create_outlined), + label: const Text('JOIN QUEUE'), ); } +} - Widget _makeCreateRegistrationButton(Event event) { - return ElevatedButton.icon( - onPressed: () async { +class _CreateRegistrationButton extends StatelessWidget { + const _CreateRegistrationButton(this.event); + + final Event event; + + @override + Widget build(BuildContext context) { + return ElevatedButton.icon( + onPressed: () async { + final router = GoRouter.of(context); final messenger = ScaffoldMessenger.of(context); final calendarCubit = BlocProvider.of(context); - final router = GoRouter.of(context); + final eventCubit = BlocProvider.of(context); + final registrationsCubit = BlocProvider.of(context); + var confirmed = !event.cancelDeadlinePassed(); if (!confirmed) { confirmed = await showDialog( context: context, - builder: (context) { - return AlertDialog( - title: const Text('Register'), - content: Text( - 'Are you sure you want to register? The ' - 'cancellation deadline has already passed.', - style: Theme.of(context).textTheme.bodyText2, - ), - actions: [ - TextButton.icon( - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(false), - icon: const Icon(Icons.clear), - label: const Text('NO'), - ), - ElevatedButton.icon( - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(true), - icon: const Icon(Icons.check), - label: const Text('YES'), - ), - ], - ); - }, + builder: (context) => const _ConfirmationDialog( + titleText: 'Register', + warningText: 'Are you sure you want to register? ' + 'The cancellation deadline has already passed.', + ), ) ?? false; } if (confirmed) { try { - final registration = await _eventCubit.register(); + final registration = await eventCubit.register(); if (event.hasFields) { router.pushNamed('event-registration', params: { 'eventPk': event.pk.toString(), @@ -604,85 +658,113 @@ class _EventScreenState extends State { content: Text('Could not register for the event.'), )); } - await _registrationsCubit.load(); + registrationsCubit.load(); } }, icon: const Icon(Icons.create_outlined), label: const Text('REGISTER'), ); } +} + +class _IWontBeThereButton extends StatelessWidget { + const _IWontBeThereButton(this.event); + + final Event event; - Widget _makeJoinQueueButton(Event event) { + @override + Widget build(BuildContext context) { return ElevatedButton.icon( onPressed: () async { final messenger = ScaffoldMessenger.of(context); final calendarCubit = BlocProvider.of(context); - final router = GoRouter.of(context); + final eventCubit = BlocProvider.of(context); + final registrationsCubit = BlocProvider.of(context); + try { - final registration = await _eventCubit.register(); - if (event.hasFields) { - router.pushNamed( - 'event-registration', - params: { - 'eventPk': event.pk.toString(), - 'registrationPk': registration.pk.toString(), - }, - ); - } + await eventCubit.cancelRegistration( + registrationPk: event.registration!.pk, + ); + registrationsCubit.load(); calendarCubit.load(); } on ApiException { messenger.showSnackBar(const SnackBar( behavior: SnackBarBehavior.floating, - content: Text('Could not join the waiting list for the event.'), + content: Text('Could not cancel your registration.'), )); } - await _registrationsCubit.load(); }, - icon: const Icon(Icons.create_outlined), - label: const Text('JOIN QUEUE'), + icon: const Icon(Icons.clear), + label: const Text("I WON'T BE THERE"), + ); + } +} + +class _IllBeThereButton extends StatelessWidget { + const _IllBeThereButton(this.event); + + final Event event; + + @override + Widget build(BuildContext context) { + return ElevatedButton.icon( + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + final calendarCubit = BlocProvider.of(context); + final eventCubit = BlocProvider.of(context); + final registrationsCubit = BlocProvider.of(context); + + try { + await eventCubit.register(); + registrationsCubit.load(); + calendarCubit.load(); + } on ApiException { + messenger.showSnackBar(const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text('Could not register for the event.'), + )); + } + }, + icon: const Icon(Icons.check), + label: const Text("I'LL BE THERE"), ); } +} + +/// A button that allows the user to cancel their registration for an event. +class _CancelRegistrationButton extends StatelessWidget { + const _CancelRegistrationButton({ + Key? key, + required this.event, + required this.warningText, + }) : super(key: key); - Widget _makeCancelRegistrationButton(Event event, String warningText) { + final Event event; + final String warningText; + + @override + Widget build(BuildContext context) { return ElevatedButton.icon( onPressed: () async { final messenger = ScaffoldMessenger.of(context); final calendarCubit = BlocProvider.of(context); final welcomeCubit = BlocProvider.of(context); + final eventCubit = BlocProvider.of(context); + final registrationsCubit = BlocProvider.of(context); + final confirmed = await showDialog( context: context, builder: (context) { - return AlertDialog( - title: const Text('Cancel registration'), - content: Text( - warningText, - style: Theme.of(context).textTheme.bodyText2, - ), - actions: [ - TextButton.icon( - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(false), - icon: const Icon(Icons.clear), - label: const Text('NO'), - ), - ElevatedButton.icon( - onPressed: () => Navigator.of( - context, - rootNavigator: true, - ).pop(true), - icon: const Icon(Icons.check), - label: const Text('YES'), - ), - ], + return _ConfirmationDialog( + titleText: 'Cancel registration', + warningText: warningText, ); }, ); if (confirmed ?? false) { try { - await _eventCubit.cancelRegistration( + await eventCubit.cancelRegistration( registrationPk: event.registration!.pk, ); } on ApiException { @@ -692,16 +774,65 @@ class _EventScreenState extends State { )); } } - await _registrationsCubit.load(); + registrationsCubit.load(); calendarCubit.load(); - await welcomeCubit.load(); + welcomeCubit.load(); }, icon: const Icon(Icons.delete_forever_outlined), label: const Text('CANCEL REGISTRATION'), ); } +} + +/// A dialog that shows a message with buttons 'YES' and 'NO', popping with a bool. +class _ConfirmationDialog extends StatelessWidget { + const _ConfirmationDialog({ + Key? key, + required this.titleText, + required this.warningText, + }) : super(key: key); + + final String titleText; + final String warningText; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(titleText), + content: Text( + warningText, + style: Theme.of(context).textTheme.bodyText2, + ), + actions: [ + TextButton.icon( + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(false), + icon: const Icon(Icons.clear), + label: const Text('NO'), + ), + ElevatedButton.icon( + onPressed: () => Navigator.of( + context, + rootNavigator: true, + ).pop(true), + icon: const Icon(Icons.check), + label: const Text('YES'), + ), + ], + ); + } +} + +/// A button that opens the registration fields page. +class _UpdateRegistrationButton extends StatelessWidget { + const _UpdateRegistrationButton(this.event); + + final Event event; - Widget _makeUpdateButton(Event event) { + @override + Widget build(BuildContext context) { return ElevatedButton.icon( onPressed: () => context.pushNamed( 'event-registration', @@ -714,8 +845,154 @@ class _EventScreenState extends State { label: const Text('UPDATE REGISTRATION'), ); } +} + +/// The basic event info: title, time, location, price. +class _BasicEventInfo extends StatelessWidget { + static final dateTimeFormatter = DateFormat('E d MMM y, HH:mm'); - Widget _makeFoodButton(Event event) { + const _BasicEventInfo(this.event); + + final Event event; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 8), + Text( + event.title.toUpperCase(), + style: textTheme.headline6, + ), + const Divider(height: 24), + Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + fit: FlexFit.tight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('FROM', style: textTheme.caption), + const SizedBox(height: 4), + Text( + dateTimeFormatter.format(event.start.toLocal()), + style: textTheme.subtitle2, + ), + ], + ), + ), + const SizedBox(width: 8), + Flexible( + fit: FlexFit.tight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('UNTIL', style: textTheme.caption), + const SizedBox(height: 4), + Text( + dateTimeFormatter.format(event.end.toLocal()), + style: textTheme.subtitle2, + ), + ], + ), + ) + ], + ), + const SizedBox(height: 12), + Row( + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + fit: FlexFit.tight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('LOCATION', style: textTheme.caption), + const SizedBox(height: 4), + Text( + event.location, + style: textTheme.subtitle2, + ), + ], + ), + ), + const SizedBox(width: 8), + Flexible( + fit: FlexFit.tight, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('PRICE', style: textTheme.caption), + const SizedBox(height: 4), + Text( + '€${event.price}', + style: textTheme.subtitle2, + ), + ], + ), + ) + ], + ), + const Divider(height: 24), + ], + ); + } +} + +/// A map of the event location, that opens a maps app when tapped. +class _EventMap extends StatelessWidget { + const _EventMap(this.event); + + final Event event; + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.loose, + children: [ + CachedImage( + imageUrl: event.mapsUrl, + placeholder: 'assets/img/map_placeholder.png', + fit: BoxFit.cover, + ), + Positioned.fill( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { + Uri url = Theme.of(context).platform == TargetPlatform.iOS + ? Uri( + scheme: 'maps', + queryParameters: {'daddr': event.location}, + ) + : Uri( + scheme: 'https', + host: 'maps.google.com', + path: 'maps', + queryParameters: {'daddr': event.location}, + ); + launchUrl(url, mode: LaunchMode.externalNonBrowserApplication); + }, + ), + ), + ), + ], + ); + } +} + +/// A button that opens the food ordering page. +class _FoodButton extends StatelessWidget { + const _FoodButton(this.event); + + final Event event; + + @override + Widget build(BuildContext context) { return SizedBox( width: double.infinity, child: ElevatedButton.icon( @@ -725,39 +1002,16 @@ class _EventScreenState extends State { ), ); } +} - TextSpan _makeTermsAndConditions(Event event) { - final url = config.termsAndConditionsUrl; - return TextSpan( - children: [ - const TextSpan( - text: 'By registering, you confirm that you have read the ', - ), - TextSpan( - text: 'terms and conditions', - recognizer: TapGestureRecognizer() - ..onTap = () async { - final messenger = ScaffoldMessenger.of(context); - try { - await launchUrl(url, mode: LaunchMode.externalApplication); - } catch (_) { - messenger.showSnackBar(SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Could not open "${url.toString()}".'), - )); - } - }, - style: TextStyle(color: Theme.of(context).colorScheme.primary), - ), - const TextSpan( - text: ', that you understand them and ' - 'that you agree to be bound by them.', - ), - ], - ); - } +/// A widget that displays the event's description HTML. +class _EventDescription extends StatelessWidget { + const _EventDescription(this.event); + + final Event event; - Widget _makeDescription(Event event) { + @override + Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric( horizontal: 16, @@ -790,86 +1044,16 @@ class _EventScreenState extends State { ), ); } +} - SliverPadding _makeRegistrationsHeader(RegistrationsState state) { - return SliverPadding( - padding: const EdgeInsets.only(left: 16), - sliver: SliverToBoxAdapter( - child: Text( - 'REGISTRATIONS', - style: Theme.of(context).textTheme.caption, - ), - ), - ); - } - - SliverPadding _makeRegistrations(RegistrationsState state) { - if (state.isLoading && state.results.isEmpty) { - return const SliverPadding( - padding: EdgeInsets.all(16), - sliver: SliverToBoxAdapter( - child: Center( - child: CircularProgressIndicator(), - ), - ), - ); - } else if (state.hasException) { - return SliverPadding( - padding: const EdgeInsets.only(left: 16, right: 16, top: 0, bottom: 16), - sliver: SliverToBoxAdapter(child: Text(state.message!)), - ); - } else { - return SliverPadding( - padding: const EdgeInsets.only(left: 16, right: 16, top: 8, bottom: 16), - sliver: SliverGrid( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - ), - delegate: SliverChildBuilderDelegate( - (context, index) { - if (state.results[index].member != null) { - return MemberTile( - member: state.results[index].member!, - ); - } else { - return DefaultMemberTile( - name: state.results[index].name!, - ); - } - }, - childCount: state.results.length, - ), - ), - ); - } - } +/// A button that creates a calendar entry for the event. +class _CalendarExportButton extends StatelessWidget { + const _CalendarExportButton(this.event); - Widget _makeShareEventButton(int pk) { - return IconButton( - padding: const EdgeInsets.all(16), - color: Theme.of(context).primaryIconTheme.color, - icon: Icon( - Theme.of(context).platform == TargetPlatform.iOS - ? Icons.ios_share - : Icons.share, - ), - onPressed: () async { - final messenger = ScaffoldMessenger.of(context); - try { - await Share.share('https://${config.apiHost}/events/$pk/'); - } catch (_) { - messenger.showSnackBar(const SnackBar( - behavior: SnackBarBehavior.floating, - content: Text('Could not share the event.'), - )); - } - }, - ); - } + final Event event; - Widget _makeCalendarExportButton(Event event) { + @override + Widget build(BuildContext context) { return IconButton( padding: const EdgeInsets.all(16), color: Theme.of(context).primaryIconTheme.color, @@ -885,105 +1069,29 @@ class _EventScreenState extends State { }, ); } +} + +/// A button that shares the event's URL. +class _ShareEventButton extends StatelessWidget { + const _ShareEventButton(this.pk); + + final int pk; @override Widget build(BuildContext context) { - return BlocBuilder( - bloc: _eventCubit, - builder: (context, state) { - if (state is ErrorState) { - return Scaffold( - appBar: ThaliaAppBar( - title: Text(widget.event?.title.toUpperCase() ?? 'EVENT'), - actions: [_makeShareEventButton(widget.pk)], - ), - body: RefreshIndicator( - onRefresh: () async { - // Await only the event info. - _registrationsCubit.load(); - await _eventCubit.load(); - }, - child: ErrorScrollView(state.message!), - ), - ); - } else if (state is LoadingState && - state is! ResultState && - widget.event == null) { - return Scaffold( - appBar: ThaliaAppBar( - title: const Text('EVENT'), - actions: [_makeShareEventButton(widget.pk)], - ), - body: const Center(child: CircularProgressIndicator()), - ); - } else { - final event = (state.result ?? widget.event)!; - return Scaffold( - appBar: ThaliaAppBar( - title: Text(event.title.toUpperCase()), - actions: [ - _makeCalendarExportButton(event), - _makeShareEventButton(widget.pk), - if (event.userPermissions.manageEvent) - IconButton( - padding: const EdgeInsets.all(16), - icon: const Icon(Icons.settings), - onPressed: () => context.pushNamed( - 'event-admin', - params: {'eventPk': event.pk.toString()}, - ), - ), - ], - ), - body: RefreshIndicator( - onRefresh: () async { - // Await only the event info. - _registrationsCubit.load(); - await _eventCubit.load(); - }, - child: BlocBuilder( - bloc: _registrationsCubit, - builder: (context, listState) { - return 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), - ], - ), - ), - if (event.registrationIsOptional || - event.registrationIsRequired) ...[ - const SliverToBoxAdapter(child: Divider()), - _makeRegistrationsHeader(listState), - _makeRegistrations(listState), - if (listState.isLoadingMore) - const SliverPadding( - padding: EdgeInsets.all(8), - sliver: SliverList( - delegate: SliverChildListDelegate.fixed([ - Center(child: CircularProgressIndicator()), - ]), - ), - ), - ], - ], - ), - ); - }, - ), - ), - ); + return IconButton( + padding: const EdgeInsets.all(16), + color: Theme.of(context).primaryIconTheme.color, + icon: Icon(Icons.adaptive.share), + onPressed: () async { + final messenger = ScaffoldMessenger.of(context); + try { + await Share.share('https://${config.apiHost}/events/$pk/'); + } catch (_) { + messenger.showSnackBar(const SnackBar( + behavior: SnackBarBehavior.floating, + content: Text('Could not share the event.'), + )); } }, ); diff --git a/lib/ui/screens/members_screen.dart b/lib/ui/screens/members_screen.dart index 867c1eb68..af62b1f19 100644 --- a/lib/ui/screens/members_screen.dart +++ b/lib/ui/screens/members_screen.dart @@ -3,40 +3,10 @@ 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'; -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) { return Scaffold( @@ -63,21 +33,9 @@ class _MembersScreenState extends State { ), drawer: MenuDrawer(), body: RefreshIndicator( - onRefresh: () async { - await _cubit.load(); - }, - child: BlocBuilder( - builder: (context, listState) { - if (listState.hasException) { - return ErrorScrollView(listState.message!); - } else { - return MemberListScrollView( - key: const PageStorageKey('members'), - controller: _controller, - listState: listState, - ); - } - }, + onRefresh: () => BlocProvider.of(context).load(), + child: PaginatedScrollView( + resultsBuilder: (_, results) => [_MembersGrid(results)], ), ), ); @@ -86,21 +44,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) { @@ -141,93 +86,47 @@ 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!); - } else { - return MemberListScrollView( - key: const PageStorageKey('members-search'), - controller: _controller, - listState: listState, - ); - } - }, + return BlocProvider.value( + value: _cubit..search(query), + child: PaginatedScrollView( + resultsBuilder: (_, results) => [_MembersGrid(results)], + ), ); } @override Widget buildSuggestions(BuildContext context) { - return BlocBuilder( - bloc: _cubit..search(query), - builder: (context, listState) { - if (listState.hasException) { - return ErrorScrollView(listState.message!); - } else { - return MemberListScrollView( - key: const PageStorageKey('members-search'), - controller: _controller, - listState: listState, - ); - } - }, + return BlocProvider.value( + value: _cubit..search(query), + child: PaginatedScrollView( + resultsBuilder: (_, results) => [_MembersGrid(results)], + ), ); } } -/// 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; +class _MembersGrid extends StatelessWidget { + const _MembersGrid(this.results); - const MemberListScrollView({ - Key? key, - required this.controller, - required this.listState, - }) : super(key: key); + final List results; @override Widget build(BuildContext context) { - return Scrollbar( - controller: controller, - child: CustomScrollView( - controller: controller, - physics: const RangeMaintainingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), + return SliverPadding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + ), + delegate: SliverChildBuilderDelegate( + (context, index) => MemberTile( + member: results[index], ), - slivers: [ - 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, - ), - ), - ), - if (listState.isLoadingMore) - const SliverPadding( - padding: EdgeInsets.all(8), - sliver: SliverList( - delegate: SliverChildListDelegate.fixed([ - Center( - child: CircularProgressIndicator(), - ) - ]), - ), - ), - ], - )); + childCount: results.length, + ), + ), + ); } } diff --git a/lib/ui/widgets.dart b/lib/ui/widgets.dart index e3ffbc54d..094485332 100644 --- a/lib/ui/widgets.dart +++ b/lib/ui/widgets.dart @@ -7,6 +7,7 @@ export 'widgets/event_detail_card.dart'; export 'widgets/mark_present_dialog.dart'; export 'widgets/member_tile.dart'; export 'widgets/menu_drawer.dart'; +export 'widgets/paginated_scroll_view.dart'; export 'widgets/push_notification_dialog.dart'; export 'widgets/push_notification_overlay.dart'; export 'widgets/sales_order_dialog.dart'; diff --git a/lib/ui/widgets/paginated_scroll_view.dart b/lib/ui/widgets/paginated_scroll_view.dart new file mode 100644 index 000000000..1354f82ff --- /dev/null +++ b/lib/ui/widgets/paginated_scroll_view.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:reaxit/blocs/list_state.dart'; + +abstract class PaginatedCubit extends Cubit> { + final int firstPageSize; + final int pageSize; + + PaginatedCubit({ + required this.firstPageSize, + required this.pageSize, + }) : super(const LoadingListState()); + + /// Load the first page of results. + Future load(); + + /// Load another page of results. + Future more(); +} + +/// A widget that displays and triggers loading of a paginated list. +class PaginatedScrollView> + extends StatefulWidget { + const PaginatedScrollView({ + super.key, + required this.resultsBuilder, + this.loadingBuilder, + }); + + /// 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; + + @override + State> createState() => + _PaginatedScrollViewState(); +} + +class _PaginatedScrollViewState> + extends State> { + late ScrollController controller; + + @override + void initState() { + controller = ScrollController()..addListener(_scrollListener); + super.initState(); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + void _scrollListener() { + final position = controller.position; + if (position.pixels >= position.maxScrollExtent - 300) { + final cubit = BlocProvider.of(context); + final state = cubit.state; + if (state is ResultsListState && + state is! DoneListState && + state is! LoadingMoreListState) { + cubit.more(); + } + } + } + + @override + Widget build(BuildContext context) { + return BlocBuilder>( + builder: (context, state) { + late final List slivers; + if (state is ErrorListState) { + slivers = [ + SliverSafeArea( + minimum: const EdgeInsets.all(16), + sliver: SliverToBoxAdapter( + child: Column( + children: [ + Container( + height: 100, + margin: const EdgeInsets.all(12), + child: Image.asset( + 'assets/img/sad-cloud.png', + fit: BoxFit.fitHeight, + ), + ), + Text(state.message!, textAlign: TextAlign.center), + ], + ), + ), + ), + ]; + } else if (state is LoadingListState) { + if (widget.loadingBuilder != null) { + slivers = widget.loadingBuilder!(context); + } else { + slivers = []; + } + } else { + final resultSlivers = widget.resultsBuilder(context, state.results); + + slivers = [ + ...resultSlivers, + if (state is LoadingMoreListState) + 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), + ), + ]; + } + + return Scrollbar( + controller: controller, + child: CustomScrollView( + controller: controller, + physics: const RangeMaintainingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: slivers, + ), + ); + }, + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index ba980a62d..e4f587520 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -480,7 +480,7 @@ packages: name: go_router url: "https://pub.dartlang.org" source: hosted - version: "5.2.0" + version: "6.0.1" google_fonts: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a2d94bf1e..48669f2f7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -110,7 +110,7 @@ dependencies: flutter_cache_manager: ^3.3.0 # Navigation. - go_router: ^5.1.10 + go_router: ^6.0.1 # Displaying QR codes. qr_flutter: ^4.0.0