diff --git a/app/lib/blocs/sourced_paging.dart b/app/lib/blocs/sourced_paging.dart new file mode 100644 index 00000000000..677415fda4d --- /dev/null +++ b/app/lib/blocs/sourced_paging.dart @@ -0,0 +1,176 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:dart_mappable/dart_mappable.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flow/cubits/flow.dart'; +import 'package:flow_api/models/model.dart'; +import 'package:flow_api/services/source.dart'; + +part 'sourced_paging_event.dart'; +part 'sourced_paging_state.dart'; + +part 'sourced_paging.mapper.dart'; + +typedef DateFetcher = Future?> Function( + String source, SourceService service, int offset, int limit, int date); +typedef ItemFetcher = Future?> Function( + String source, SourceService service, int offset, int limit); +typedef SourceFetcher = Future?> Function( + SourceService service, int offset, int limit); + +class SourcedPagingBloc + extends Bloc> { + final FlowCubit cubit; + final int pageSize; + final bool useDates; + final List? sources; + final DateFetcher _fetch; + + SourcedPagingBloc.dated( + {required this.cubit, + this.sources, + required DateFetcher fetch, + this.pageSize = 50}) + : _fetch = fetch, + useDates = true, + super(const SourcedPagingInitial()) { + _init(); + } + + SourcedPagingBloc.item( + {required this.cubit, this.sources, required fetch, this.pageSize = 50}) + : useDates = false, + _fetch = _buildDatedFetch(fetch), + super(const SourcedPagingInitial()) { + _init(); + } + + SourcedPagingBloc.source({ + required this.cubit, + required String source, + required SourceFetcher fetch, + this.pageSize = 50, + }) : sources = [source], + useDates = false, + _fetch = _buildDatedFetchSource(fetch), + super(const SourcedPagingInitial()) { + _init(); + } + + void _init() { + on(_onFetched); + on((event, emit) { + emit(const SourcedPagingInitial()); + fetch(); + }); + on((event, emit) { + final state = this.state; + if (state is SourcedPagingSuccess) { + final items = state.dates.map((e) => e + .where((i) => + i.model == event.item && + (event.source == null || (i.source == event.source))) + .toList()); + emit(SourcedPagingSuccess( + currentPageKey: state.currentPageKey, + dates: items.toList(), + hasReachedMax: state.hasReachedMax, + currentDate: state.currentDate, + )); + } + }); + fetch(); + } + + Future _onFetched( + SourcedPagingFetched event, + Emitter> emit, + ) async { + final state = this.state; + if (state.hasReachedMax && !useDates) return; + + final date = state.currentDate; + final previousItems = state is SourcedPagingSuccess + ? state.dates + : >>[]; + try { + final currentPageKey = state.currentPageKey ?? + SourcedModel(cubit.getCurrentSources().first, 0); + + final fetchedItems = (await _fetch( + currentPageKey.source, + cubit.getService(currentPageKey.source), + currentPageKey.model * pageSize, + pageSize, + state.currentDate) ?? + []) + .map((e) => SourcedModel(currentPageKey.source, e)) + .toList(); + + final sources = this.sources ?? cubit.getCurrentSources(); + final currentSourceIndex = sources.indexOf(currentPageKey.source); + final keepSource = fetchedItems.length >= pageSize; + final isLastSource = currentSourceIndex >= sources.length - 1; + final items = List>>.from(previousItems); + if (items.length <= date) { + items.addAll(List.generate( + date - items.length + 1, + (_) => >[], + )); + } + items[date] = [...?previousItems.elementAtOrNull(date), ...fetchedItems]; + + if (isLastSource && !keepSource) { + emit(SourcedPagingSuccess( + currentPageKey: currentPageKey, + dates: items, + hasReachedMax: true, + currentDate: useDates ? (state.currentDate + 1) : state.currentDate, + )); + } else if (keepSource) { + emit(SourcedPagingSuccess( + dates: items, + currentPageKey: SourcedModel( + currentPageKey.source, + currentPageKey.model + 1, + ), + )); + } else { + final nextSource = sources[currentSourceIndex + 1]; + emit(SourcedPagingSuccess( + dates: items, + currentPageKey: SourcedModel(nextSource, 0), + currentDate: date, + )); + } + } catch (e) { + emit(SourcedPagingFailure( + e, + currentDate: date, + dates: previousItems, + )); + } + } + + void refresh() => add(SourcedPagingRefresh()); + void fetch() { + add(SourcedPagingFetched()); + } + + void remove(SourcedModel item) => + add(SourcedPagingRemoved(item.model, item.source)); + void removeSourced(T item) => add(SourcedPagingRemoved(item)); +} + +_buildDatedFetch(ItemFetcher fetch) => (String source, SourceService service, + int offset, int limit, int date) async { + final items = await fetch(source, service, offset, limit); + return items; + }; +_buildDatedFetchSource(SourceFetcher fetch) => (String source, + SourceService service, int offset, int limit, int date) async { + final items = await fetch(service, offset, limit); + return items; + }; diff --git a/app/lib/blocs/sourced_paging.mapper.dart b/app/lib/blocs/sourced_paging.mapper.dart new file mode 100644 index 00000000000..2020406a8ae --- /dev/null +++ b/app/lib/blocs/sourced_paging.mapper.dart @@ -0,0 +1,164 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, unnecessary_cast, override_on_non_overriding_member +// ignore_for_file: strict_raw_type, inference_failure_on_untyped_parameter + +part of 'sourced_paging.dart'; + +class SourcedPagingSuccessMapper extends ClassMapperBase { + SourcedPagingSuccessMapper._(); + + static SourcedPagingSuccessMapper? _instance; + static SourcedPagingSuccessMapper ensureInitialized() { + if (_instance == null) { + MapperContainer.globals.use(_instance = SourcedPagingSuccessMapper._()); + ConnectedModelMapper.ensureInitialized(); + } + return _instance!; + } + + @override + final String id = 'SourcedPagingSuccess'; + @override + Function get typeFactory => (f) => f>(); + + static List>> _$dates( + SourcedPagingSuccess v) => + v.dates; + static dynamic _arg$dates(f) => f>>>(); + static const Field>>> _f$dates = + Field('dates', _$dates, opt: true, def: const [], arg: _arg$dates); + static ConnectedModel _$currentPageKey(SourcedPagingSuccess v) => + v.currentPageKey; + static const Field> + _f$currentPageKey = Field('currentPageKey', _$currentPageKey); + static bool _$hasReachedMax(SourcedPagingSuccess v) => v.hasReachedMax; + static const Field _f$hasReachedMax = + Field('hasReachedMax', _$hasReachedMax, opt: true, def: false); + static int _$currentDate(SourcedPagingSuccess v) => v.currentDate; + static const Field _f$currentDate = + Field('currentDate', _$currentDate, opt: true, def: 0); + + @override + final MappableFields fields = const { + #dates: _f$dates, + #currentPageKey: _f$currentPageKey, + #hasReachedMax: _f$hasReachedMax, + #currentDate: _f$currentDate, + }; + + static SourcedPagingSuccess _instantiate(DecodingData data) { + return SourcedPagingSuccess( + dates: data.dec(_f$dates), + currentPageKey: data.dec(_f$currentPageKey), + hasReachedMax: data.dec(_f$hasReachedMax), + currentDate: data.dec(_f$currentDate)); + } + + @override + final Function instantiate = _instantiate; +} + +mixin SourcedPagingSuccessMappable { + SourcedPagingSuccessCopyWith, SourcedPagingSuccess, + SourcedPagingSuccess, T> + get copyWith => _SourcedPagingSuccessCopyWithImpl< + SourcedPagingSuccess, + SourcedPagingSuccess, + T>(this as SourcedPagingSuccess, $identity, $identity); + @override + String toString() { + return SourcedPagingSuccessMapper.ensureInitialized() + .stringifyValue(this as SourcedPagingSuccess); + } + + @override + bool operator ==(Object other) { + return SourcedPagingSuccessMapper.ensureInitialized() + .equalsValue(this as SourcedPagingSuccess, other); + } + + @override + int get hashCode { + return SourcedPagingSuccessMapper.ensureInitialized() + .hashValue(this as SourcedPagingSuccess); + } +} + +extension SourcedPagingSuccessValueCopy<$R, $Out, T> + on ObjectCopyWith<$R, SourcedPagingSuccess, $Out> { + SourcedPagingSuccessCopyWith<$R, SourcedPagingSuccess, $Out, T> + get $asSourcedPagingSuccess => $base.as((v, t, t2) => + _SourcedPagingSuccessCopyWithImpl<$R, $Out, T>(v, t, t2)); +} + +abstract class SourcedPagingSuccessCopyWith< + $R, + $In extends SourcedPagingSuccess, + $Out, + T> implements ClassCopyWith<$R, $In, $Out> { + ListCopyWith< + $R, + List>, + ObjectCopyWith<$R, List>, + List>>> get dates; + ConnectedModelCopyWith<$R, ConnectedModel, + ConnectedModel, String, int> get currentPageKey; + $R call( + {List>>? dates, + ConnectedModel? currentPageKey, + bool? hasReachedMax, + int? currentDate}); + SourcedPagingSuccessCopyWith<$R2, $In, $Out2, T> $chain<$R2, $Out2>( + Then<$Out2, $R2> t); +} + +class _SourcedPagingSuccessCopyWithImpl<$R, $Out, T> + extends ClassCopyWithBase<$R, SourcedPagingSuccess, $Out> + implements + SourcedPagingSuccessCopyWith<$R, SourcedPagingSuccess, $Out, T> { + _SourcedPagingSuccessCopyWithImpl(super.value, super.then, super.then2); + + @override + late final ClassMapperBase $mapper = + SourcedPagingSuccessMapper.ensureInitialized(); + @override + ListCopyWith< + $R, + List>, + ObjectCopyWith<$R, List>, + List>>> get dates => ListCopyWith( + $value.dates, + (v, t) => ObjectCopyWith(v, $identity, t), + (v) => call(dates: v)); + @override + ConnectedModelCopyWith<$R, ConnectedModel, + ConnectedModel, String, int> + get currentPageKey => + $value.currentPageKey.copyWith.$chain((v) => call(currentPageKey: v)); + @override + $R call( + {List>>? dates, + ConnectedModel? currentPageKey, + bool? hasReachedMax, + int? currentDate}) => + $apply(FieldCopyWithData({ + if (dates != null) #dates: dates, + if (currentPageKey != null) #currentPageKey: currentPageKey, + if (hasReachedMax != null) #hasReachedMax: hasReachedMax, + if (currentDate != null) #currentDate: currentDate + })); + @override + SourcedPagingSuccess $make(CopyWithData data) => SourcedPagingSuccess( + dates: data.get(#dates, or: $value.dates), + currentPageKey: data.get(#currentPageKey, or: $value.currentPageKey), + hasReachedMax: data.get(#hasReachedMax, or: $value.hasReachedMax), + currentDate: data.get(#currentDate, or: $value.currentDate)); + + @override + SourcedPagingSuccessCopyWith<$R2, SourcedPagingSuccess, $Out2, T> + $chain<$R2, $Out2>(Then<$Out2, $R2> t) => + _SourcedPagingSuccessCopyWithImpl<$R2, $Out2, T>($value, $cast, t); +} diff --git a/app/lib/blocs/sourced_paging_event.dart b/app/lib/blocs/sourced_paging_event.dart new file mode 100644 index 00000000000..511c84d4d45 --- /dev/null +++ b/app/lib/blocs/sourced_paging_event.dart @@ -0,0 +1,30 @@ +part of 'sourced_paging.dart'; + +abstract class SourcedPagingEvent extends Equatable { + @override + List get props => []; +} + +class SourcedPagingFetched extends SourcedPagingEvent { + SourcedPagingFetched(); + + @override + List get props => []; +} + +class SourcedPagingRefresh extends SourcedPagingEvent { + SourcedPagingRefresh(); + + @override + List get props => []; +} + +class SourcedPagingRemoved extends SourcedPagingEvent { + final String? source; + final Object? item; + + SourcedPagingRemoved(this.item, [this.source]); + + @override + List get props => [source, item].nonNulls.toList(growable: false); +} diff --git a/app/lib/blocs/sourced_paging_state.dart b/app/lib/blocs/sourced_paging_state.dart new file mode 100644 index 00000000000..64c8f236755 --- /dev/null +++ b/app/lib/blocs/sourced_paging_state.dart @@ -0,0 +1,54 @@ +part of 'sourced_paging.dart'; + +enum SourcedPagingStatus { initial, success, failure } + +sealed class SourcedPagingState { + const SourcedPagingState(); + + SourcedModel? get currentPageKey => null; + bool get hasReachedMax => false; + List>> get dates => const []; + List> get items => dates.expand((e) => e).toList(); + int get currentDate => 0; +} + +final class SourcedPagingInitial extends SourcedPagingState { + const SourcedPagingInitial(); +} + +@MappableClass( + generateMethods: GenerateMethods.copy | + GenerateMethods.stringify | + GenerateMethods.equals) +final class SourcedPagingSuccess extends SourcedPagingState + with SourcedPagingSuccessMappable { + @override + final List>> dates; + @override + final SourcedModel currentPageKey; + @override + final bool hasReachedMax; + @override + final int currentDate; + + const SourcedPagingSuccess({ + this.dates = const [], + required this.currentPageKey, + this.hasReachedMax = false, + this.currentDate = 0, + }); +} + +final class SourcedPagingFailure extends SourcedPagingState { + final Object error; + @override + final List>> dates; + @override + final int currentDate; + + const SourcedPagingFailure(this.error, + {this.dates = const [], this.currentDate = 0}); + + @override + bool get hasReachedMax => true; +} diff --git a/app/lib/helpers/sourced_paging_controller.dart b/app/lib/helpers/sourced_paging_controller.dart deleted file mode 100644 index a5c92241b8f..00000000000 --- a/app/lib/helpers/sourced_paging_controller.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'dart:async'; - -import 'package:flow/cubits/flow.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import 'package:flow_api/models/model.dart'; -import 'package:flow_api/services/source.dart'; - -class SourcedPagingController - extends PagingController, SourcedModel> { - final FlowCubit cubit; - final int pageSize; - - List get sources => cubit.getCurrentSources(); - - SourcedPagingController(this.cubit, {this.pageSize = 50}) - : super(firstPageKey: const SourcedModel("", -1)); - - PageRequestListener> addFetchListener( - Future?> Function(String, SourceService, int offset, int limit) - fetch) { - FutureOr listener(SourcedModel pageKey) async { - final isFirstPage = pageKey.model < 0; - var currentPageKey = pageKey; - if (isFirstPage) { - currentPageKey = SourcedModel(sources.first, 0); - } - final fetched = (await fetch( - currentPageKey.source, - cubit.getService(currentPageKey.source), - currentPageKey.model * pageSize, - pageSize) ?? - []) - .map((e) => SourcedModel(currentPageKey.source, e)) - .toList(); - final index = sources.indexOf(currentPageKey.source); - final currentSource = isFirstPage ? sources.first : currentPageKey.source; - final keepSource = fetched.length >= pageSize; - final isLastSource = index >= sources.length - 1; - if (isLastSource && !keepSource) { - appendLastPage(fetched); - } else if (keepSource) { - appendPage( - fetched, SourcedModel(currentSource, currentPageKey.model + 1)); - } else { - final nextSource = sources[index + 1]; - appendPage(fetched, SourcedModel(nextSource, 0)); - } - } - - addPageRequestListener(listener); - return listener; - } -} diff --git a/app/lib/pages/calendar/list.dart b/app/lib/pages/calendar/list.dart index f2a7310ce10..59b05bc49d9 100644 --- a/app/lib/pages/calendar/list.dart +++ b/app/lib/pages/calendar/list.dart @@ -1,8 +1,9 @@ +import 'package:flow/blocs/sourced_paging.dart'; import 'package:flow/cubits/flow.dart'; -import 'package:flow/widgets/builder_delegate.dart'; +import 'package:flow/widgets/paging/list.dart'; +import 'package:flow_api/services/source.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:intl/intl.dart'; import 'package:material_leap/material_leap.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; @@ -33,108 +34,61 @@ class CalendarListView extends StatefulWidget { class _CalendarListViewState extends State { late FlowCubit _cubit; - late final PagingController< - ConnectedModel, Map>>, - List>> _controller; + late final SourcedPagingBloc> _bloc; static const _pageSize = 50; @override void initState() { super.initState(); _cubit = context.read(); - _controller = PagingController( - firstPageKey: const ConnectedModel(-1, ConnectedModel({}, {}))); - _controller.addPageRequestListener(_requestPage); + _bloc = SourcedPagingBloc.dated( + cubit: _cubit, + fetch: _fetchCalendarItems, + pageSize: _pageSize, + ); } @override void dispose() { - _controller.dispose(); + _bloc.close(); super.dispose(); } - Future _requestPage( - ConnectedModel, Map>> - key) async { - var day = key.source; - var sources = key.model; - final allSources = widget.filter.source != null - ? [ - widget.filter.source!, - ] - : _cubit.getCurrentSources(); - - ConnectedModel, Map> createFullSourceMap() { - final map = Map.fromEntries(allSources.map((e) => MapEntry(e, 0))); - return ConnectedModel(map, Map.from(map)); - } - - if (day < 0) { - day = 0; - sources = createFullSourceMap(); - } - var appointmentSource = sources.source; - var items = >[]; - if (appointmentSource.isNotEmpty) { - final model = await _fetchCalendarItems(day, appointmentSource); - items.addAll(model.source); - appointmentSource.removeWhere((key, value) => !model.model.contains(key)); - } - if (appointmentSource.isEmpty) { - day++; - sources = createFullSourceMap(); - } - if (mounted) { - _controller.appendPage([items], ConnectedModel(day, sources)); - } - } - - Future< - ConnectedModel>, - List>> _fetchCalendarItems( - int day, Map sources) async { - if (!mounted) return ConnectedModel([], sources.keys.toList()); - var date = DateTime.now().onlyDate(); + Future>> _fetchCalendarItems( + String source, + SourceService service, + int offset, + int limit, + int date, + ) async { + var dateTime = DateTime.now().onlyDate(); if (widget.filter.past) { - date = date.subtract(Duration(days: day)); + dateTime = dateTime.subtract(Duration(days: date)); } else { - date = date.add(Duration(days: day)); + dateTime = dateTime.add(Duration(days: date)); } - if (!mounted) return ConnectedModel([], sources.keys.toList()); - - final appointments = >[]; - final nextSources = []; - for (final source in sources.entries) { - final fetched = - await _cubit.getService(source.key).calendarItem?.getCalendarItems( - date: date, - status: EventStatus.values - .where((element) => - !widget.filter.hiddenStatuses.contains(element)) - .toList(), - search: widget.search, - groupIds: widget.filter.groups, - eventId: widget.filter.event, - offset: source.value * _pageSize, - limit: _pageSize, - resourceIds: widget.filter.resources, - ); - if (fetched == null) continue; - appointments - .addAll(fetched.map((event) => SourcedModel(source.key, event))); - if (fetched.length >= _pageSize) { - nextSources.add(source.key); - } - } - return ConnectedModel(appointments, nextSources); + return await service.calendarItem?.getCalendarItems( + date: dateTime, + status: EventStatus.values + .where( + (element) => !widget.filter.hiddenStatuses.contains(element)) + .toList(), + search: widget.search, + groupIds: widget.filter.groups, + eventId: widget.filter.event, + offset: offset * _pageSize, + limit: _pageSize, + resourceIds: widget.filter.resources, + ) ?? + const []; } @override void didUpdateWidget(covariant CalendarListView oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.filter != widget.filter) { - _controller.refresh(); + _bloc.refresh(); } } @@ -143,7 +97,7 @@ class _CalendarListViewState extends State { final locale = Localizations.localeOf(context).languageCode; final dateFormatter = DateFormat.yMMMMd(locale); return CreateEventScaffold( - onCreated: _controller.refresh, + onCreated: _bloc.refresh, event: widget.filter.sourceEvent, child: Column( children: [ @@ -155,97 +109,93 @@ class _CalendarListViewState extends State { const SizedBox(height: 8), Expanded( child: LayoutBuilder( - builder: (context, constraints) => PagedListView( - pagingController: _controller, - builderDelegate: buildMaterialPagedDelegate< - List>>( - _controller, - (context, item, index) { - var date = DateTime.now(); - if (widget.filter.past) { - date = date.subtract(Duration(days: index)); - } else { - date = date.add(Duration(days: index)); - } - final header = Padding( - padding: const EdgeInsets.symmetric( - vertical: 64, - horizontal: 16, - ), - child: Column( - children: [ - if (index == 0) - Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: PhosphorIcon( - PhosphorIconsLight.calendarBlank, - color: Theme.of(context).colorScheme.secondary, - size: 64, - ), - ), - Text( - dateFormatter.format(date), - style: Theme.of(context).textTheme.titleLarge, - ), - Text( - DateFormat.EEEE(locale).format(date), - ), - ], - ), - ); - final list = Column( + builder: (context, constraints) => PagedListView.dated( + bloc: _bloc, + dateBuilder: (context, items, index) { + var date = DateTime.now(); + if (widget.filter.past) { + date = date.subtract(Duration(days: index)); + } else { + date = date.add(Duration(days: index)); + } + final header = Padding( + padding: const EdgeInsets.symmetric( + vertical: 64, + horizontal: 16, + ), + child: Column( children: [ - if (item.isEmpty) + if (index == 0) Padding( padding: const EdgeInsets.symmetric(vertical: 16), - child: Text( - AppLocalizations.of(context).noEvents, - style: Theme.of(context).textTheme.bodyLarge, + child: PhosphorIcon( + PhosphorIconsLight.calendarBlank, + color: Theme.of(context).colorScheme.secondary, + size: 64, ), ), - ...item.map((event) { - return CalendarListTile( - key: ValueKey([ - event.main.id, - event.source, - event.main.runtimeType - ]), - eventItem: event, - date: date, - onRefresh: _controller.refresh, - ); - }), + Text( + dateFormatter.format(date), + style: Theme.of(context).textTheme.titleLarge, + ), + Text( + DateFormat.EEEE(locale).format(date), + ), ], - ); - final isMobile = constraints.maxWidth < 800; - return Align( - alignment: Alignment.topCenter, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1000), - child: GestureDetector( - onTap: () => showCalendarCreate( - context: context, - time: date, - event: widget.filter.sourceEvent, - ).then((value) => _controller.refresh()), - child: isMobile - ? Column( - children: [ - header, - list, - ], - ) - : Row( - children: [ - header, - const SizedBox(width: 16), - Expanded(child: list), - ], - ), - )), - ); - }, - ), + ), + ); + final list = Column( + children: [ + if (items.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Text( + AppLocalizations.of(context).noEvents, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ...items.map((event) { + return CalendarListTile( + key: ValueKey([ + event.main.id, + event.source, + event.main.runtimeType + ]), + eventItem: event, + date: date, + onRefresh: _bloc.refresh, + ); + }), + ], + ); + final isMobile = constraints.maxWidth < 800; + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1000), + child: GestureDetector( + onTap: () => showCalendarCreate( + context: context, + time: date, + event: widget.filter.sourceEvent, + ).then((value) => _bloc.refresh()), + child: isMobile + ? Column( + children: [ + header, + list, + ], + ) + : Row( + children: [ + header, + const SizedBox(width: 16), + Expanded(child: list), + ], + ), + )), + ); + }, ), ), ), diff --git a/app/lib/pages/calendar/pending.dart b/app/lib/pages/calendar/pending.dart index b72c2d87900..147fa4dfa2d 100644 --- a/app/lib/pages/calendar/pending.dart +++ b/app/lib/pages/calendar/pending.dart @@ -1,13 +1,12 @@ +import 'package:flow/blocs/sourced_paging.dart'; +import 'package:flow/widgets/paging/list.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:flow_api/models/event/item/model.dart'; import 'package:flow_api/models/event/model.dart'; import 'package:flow_api/models/model.dart'; import '../../cubits/flow.dart'; -import '../../helpers/sourced_paging_controller.dart'; -import '../../widgets/builder_delegate.dart'; import 'filter.dart'; import 'page.dart'; import 'tile.dart'; @@ -30,30 +29,30 @@ class CalendarPendingView extends StatefulWidget { class _CalendarPendingViewState extends State { late FlowCubit _cubit; - late final SourcedPagingController> - _controller; + late final SourcedPagingBloc> _bloc; @override void initState() { super.initState(); _cubit = context.read(); - _controller = SourcedPagingController(_cubit); - _controller.addFetchListener((source, service, offset, limit) async => - service.calendarItem?.getCalendarItems( - status: EventStatus.values - .where( - (element) => !widget.filter.hiddenStatuses.contains(element)) - .toList(), - search: widget.search, - pending: true, - offset: offset, - limit: limit, - resourceIds: widget.filter.resources, - )); + _bloc = SourcedPagingBloc.item( + cubit: _cubit, + fetch: (source, service, offset, limit) async => + service.calendarItem?.getCalendarItems( + status: EventStatus.values + .where((element) => + !widget.filter.hiddenStatuses.contains(element)) + .toList(), + search: widget.search, + pending: true, + offset: offset, + limit: limit, + resourceIds: widget.filter.resources, + )); } @override void dispose() { - _controller.dispose(); + _bloc.close(); super.dispose(); } @@ -61,14 +60,14 @@ class _CalendarPendingViewState extends State { void didUpdateWidget(covariant CalendarPendingView oldWidget) { super.didUpdateWidget(oldWidget); if (widget.filter != oldWidget.filter) { - _controller.refresh(); + _bloc.refresh(); } } @override Widget build(BuildContext context) { return CreateEventScaffold( - onCreated: _controller.refresh, + onCreated: _bloc.refresh, event: widget.filter.sourceEvent, child: Column( children: [ @@ -80,22 +79,19 @@ class _CalendarPendingViewState extends State { const SizedBox(height: 8), Expanded( child: LayoutBuilder( - builder: (context, constraints) => PagedListView( - pagingController: _controller, - builderDelegate: buildMaterialPagedDelegate< - SourcedConnectedModel>( - _controller, - (context, item, index) { - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 1000), - child: CalendarListTile( - key: ValueKey('${item.source}@${item.main.id}'), - eventItem: item, - onRefresh: _controller.refresh, - ), - ); - }, - ), + builder: (context, constraints) => + PagedListView>.item( + bloc: _bloc, + itemBuilder: (context, item, index) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1000), + child: CalendarListTile( + key: ValueKey('${item.source}@${item.main.id}'), + eventItem: item, + onRefresh: _bloc.refresh, + ), + ); + }, ), ), ), diff --git a/app/lib/pages/events/page.dart b/app/lib/pages/events/page.dart index 1b164e4833a..0924af9d263 100644 --- a/app/lib/pages/events/page.dart +++ b/app/lib/pages/events/page.dart @@ -1,16 +1,14 @@ +import 'package:flow/blocs/sourced_paging.dart'; import 'package:flow/pages/events/event.dart'; -import 'package:flow/widgets/builder_delegate.dart'; import 'package:flow/widgets/navigation.dart'; +import 'package:flow/widgets/paging/list.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flow/src/generated/i18n/app_localizations.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:flow_api/models/event/model.dart'; -import 'package:flow_api/models/model.dart'; import '../../cubits/flow.dart'; -import '../../helpers/sourced_paging_controller.dart'; import 'filter.dart'; import 'tile.dart'; @@ -105,28 +103,29 @@ class EventsBodyView extends StatefulWidget { class _EventsBodyViewState extends State { late final FlowCubit _flowCubit; - late final SourcedPagingController _controller; + late final SourcedPagingBloc _bloc; late EventFilter _filter; @override void initState() { _flowCubit = context.read(); - _controller = SourcedPagingController(_flowCubit); - _controller.addFetchListener((source, service, offset, limit) async => - _filter.source != null && _filter.source != source - ? null - : service.event?.getEvents( - offset: offset, - limit: limit, - groupId: _filter.source == source ? _filter.group : null, - search: widget.search)); + _bloc = SourcedPagingBloc.item( + cubit: _flowCubit, + fetch: (source, service, offset, limit) async => + _filter.source != null && _filter.source != source + ? null + : service.event?.getEvents( + offset: offset, + limit: limit, + groupId: _filter.source == source ? _filter.group : null, + search: widget.search)); _filter = widget.filter; super.initState(); } @override void dispose() { - _controller.dispose(); + _bloc.close(); super.dispose(); } @@ -135,7 +134,7 @@ class _EventsBodyViewState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.search != widget.search) { - _controller.refresh(); + _bloc.refresh(); } } @@ -150,37 +149,34 @@ class _EventsBodyViewState extends State { setState(() { _filter = filter; }); - _controller.refresh(); + _bloc.refresh(); }, ), const SizedBox(height: 8), Expanded( - child: PagedListView( - pagingController: _controller, - builderDelegate: buildMaterialPagedDelegate>( - _controller, - (ctx, item, index) => Align( - alignment: Alignment.topCenter, - child: Container( - constraints: const BoxConstraints(maxWidth: 800), - child: Dismissible( - key: ValueKey('${item.model.id}@${item.source}'), - onDismissed: (direction) async { - await _flowCubit - .getService(item.source) - .event - ?.deleteEvent(item.model.id!); - _controller.itemList!.remove(item); - }, - background: Container( - color: Colors.red, - ), - child: EventTile( - flowCubit: _flowCubit, - pagingController: _controller, - source: item.source, - event: item.model, - ), + child: PagedListView.item( + bloc: _bloc, + itemBuilder: (ctx, item, index) => Align( + alignment: Alignment.topCenter, + child: Container( + constraints: const BoxConstraints(maxWidth: 800), + child: Dismissible( + key: ValueKey('${item.model.id}@${item.source}'), + onDismissed: (direction) async { + await _flowCubit + .getService(item.source) + .event + ?.deleteEvent(item.model.id!); + _bloc.remove(item); + }, + background: Container( + color: Colors.red, + ), + child: EventTile( + flowCubit: _flowCubit, + bloc: _bloc, + source: item.source, + event: item.model, ), ), ), @@ -192,7 +188,7 @@ class _EventsBodyViewState extends State { floatingActionButton: FloatingActionButton.extended( onPressed: () => showDialog( context: context, builder: (context) => const EventDialog()) - .then((_) => _controller.refresh()), + .then((_) => _bloc.refresh()), label: Text(AppLocalizations.of(context).create), icon: const PhosphorIcon(PhosphorIconsLight.plus), ), diff --git a/app/lib/pages/events/tile.dart b/app/lib/pages/events/tile.dart index ded2fca866b..651176c65ff 100644 --- a/app/lib/pages/events/tile.dart +++ b/app/lib/pages/events/tile.dart @@ -1,16 +1,13 @@ +import 'package:flow/blocs/sourced_paging.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flow/src/generated/i18n/app_localizations.dart'; import 'package:go_router/go_router.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:material_leap/material_leap.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:flow_api/models/event/model.dart'; import 'package:flow_api/models/model.dart'; import '../../cubits/flow.dart'; -import '../../helpers/sourced_paging_controller.dart'; -import '../../widgets/builder_delegate.dart'; import '../../widgets/markdown_field.dart'; import '../calendar/filter.dart'; import 'event.dart'; @@ -21,13 +18,13 @@ class EventTile extends StatelessWidget { required this.source, required this.event, required this.flowCubit, - required this.pagingController, + required this.bloc, }); final FlowCubit flowCubit; final Event event; final String source; - final SourcedPagingController pagingController; + final SourcedPagingBloc bloc; @override Widget build(BuildContext context) { @@ -87,11 +84,11 @@ class EventTile extends StatelessWidget { onPressed: () async { Navigator.of(context).pop(); await flowCubit.getService(source).event?.deleteEvent(event.id!); - pagingController.itemList!.remove(SourcedModel( + bloc.remove(SourcedModel( source, event, )); - pagingController.refresh(); + bloc.refresh(); }, child: Text( AppLocalizations.of(context).delete, @@ -109,57 +106,6 @@ class EventTile extends StatelessWidget { event: event, source: source, ), - ).then((value) => pagingController.refresh()); + ).then((value) => bloc.refresh()); } } - -Future?> showEventModalBottomSheet( - {required BuildContext context, Event? event, DateTime? time}) async { - SourcedModel? event; - final cubit = context.read(); - final pagingController = SourcedPagingController(cubit); - pagingController.addFetchListener((source, service, offset, limit) async => - service.event?.getEvents(offset: offset, limit: limit)); - final shouldCreate = await showLeapBottomSheet( - context: context, - titleBuilder: (ctx) => Text(AppLocalizations.of(context).events), - actionsBuilder: (ctx) => [ - TextButton.icon( - icon: const PhosphorIcon(PhosphorIconsLight.plusCircle), - label: Text(AppLocalizations.of(context).create), - onPressed: () { - Navigator.of(ctx).pop(true); - }, - ), - ], - childrenBuilder: (ctx) => [ - PagedListView( - shrinkWrap: true, - pagingController: pagingController, - builderDelegate: - buildMaterialPagedDelegate>( - pagingController, - (ctx, item, index) { - return ListTile( - title: Text(item.model.name), - leading: const PhosphorIcon(PhosphorIconsLight.calendar), - onTap: () { - event = item; - Navigator.of(ctx).pop(); - }, - ); - }, - )), - ]); - pagingController.dispose(); - if (shouldCreate == true && context.mounted) { - event = await showDialog( - context: context, - builder: (ctx) => EventDialog( - event: event?.model, - source: event?.source, - ), - ); - } - return event; -} diff --git a/app/lib/pages/groups/page.dart b/app/lib/pages/groups/page.dart index 641cf46cc86..a7ae52a89c2 100644 --- a/app/lib/pages/groups/page.dart +++ b/app/lib/pages/groups/page.dart @@ -1,16 +1,14 @@ +import 'package:flow/blocs/sourced_paging.dart'; import 'package:flow/pages/groups/group.dart'; -import 'package:flow/widgets/builder_delegate.dart'; import 'package:flow/widgets/navigation.dart'; +import 'package:flow/widgets/paging/list.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flow/src/generated/i18n/app_localizations.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:flow_api/models/group/model.dart'; -import 'package:flow_api/models/model.dart'; import '../../cubits/flow.dart'; -import '../../helpers/sourced_paging_controller.dart'; import 'tile.dart'; class GroupsPage extends StatefulWidget { @@ -89,20 +87,21 @@ class GroupsBodyView extends StatefulWidget { class _GroupsBodyViewState extends State { late final FlowCubit _flowCubit; - late final SourcedPagingController _controller; + late final SourcedPagingBloc _bloc; @override void initState() { _flowCubit = context.read(); - _controller = SourcedPagingController(_flowCubit); - _controller.addFetchListener((source, service, offset, limit) async => - service.group?.getGroups(offset: offset, limit: limit)); + _bloc = SourcedPagingBloc.item( + cubit: _flowCubit, + fetch: (source, service, offset, limit) async => + service.group?.getGroups(offset: offset, limit: limit)); super.initState(); } @override void dispose() { - _controller.dispose(); + _bloc.close(); super.dispose(); } @@ -111,39 +110,36 @@ class _GroupsBodyViewState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.search != widget.search) { - _controller.refresh(); + _bloc.refresh(); } } @override Widget build(BuildContext context) { return Scaffold( - body: PagedListView( - pagingController: _controller, - builderDelegate: buildMaterialPagedDelegate>( - _controller, - (ctx, item, index) => Align( - alignment: Alignment.topCenter, - child: Container( - constraints: const BoxConstraints(maxWidth: 800), - child: Dismissible( - key: ValueKey('${item.model.id}@${item.source}'), - onDismissed: (direction) async { - await _flowCubit - .getService(item.source) - .group - ?.deleteGroup(item.model.id!); - _controller.itemList!.remove(item); - }, - background: Container( - color: Colors.red, - ), - child: GroupTile( - flowCubit: _flowCubit, - pagingController: _controller, - source: item.source, - group: item.model, - ), + body: PagedListView.item( + bloc: _bloc, + itemBuilder: (ctx, item, index) => Align( + alignment: Alignment.topCenter, + child: Container( + constraints: const BoxConstraints(maxWidth: 800), + child: Dismissible( + key: ValueKey('${item.model.id}@${item.source}'), + onDismissed: (direction) async { + await _flowCubit + .getService(item.source) + .group + ?.deleteGroup(item.model.id!); + _bloc.remove(item); + }, + background: Container( + color: Colors.red, + ), + child: GroupTile( + flowCubit: _flowCubit, + bloc: _bloc, + source: item.source, + group: item.model, ), ), ), @@ -152,7 +148,7 @@ class _GroupsBodyViewState extends State { floatingActionButton: FloatingActionButton.extended( onPressed: () => showDialog( context: context, builder: (context) => const GroupDialog()) - .then((_) => _controller.refresh()), + .then((_) => _bloc.refresh()), label: Text(AppLocalizations.of(context).create), icon: const PhosphorIcon(PhosphorIconsLight.plus), ), diff --git a/app/lib/pages/groups/tile.dart b/app/lib/pages/groups/tile.dart index afca152a7c9..dede3bcfa34 100644 --- a/app/lib/pages/groups/tile.dart +++ b/app/lib/pages/groups/tile.dart @@ -1,3 +1,4 @@ +import 'package:flow/blocs/sourced_paging.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:flow/src/generated/i18n/app_localizations.dart'; @@ -7,7 +8,6 @@ import 'package:flow_api/models/group/model.dart'; import 'package:flow_api/models/model.dart'; import '../../cubits/flow.dart'; -import '../../helpers/sourced_paging_controller.dart'; import '../../widgets/markdown_field.dart'; import '../calendar/filter.dart'; import '../users/filter.dart'; @@ -19,13 +19,13 @@ class GroupTile extends StatelessWidget { required this.source, required this.group, required this.flowCubit, - required this.pagingController, + required this.bloc, }); final FlowCubit flowCubit; final Group group; final String source; - final SourcedPagingController pagingController; + final SourcedPagingBloc bloc; @override Widget build(BuildContext context) { @@ -80,11 +80,11 @@ class GroupTile extends StatelessWidget { onPressed: () async { Navigator.of(context).pop(); await flowCubit.getService(source).group?.deleteGroup(group.id!); - pagingController.itemList!.remove(SourcedModel( + bloc.remove(SourcedModel( source, group, )); - pagingController.refresh(); + bloc.refresh(); }, child: Text( AppLocalizations.of(context).delete, @@ -122,6 +122,6 @@ class GroupTile extends StatelessWidget { group: group, source: source, ), - ).then((value) => pagingController.refresh()); + ).then((value) => bloc.refresh()); } } diff --git a/app/lib/pages/groups/view.dart b/app/lib/pages/groups/view.dart index 3f6d9e89673..c3c63d55926 100644 --- a/app/lib/pages/groups/view.dart +++ b/app/lib/pages/groups/view.dart @@ -1,15 +1,15 @@ +import 'package:flow/blocs/sourced_paging.dart'; +import 'package:flow/widgets/paging/list.dart'; import 'package:flow_api/services/source.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flow/src/generated/i18n/app_localizations.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'package:phosphor_flutter/phosphor_flutter.dart'; import 'package:flow_api/models/model.dart'; import 'package:flow_api/models/group/model.dart'; import 'package:flow_api/models/group/service.dart'; import '../../cubits/flow.dart'; -import '../../widgets/builder_delegate.dart'; import 'group.dart'; class GroupsView extends StatefulWidget { @@ -35,77 +35,55 @@ class GroupsView extends StatefulWidget { class _GroupsViewState extends State> { - static const _pageSize = 20; - late final GroupService? _groupService; - final PagingController _pagingController = - PagingController(firstPageKey: 0); + late final SourcedPagingBloc _bloc; @override void initState() { - final service = context.read().getService(widget.source); + final cubit = context.read(); + final service = cubit.getService(widget.source); _groupService = service.group; - _pagingController.addPageRequestListener((pageKey) { - _fetchPage(pageKey); - }); + _bloc = SourcedPagingBloc.source( + cubit: cubit, + source: widget.source, + fetch: (service, offset, limit) => widget.connector + .getItems(widget.model.id!, offset: offset, limit: limit), + ); super.initState(); } - Future _fetchPage(int pageKey) async { - try { - final newItems = await widget.connector.getItems(widget.model.id!, - offset: pageKey * _pageSize, limit: _pageSize); - final isLastPage = newItems.length < _pageSize; - if (isLastPage) { - _pagingController.appendLastPage(newItems); - } else { - final nextPageKey = pageKey + 1; - _pagingController.appendPage(newItems, nextPageKey); - } - } catch (error) { - _pagingController.error = error; - } - } - @override - Widget build(BuildContext context) => - // Don't worry about displaying progress or error indicators on screen; the - // package takes care of that. If you want to customize them, use the - // [PagedChildBuilderDelegate] properties. - Stack( + Widget build(BuildContext context) => Stack( children: [ Column( children: [ Flexible( - child: PagedListView( - pagingController: _pagingController, - builderDelegate: buildMaterialPagedDelegate( - _pagingController, - (context, item, index) { - return Dismissible( - key: ValueKey(item.id), - background: Container(color: Colors.red), - onDismissed: (direction) { - _groupService?.deleteGroup(item.id!); - _pagingController.itemList!.remove(item); + child: PagedListView.source( + bloc: _bloc, + itemBuilder: (context, item, index) { + return Dismissible( + key: ValueKey(item.id), + background: Container(color: Colors.red), + onDismissed: (direction) { + _groupService?.deleteGroup(item.id!); + _bloc.removeSourced(item); + }, + child: ListTile( + title: Text(item.name), + onTap: () async { + await showDialog>( + context: context, + builder: (context) => GroupDialog( + source: widget.source, + group: item, + ), + ); + _bloc.refresh(); }, - child: ListTile( - title: Text(item.name), - onTap: () async { - await showDialog>( - context: context, - builder: (context) => GroupDialog( - source: widget.source, - group: item, - ), - ); - _pagingController.refresh(); - }, - ), - ); - }, - ), + ), + ); + }, ), ), const SizedBox(height: 64), @@ -129,7 +107,7 @@ class _GroupsViewState await widget.connector .connect(widget.model.id!, group.model.id!); } - _pagingController.refresh(); + _bloc.refresh(); }, ), ), @@ -139,7 +117,7 @@ class _GroupsViewState @override void dispose() { - _pagingController.dispose(); + _bloc.close(); super.dispose(); } } diff --git a/app/lib/pages/notes/details.dart b/app/lib/pages/notes/details.dart index 9ecd86b72bf..240251df660 100644 --- a/app/lib/pages/notes/details.dart +++ b/app/lib/pages/notes/details.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'package:collection/collection.dart'; -import 'package:flow/helpers/sourced_paging_controller.dart'; +import 'package:flow/blocs/sourced_paging.dart'; import 'package:flow/pages/notes/select.dart'; import 'package:flow/widgets/markdown_field.dart'; import 'package:flutter/material.dart'; @@ -22,13 +22,13 @@ import 'label.dart'; class NoteDetailsView extends StatefulWidget { final String source; final Note note; - final SourcedPagingController controller; + final SourcedPagingBloc bloc; const NoteDetailsView({ super.key, required this.source, required this.note, - required this.controller, + required this.bloc, }); @override @@ -202,7 +202,7 @@ class _NoteDetailsViewState extends State { AppLocalizations.of(context).delete, () async { await _noteService?.deleteNote(_newNote.id!); - widget.controller.refresh(); + widget.bloc.refresh(); } ) ] diff --git a/app/lib/pages/notes/list.dart b/app/lib/pages/notes/list.dart index 61affef7322..8a8b39a5ef8 100644 --- a/app/lib/pages/notes/list.dart +++ b/app/lib/pages/notes/list.dart @@ -1,24 +1,21 @@ part of 'navigator/drawer.dart'; class NotesListView extends StatelessWidget { - final SourcedPagingController controller; + final SourcedPagingBloc bloc; const NotesListView({ super.key, - required this.controller, + required this.bloc, }); @override Widget build(BuildContext context) { - return PagedListView( - pagingController: controller, - builderDelegate: buildMaterialPagedDelegate>( - controller, - (ctx, item, index) => NoteListTile( - note: item.model, - source: item.source, - controller: controller, - ), + return PagedListView.item( + bloc: bloc, + itemBuilder: (ctx, item, index) => NoteListTile( + note: item.model, + source: item.source, + bloc: bloc, ), ); } diff --git a/app/lib/pages/notes/navigator/drawer.dart b/app/lib/pages/notes/navigator/drawer.dart index 80fc325c4bf..ec1fa2fa736 100644 --- a/app/lib/pages/notes/navigator/drawer.dart +++ b/app/lib/pages/notes/navigator/drawer.dart @@ -1,10 +1,10 @@ +import 'package:flow/blocs/sourced_paging.dart'; import 'package:flow/cubits/flow.dart'; -import 'package:flow/helpers/sourced_paging_controller.dart'; import 'package:flow/pages/notes/tile.dart'; import 'package:flow/pages/notes/filter.dart'; import 'package:flow/pages/notes/label.dart'; import 'package:flow/pages/notes/notebook.dart'; -import 'package:flow/widgets/builder_delegate.dart'; +import 'package:flow/widgets/paging/list.dart'; import 'package:flow/widgets/select.dart'; import 'package:flow_api/models/label/model.dart'; import 'package:flow_api/models/model.dart'; @@ -13,7 +13,6 @@ import 'package:flow_api/services/database.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flow/src/generated/i18n/app_localizations.dart'; -import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import 'dart:typed_data'; import 'package:material_leap/material_leap.dart'; import 'package:material_leap/widgets.dart'; @@ -28,14 +27,14 @@ class NotesNavigatorDrawer extends StatelessWidget { final ValueChanged? onFilterChanged; final NoteFilter filter; final bool isSearching; - final SourcedPagingController controller; + final SourcedPagingBloc bloc; const NotesNavigatorDrawer({ super.key, this.note, this.onFilterChanged, required this.filter, - required this.controller, + required this.bloc, required this.isSearching, }); @@ -75,7 +74,7 @@ class NotesNavigatorDrawer extends StatelessWidget { if (note != null && !isSearching) Expanded( child: NotesListView( - controller: controller, + bloc: bloc, ), ), ], diff --git a/app/lib/pages/notes/navigator/labels.dart b/app/lib/pages/notes/navigator/labels.dart index 4ba03e5c370..58a55ff755b 100644 --- a/app/lib/pages/notes/navigator/labels.dart +++ b/app/lib/pages/notes/navigator/labels.dart @@ -11,23 +11,24 @@ class _NoteLabelsView extends StatefulWidget { } class _NoteLabelsViewState extends State<_NoteLabelsView> { - late final SourcedPagingController