From 1d06edaf5f1fc9f2a074feb6addb52223350f0a6 Mon Sep 17 00:00:00 2001 From: realth000 Date: Thu, 10 Oct 2024 07:34:52 +0800 Subject: [PATCH] refactor(notification): separate notification types --- .../notification/bloc/notification_bloc.dart | 157 ++++++++++-------- .../notification/bloc/notification_event.dart | 18 +- .../notification/bloc/notification_state.dart | 80 ++++++--- .../repository/notification_repository.dart | 88 +++++++--- .../notification/view/notification_page.dart | 132 +++++++++------ 5 files changed, 306 insertions(+), 169 deletions(-) diff --git a/lib/features/notification/bloc/notification_bloc.dart b/lib/features/notification/bloc/notification_bloc.dart index df35d0e9..7d139d6e 100644 --- a/lib/features/notification/bloc/notification_bloc.dart +++ b/lib/features/notification/bloc/notification_bloc.dart @@ -9,84 +9,107 @@ part 'notification_event.dart'; part 'notification_state.dart'; /// Emitter -typedef _Emit = Emitter; +typedef _Emit> = Emitter; /// Bloc of notification. -class NotificationBloc extends Bloc - with LoggerMixin { +class NotificationBaseBloc> + extends Bloc with LoggerMixin { /// Constructor. - NotificationBloc({required NotificationRepository notificationRepository}) - : _notificationRepository = notificationRepository, - super(const NotificationState()) { - on(_onNotificationRefreshNoticeRequired); - on( - _onNotificationRefreshPersonalMessageRequired, - ); - on( - _onNotificationRefreshBroadcastMessageRequired, + NotificationBaseBloc({ + required NotificationRepository notificationRepository, + required T initialState, + }) : _notificationRepository = notificationRepository, + super(initialState) { + on( + (event, emit) => switch (event) { + NotificationRefreshRequested() => _onRefreshRequested(emit), + NotificationLoadMoreRequested() => _onLoadRequested(emit), + }, ); } final NotificationRepository _notificationRepository; - Future _onNotificationRefreshNoticeRequired( - NotificationRefreshNoticeRequired event, - _Emit emit, - ) async { - emit(state.copyWith(noticeStatus: NotificationStatus.loading)); - await _notificationRepository.fetchNotice().match((e) { - handle(e); - error('failed to fetch notice: $e'); - emit(state.copyWith(noticeStatus: NotificationStatus.failed)); - }, (v) { - final noticeList = v; - emit( - state.copyWith( - noticeStatus: NotificationStatus.success, - noticeList: noticeList, - ), - ); - }).run(); + Future _onRefreshRequested(_Emit emit) async { + emit(state.copyWith(status: NotificationStatus.loading) as T); + switch (T) { + case NoticeState: + await _notificationRepository.fetchNotice().match((e) { + handle(e); + error('failed to fetch notice: $e'); + emit(state.copyWith(status: NotificationStatus.failure) as T); + }, (v) { + final noticeList = v.notificationList; + emit( + state.copyWith( + status: NotificationStatus.success, + noticeList: noticeList as List, + ) as T, + ); + }).run(); + case PersonalMessageState: + await _notificationRepository.fetchPersonalMessage().match((e) { + handle(e); + error('failed to fetch notice: $e'); + emit(state.copyWith(status: NotificationStatus.failure) as T); + }, (v) { + final noticeList = v; + emit( + state.copyWith( + status: NotificationStatus.success, + noticeList: noticeList as List, + ) as T, + ); + }).run(); + case BroadcastMessageState: + await _notificationRepository.fetchBroadMessage().match((e) { + handle(e); + error('failed to fetch notice: $e'); + emit(state.copyWith(status: NotificationStatus.failure) as T); + }, (v) { + final noticeList = v; + emit( + state.copyWith( + status: NotificationStatus.success, + noticeList: noticeList as List, + ) as T, + ); + }).run(); + case Type(): + throw UnimplementedError('notification type not implemented: $T'); + } } - Future _onNotificationRefreshPersonalMessageRequired( - NotificationRefreshPersonalMessageRequired event, - _Emit emit, - ) async { - emit(state.copyWith(personalMessageStatus: NotificationStatus.loading)); - await _notificationRepository.fetchPersonalMessage().match((e) { - handle(e); - error('failed to fetch private messages: $e'); - emit(state.copyWith(personalMessageStatus: NotificationStatus.failed)); - }, (v) { - final privateMessageList = v; - emit( - state.copyWith( - personalMessageStatus: NotificationStatus.success, - personalMessageList: privateMessageList, - ), - ); - }).run(); + Future _onLoadRequested(_Emit emit) async { + throw UnimplementedError('implement load pages'); } +} - Future _onNotificationRefreshBroadcastMessageRequired( - NotificationRefreshBroadcastMessageRequired event, - _Emit emit, - ) async { - emit(state.copyWith(broadcastMessageStatus: NotificationStatus.loading)); - await _notificationRepository.fetchBroadMessage().match((e) { - handle(e); - error('failed to fetch broad messages: $e'); - emit(state.copyWith(broadcastMessageStatus: NotificationStatus.failed)); - }, (v) { - final broadcastMessageList = v; +/// Bloc of notice type notification. +final class NoticeBloc extends NotificationBaseBloc { + /// Constructor. + NoticeBloc({required super.notificationRepository}) + : super( + initialState: const NoticeState(), + ); +} - emit( - state.copyWith( - broadcastMessageStatus: NotificationStatus.success, - broadcastMessageList: broadcastMessageList, - ), - ); - }).run(); - } +/// Bloc of personal message type notification. +final class PersonalMessageBloc + extends NotificationBaseBloc { + /// Constructor. + PersonalMessageBloc({required super.notificationRepository}) + : super( + initialState: const PersonalMessageState(), + ); +} + +/// Bloc of broadcast message type notification. +final class BroadcastMessageBloc + extends NotificationBaseBloc { + /// Constructor. + BroadcastMessageBloc({required super.notificationRepository}) + : super( + initialState: const BroadcastMessageState(), + ); } diff --git a/lib/features/notification/bloc/notification_event.dart b/lib/features/notification/bloc/notification_event.dart index 4d088946..6ed543a1 100644 --- a/lib/features/notification/bloc/notification_event.dart +++ b/lib/features/notification/bloc/notification_event.dart @@ -6,18 +6,12 @@ sealed class NotificationEvent with NotificationEventMappable { const NotificationEvent(); } -/// User required to refresh the notification page. +/// Requested to refresh notification. @MappableClass() -final class NotificationRefreshNoticeRequired extends NotificationEvent - with NotificationRefreshNoticeRequiredMappable {} +final class NotificationRefreshRequested extends NotificationEvent + with NotificationRefreshRequestedMappable {} -/// User required to refresh the private personal message tab. +/// Requested to load more notification from the next page. @MappableClass() -final class NotificationRefreshPersonalMessageRequired extends NotificationEvent - with NotificationRefreshPersonalMessageRequiredMappable {} - -/// User required to refresh the broadcast message tab. -@MappableClass() -final class NotificationRefreshBroadcastMessageRequired - extends NotificationEvent - with NotificationRefreshBroadcastMessageRequiredMappable {} +final class NotificationLoadMoreRequested extends NotificationEvent + with NotificationLoadMoreRequestedMappable {} diff --git a/lib/features/notification/bloc/notification_state.dart b/lib/features/notification/bloc/notification_state.dart index 4440fe69..54cf3279 100644 --- a/lib/features/notification/bloc/notification_state.dart +++ b/lib/features/notification/bloc/notification_state.dart @@ -12,37 +12,75 @@ enum NotificationStatus { success, /// Failed. - failed, + failure, } -/// State of notification. +/// Basic notification. +/// +/// Common members of notification where T can be: +/// +/// * [Notice] +/// * [PersonalMessage]. +/// * [BroadcastMessage]. @MappableClass() -class NotificationState with NotificationStateMappable { +sealed class NotificationBaseState with NotificationBaseStateMappable { /// Constructor. - const NotificationState({ - this.noticeStatus = NotificationStatus.initial, - this.personalMessageStatus = NotificationStatus.initial, - this.broadcastMessageStatus = NotificationStatus.initial, + const NotificationBaseState({ + this.status = NotificationStatus.initial, + this.pageNumber = 1, + this.hasNextPage = false, this.noticeList = const [], - this.personalMessageList = const [], - this.broadcastMessageList = const [], }); - /// Notice tab status. - final NotificationStatus noticeStatus; + /// Status. + final NotificationStatus status; - /// Personal message status. - final NotificationStatus personalMessageStatus; + /// Current loaded page number. + final int pageNumber; - /// Broadcast message status. - final NotificationStatus broadcastMessageStatus; + /// Whether has next page to load more notification. + final bool hasNextPage; - /// All fetched [Notice]. - final List noticeList; + /// All fetched notice, + final List noticeList; +} + +/// State of notice tab. +@MappableClass() +final class NoticeState extends NotificationBaseState + with NoticeStateMappable { + /// Constructor. + const NoticeState({ + super.status, + super.pageNumber, + super.hasNextPage, + super.noticeList, + }) : super(); +} - /// All fetched [PersonalMessage]. - final List personalMessageList; +/// State of personal message tab. +@MappableClass() +final class PersonalMessageState extends NotificationBaseState + with PersonalMessageStateMappable { + /// Constructor. + const PersonalMessageState({ + super.status, + super.pageNumber, + super.hasNextPage, + super.noticeList, + }) : super(); +} - /// All fetched [BroadcastMessage]. - final List broadcastMessageList; +/// State of personal message tab. +@MappableClass() +final class BroadcastMessageState + extends NotificationBaseState + with BroadcastMessageStateMappable { + /// Constructor. + const BroadcastMessageState({ + super.status, + super.pageNumber, + super.hasNextPage, + super.noticeList, + }) : super(); } diff --git a/lib/features/notification/repository/notification_repository.dart b/lib/features/notification/repository/notification_repository.dart index 6c97ebc8..46f37c02 100644 --- a/lib/features/notification/repository/notification_repository.dart +++ b/lib/features/notification/repository/notification_repository.dart @@ -4,6 +4,7 @@ import 'package:fpdart/fpdart.dart'; import 'package:tsdm_client/constants/url.dart'; import 'package:tsdm_client/exceptions/exceptions.dart'; import 'package:tsdm_client/extensions/fp.dart'; +import 'package:tsdm_client/extensions/universal_html.dart'; import 'package:tsdm_client/features/notification/models/models.dart'; import 'package:tsdm_client/instance.dart'; import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; @@ -11,21 +12,51 @@ import 'package:tsdm_client/utils/logger.dart'; import 'package:universal_html/html.dart' as uh; import 'package:universal_html/parsing.dart'; +/// Result of loading progress of a notification type page. +class NoticeResult { + /// Constructor. + const NoticeResult({ + required this.notificationList, + required this.pageNumber, + required this.hasNextPage, + this.statusCode, + }); + + /// Empty result + factory NoticeResult.empty() => NoticeResult( + notificationList: [], + pageNumber: 1, + hasNextPage: false, + ); + + /// All notice. + final List notificationList; + + /// Current loaded page. + final int pageNumber; + + /// Flag indicating has next page or not + final bool hasNextPage; + + /// Some status code. + final int? statusCode; +} + /// Repository of notification. final class NotificationRepository with LoggerMixin { /// Get and parse a list of [Notice] from the given [url]. /// - /// * Return (List, null) if success. - /// * Return (null, resp.StatusCode) if http request failed. - /// * Return ([], null) if success but no notice found. - AsyncEither<(List?, int?)> _fetchNotice( + /// * Return Notice> if success. + /// * Return resp.StatusCode if http request failed. + AsyncEither>> _fetchNotice( NetClientProvider netClient, String url, ) => AsyncEither( - () async => netClient.get(url).map((value) { + () async => + netClient.get(url).map>>((value) { if (value.statusCode != HttpStatus.ok) { - return (null, value.statusCode); + return Left(value.statusCode); } final document = parseHtmlDocument(value.data as String); @@ -36,7 +67,7 @@ final class NotificationRepository with LoggerMixin { if (emptyNode != null) { error('empty notice'); // No notice here. - return ([], null); + return Right(NoticeResult.empty()); } final noticeList = document @@ -46,12 +77,22 @@ final class NotificationRepository with LoggerMixin { .map(Notice.fromClNode) .whereType() .toList(); - return (noticeList, null); + + final pageNumber = document.currentPage() ?? 1; + final hasNextPage = (document.totalPages() ?? -1) > pageNumber; + + return Right( + NoticeResult( + notificationList: noticeList, + pageNumber: pageNumber, + hasNextPage: hasNextPage, + ), + ); }).run(), ); /// Fetch notice from web server, including unread notices and read notices. - AsyncEither> fetchNotice() => AsyncEither(() async { + AsyncEither> fetchNotice() => AsyncEither(() async { final netClient = getIt.get(); final data = await Future.wait([ @@ -69,22 +110,31 @@ final class NotificationRepository with LoggerMixin { } final d1d = d1.unwrap(); final d2d = d2.unwrap(); - if (d1d.$2 != null) { - return left(HttpRequestFailedException(d1d.$2)); + if (d1d.isLeft()) { + return Left(HttpRequestFailedException(d1d.unwrapErr())); } - if (d2d.$2 != null) { - return left(HttpRequestFailedException(d2d.$2)); + if (d2d.isLeft()) { + return Left(HttpRequestFailedException(d2d.unwrapErr())); } + final d2dd = d2d.unwrap(); + // Filter duplicate notices. // Only filter on reply type notices for now. - final d3 = d1d.$1!.where( - (x) => - x.redirectUrl == null || - !d2d.$1!.any((y) => y.redirectUrl == x.redirectUrl), - ); + final d3 = d1d.unwrap().notificationList.where( + (x) => + x.redirectUrl == null || + d2dd.notificationList + .any((y) => y.redirectUrl == x.redirectUrl), + ); - return right([...d3, ...?d2d.$1]); + return right( + NoticeResult( + notificationList: [...d3, ...d2dd.notificationList], + pageNumber: d2dd.pageNumber, + hasNextPage: d2dd.hasNextPage, + ), + ); }); /// Fetch the html document of notice detail page. diff --git a/lib/features/notification/view/notification_page.dart b/lib/features/notification/view/notification_page.dart index 38f8b23a..29243d80 100644 --- a/lib/features/notification/view/notification_page.dart +++ b/lib/features/notification/view/notification_page.dart @@ -52,13 +52,12 @@ class _NotificationPageState extends State ); } - Widget _buildNoticeTab(BuildContext context, NotificationState state) { - return switch (state.noticeStatus) { + Widget _buildNoticeTab(BuildContext context, NoticeState state) { + return switch (state.status) { NotificationStatus.initial || NotificationStatus.loading => const Center(child: CircularProgressIndicator()), - NotificationStatus.success => - (BuildContext context, NotificationState state) { + NotificationStatus.success => (BuildContext context, NoticeState state) { final Widget content; if (state.noticeList.isEmpty) { content = _buildEmptyBody(context); @@ -82,45 +81,47 @@ class _NotificationPageState extends State }, header: const MaterialHeader(), controller: _noticeRefreshController, + onLoad: () async { + if (!mounted) { + return; + } + context.read().add(NotificationLoadMoreRequested()); + }, onRefresh: () async { if (!mounted) { return; } - context - .read() - .add(NotificationRefreshNoticeRequired()); + context.read().add(NotificationRefreshRequested()); }, child: content, ); }(context, state), - NotificationStatus.failed => buildRetryButton( + NotificationStatus.failure => buildRetryButton( context, - () => context - .read() - .add(NotificationRefreshNoticeRequired()), + () => context.read().add(NotificationRefreshRequested()), ), }; } - Widget _buildPrivateMessageTab( + Widget _buildPersonalMessageTab( BuildContext context, - NotificationState state, + PersonalMessageState state, ) { - return switch (state.personalMessageStatus) { + return switch (state.status) { NotificationStatus.initial || NotificationStatus.loading => const Center(child: CircularProgressIndicator()), NotificationStatus.success => - (BuildContext context, NotificationState state) { + (BuildContext context, PersonalMessageState state) { final Widget content; - if (state.personalMessageList.isEmpty) { + if (state.noticeList.isEmpty) { content = _buildEmptyBody(context); } else { content = ListView.separated( padding: edgeInsetsL12T4R12B24, - itemCount: state.personalMessageList.length, + itemCount: state.noticeList.length, itemBuilder: (context, index) => - PrivateMessageCard(message: state.personalMessageList[index]), + PrivateMessageCard(message: state.noticeList[index]), separatorBuilder: (context, index) => sizedBoxW4H4, ); } @@ -139,41 +140,41 @@ class _NotificationPageState extends State return; } context - .read() - .add(NotificationRefreshPersonalMessageRequired()); + .read() + .add(NotificationRefreshRequested()); }, child: content, ); }(context, state), - NotificationStatus.failed => buildRetryButton( + NotificationStatus.failure => buildRetryButton( context, () => context - .read() - .add(NotificationRefreshPersonalMessageRequired()), + .read() + .add(NotificationRefreshRequested()), ), }; } Widget _buildBroadcastMessageTab( BuildContext context, - NotificationState state, + BroadcastMessageState state, ) { - return switch (state.broadcastMessageStatus) { + return switch (state.status) { NotificationStatus.initial || NotificationStatus.loading => const Center(child: CircularProgressIndicator()), NotificationStatus.success => - (BuildContext context, NotificationState state) { + (BuildContext context, BroadcastMessageState state) { final Widget content; - if (state.broadcastMessageList.isEmpty) { + if (state.noticeList.isEmpty) { content = _buildEmptyBody(context); } else { content = ListView.separated( padding: edgeInsetsL12T4R12B24, - itemCount: state.broadcastMessageList.length, + itemCount: state.noticeList.length, itemBuilder: (context, index) { return BroadcastMessageCard( - message: state.broadcastMessageList[index], + message: state.noticeList[index], ); }, separatorBuilder: (context, index) => sizedBoxW4H4, @@ -194,17 +195,17 @@ class _NotificationPageState extends State return; } context - .read() - .add(NotificationRefreshBroadcastMessageRequired()); + .read() + .add(NotificationRefreshRequested()); }, child: content, ); }(context, state), - NotificationStatus.failed => buildRetryButton( + NotificationStatus.failure => buildRetryButton( context, () => context - .read() - .add(NotificationRefreshBroadcastMessageRequired()), + .read() + .add(NotificationRefreshRequested()), ), }; } @@ -245,22 +246,53 @@ class _NotificationPageState extends State create: (_) => NotificationRepository(), ), BlocProvider( - create: (context) => NotificationBloc( + create: (context) => NoticeBloc( + notificationRepository: RepositoryProvider.of(context), + )..add(NotificationRefreshRequested()), + ), + BlocProvider( + create: (context) => PersonalMessageBloc( notificationRepository: RepositoryProvider.of(context), - ) - ..add(NotificationRefreshNoticeRequired()) - ..add(NotificationRefreshPersonalMessageRequired()) - ..add(NotificationRefreshBroadcastMessageRequired()), + )..add(NotificationRefreshRequested()), + ), + BlocProvider( + create: (context) => BroadcastMessageBloc( + notificationRepository: RepositoryProvider.of(context), + )..add(NotificationRefreshRequested()), ), ], - child: BlocListener( - listener: (context, state) { - if (state.noticeStatus == NotificationStatus.failed) { - showFailedToLoadSnackBar(context); - } - }, - child: BlocBuilder( - builder: (context, state) { + child: MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + if (state.status == NotificationStatus.failure) { + showFailedToLoadSnackBar(context); + } + }, + ), + BlocListener( + listener: (context, state) { + if (state.status == NotificationStatus.failure) { + showFailedToLoadSnackBar(context); + } + }, + ), + BlocListener( + listener: (context, state) { + if (state.status == NotificationStatus.failure) { + showFailedToLoadSnackBar(context); + } + }, + ), + ], + child: Builder( + builder: (context) { + final noticeState = context.watch().state; + final personalMessageState = + context.watch().state; + final broadcastMessageState = + context.watch().state; + return Scaffold( appBar: AppBar( title: Text(tr.title), @@ -276,9 +308,9 @@ class _NotificationPageState extends State body: TabBarView( controller: _tabController, children: [ - _buildNoticeTab(context, state), - _buildPrivateMessageTab(context, state), - _buildBroadcastMessageTab(context, state), + _buildNoticeTab(context, noticeState), + _buildPersonalMessageTab(context, personalMessageState), + _buildBroadcastMessageTab(context, broadcastMessageState), ], ), );