From 0367d1a653c942f9305f87e33b4934632f276a08 Mon Sep 17 00:00:00 2001 From: realth000 Date: Tue, 6 Feb 2024 01:06:09 +0800 Subject: [PATCH] feat(points): Init user points statistics page * User points statistics page need further optimization. * User points changelog page is unimplemented. --- lib/extensions/universal_html.dart | 2 +- lib/features/points/bloc/points_bloc.dart | 87 +++++++++++ lib/features/points/bloc/points_event.dart | 34 +++++ lib/features/points/bloc/points_state.dart | 119 +++++++++++++++ lib/features/points/models/points_change.dart | 130 ++++++++++++++++ .../repository/model/changelog_parameter.dart | 63 ++++++++ .../points/repository/points_repository.dart | 52 +++++++ lib/features/points/views/points_page.dart | 144 ++++++++++++++++++ lib/features/profile/view/profile_page.dart | 6 + lib/routes/app_routes.dart | 6 + lib/routes/screen_paths.dart | 6 + 11 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 lib/features/points/bloc/points_bloc.dart create mode 100644 lib/features/points/bloc/points_event.dart create mode 100644 lib/features/points/bloc/points_state.dart create mode 100644 lib/features/points/models/points_change.dart create mode 100644 lib/features/points/repository/model/changelog_parameter.dart create mode 100644 lib/features/points/repository/points_repository.dart create mode 100644 lib/features/points/views/points_page.dart diff --git a/lib/extensions/universal_html.dart b/lib/extensions/universal_html.dart index 9fc2bb70..21645e6d 100644 --- a/lib/extensions/universal_html.dart +++ b/lib/extensions/universal_html.dart @@ -177,7 +177,7 @@ extension GrepExtension on Element { if (children.length > 1) { // The value here is not a plain text, it's a normal node. // TODO: If we have a normal node value, parse it's link, img even styles. - value = childAtOrNull(1)?.firstEndDeepText(); + value = nodes.elementAtOrNull(1)?.text; } else { value = nodes.lastOrNull?.text?.trim(); } diff --git a/lib/features/points/bloc/points_bloc.dart b/lib/features/points/bloc/points_bloc.dart new file mode 100644 index 00000000..7d41e22d --- /dev/null +++ b/lib/features/points/bloc/points_bloc.dart @@ -0,0 +1,87 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/extensions/universal_html.dart'; +import 'package:tsdm_client/features/points/models/points_change.dart'; +import 'package:tsdm_client/features/points/repository/points_repository.dart'; +import 'package:tsdm_client/utils/debug.dart'; +import 'package:universal_html/html.dart' as uh; + +part 'points_event.dart'; +part 'points_state.dart'; + +/// Statistics emitter. +typedef PointsStatisticsEmitter = Emitter; + +/// Bloc of user points statistics page. +final class PointsStatisticsBloc + extends Bloc { + /// Constructor. + PointsStatisticsBloc({required PointsRepository pointsRepository}) + : _pointsRepository = pointsRepository, + super(const PointsStatisticsState()) { + on(_onPointsStatisticsRefreshRequired); + } + + final PointsRepository _pointsRepository; + + Future _onPointsStatisticsRefreshRequired( + PointsStatisticsRefreshRequired event, + PointsStatisticsEmitter emit, + ) async { + emit(state.copyWith(status: PointsStatus.loading)); + try { + final document = await _pointsRepository.fetchStatisticsPage(); + final result = _parseDocument(document); + if (result == null) { + emit(state.copyWith(status: PointsStatus.failed)); + return; + } + emit( + state.copyWith( + status: PointsStatus.success, + pointsMap: result.$1, + pointsRecentChangelog: result.$2, + ), + ); + } on HttpRequestFailedException catch (e) { + debug('failed to fetch points statistics page: $e'); + emit(state.copyWith(status: PointsStatus.failed)); + } + } + + (Map, List)? _parseDocument( + uh.Document document, + ) { + final rootNode = document.querySelector('div#ct_shell div.bm.bw0'); + if (rootNode == null) { + debug('points change root node not found'); + return null; + } + final pointsMapEntries = rootNode + .querySelectorAll('ul.creditl > li') + .map((e) => e.parseLiEmNode()) + .whereType<(String, String)>() + .map((e) => MapEntry(e.$1, e.$2)); + final pointsMap = Map.fromEntries(pointsMapEntries); + + final tableNode = rootNode.querySelector('table.dt'); + if (tableNode == null) { + debug('points change table not found'); + return null; + } + final pointsChangeList = _buildChangeListFromTable(tableNode); + return (pointsMap, pointsChangeList); + } + + /// Build a list of [PointsChange] from + List _buildChangeListFromTable(uh.Element element) { + final ret = element + .querySelectorAll('table > tbody > tr') + .skip(1) + .map(PointsChange.fromTrNode) + .whereType() + .toList(); + return ret; + } +} diff --git a/lib/features/points/bloc/points_event.dart b/lib/features/points/bloc/points_event.dart new file mode 100644 index 00000000..9b9bfeed --- /dev/null +++ b/lib/features/points/bloc/points_event.dart @@ -0,0 +1,34 @@ +part of 'points_bloc.dart'; + +/// Event of points statistics page. +sealed class PointsStatisticsEvent extends Equatable { + /// Constructor. + const PointsStatisticsEvent(); + + @override + List get props => []; +} + +/// User required to refresh the points statistics page. +final class PointsStatisticsRefreshRequired extends PointsStatisticsEvent {} + +/// Event of points changelog page. +sealed class PointsChangelogEvent extends Equatable { + /// Constructor. + const PointsChangelogEvent(); + + @override + List get props => []; +} + +/// User required to refresh the points changelog page. +final class PointsChangelogRefreshRequired extends PointsChangelogEvent {} + +/// User required to load more page in points changelog page. +final class PointsChangelogLoadMoreRequired extends PointsChangelogEvent { + /// Constructor. + const PointsChangelogLoadMoreRequired(this.pageNumber); + + /// Page number to fetch data from. + final String pageNumber; +} diff --git a/lib/features/points/bloc/points_state.dart b/lib/features/points/bloc/points_state.dart new file mode 100644 index 00000000..5304e1cb --- /dev/null +++ b/lib/features/points/bloc/points_state.dart @@ -0,0 +1,119 @@ +part of 'points_bloc.dart'; + +/// State of current user points. +enum PointsStatus { + /// Initial. + initial, + + /// Loading data. + loading, + + /// Load succeed. + success, + + /// Load failed. + failed, +} + +/// State of user points statistics page. +/// +/// This page has no pagination and the length of points changelog is expected +/// to no more than 10. +final class PointsStatisticsState extends Equatable { + /// Constructor. + const PointsStatisticsState({ + this.status = PointsStatus.initial, + this.pointsMap = const {}, + this.pointsRecentChangelog = const [], + }); + + /// Status. + final PointsStatus status; + + /// Map of user's different attributes points. + /// + /// With `name` and `value`. + final Map pointsMap; + + /// A list of changes event on user's points. + /// + /// But this field only contains recent changes that shows in user's points + /// page. + /// + /// The length is expected to be no more than 10. + /// + /// For full changelog, see [PointsChangelogState.pointsFullChangelog]. + final List pointsRecentChangelog; + + /// Copy with + PointsStatisticsState copyWith({ + PointsStatus? status, + Map? pointsMap, + List? pointsRecentChangelog, + }) { + return PointsStatisticsState( + status: status ?? this.status, + pointsMap: pointsMap ?? this.pointsMap, + pointsRecentChangelog: + pointsRecentChangelog ?? this.pointsRecentChangelog, + ); + } + + @override + List get props => [ + status, + pointsMap, + pointsRecentChangelog, + ]; +} + +/// State of points changelog page. +final class PointsChangelogState extends Equatable { + /// Constructor. + const PointsChangelogState({ + this.status = PointsStatus.initial, + this.pointsFullChangelog = const [], + this.pointsLogPageCurrentNumber = 1, + this.pointsLogPageTotalNumber = 1, + }); + + /// Status. + final PointsStatus status; + + /// A list of changes event on user's points. + /// + /// This field contains all queried changes on user's points, may contains + /// a long period. + /// + /// For recent changes, see [PointsStatisticsState.pointsRecentChangelog]. + final List pointsFullChangelog; + + /// Current page number of user's points changelog page. + final int pointsLogPageCurrentNumber; + + /// Total pages count of user's points changelog page. + final int pointsLogPageTotalNumber; + + /// Copy with. + PointsChangelogState copyWith({ + List? pointsFullChangelog, + int? pointsLogPageCurrentNumber, + int? pointsLogPageTotalNumber, + }) { + return PointsChangelogState( + pointsFullChangelog: pointsFullChangelog ?? this.pointsFullChangelog, + pointsLogPageCurrentNumber: + pointsLogPageCurrentNumber ?? this.pointsLogPageCurrentNumber, + pointsLogPageTotalNumber: + pointsLogPageTotalNumber ?? this.pointsLogPageTotalNumber, + ); + } + + @override + List get props => [ + status, + pointsFullChangelog, + pointsLogPageCurrentNumber, + pointsLogPageTotalNumber, + ]; +} diff --git a/lib/features/points/models/points_change.dart b/lib/features/points/models/points_change.dart new file mode 100644 index 00000000..9ea5e340 --- /dev/null +++ b/lib/features/points/models/points_change.dart @@ -0,0 +1,130 @@ +import 'package:equatable/equatable.dart'; +import 'package:tsdm_client/extensions/string.dart'; +import 'package:tsdm_client/utils/debug.dart'; +import 'package:universal_html/html.dart' as uh; + +/// A single change on the user's points. +/// +/// Contains of change operation, attr points change list, change detail and +/// happened datetime. +class PointsChange extends Equatable { + /// Constructor + const PointsChange({ + required this.operation, + required this.changeMap, + required this.detail, + required this.time, + this.redirectUrl, + }); + + /// Operation type of this change. + final String operation; + + /// Map of the changes in different attr types with their names and values. + final Map changeMap; + + /// Html node to parse the detail change. + final String detail; + + /// An url to redirect. + /// + /// Point changes usually have this, but not guaranteed. + final String? redirectUrl; + + /// Datetime of this change. + final DateTime time; + + /// [PointsChange] is expected to exists inside a
. + /// + /// In that table, each (except the table header) can be converted to an + /// instance of [PointsChange] : + /// + /// + /// + /// + /// + /// + /// + /// This function tries to build [PointsChange] from [element]. + static PointsChange? fromTrNode(uh.Element element) { + final tdList = element.querySelectorAll('td'); + if (tdList.length != 4) { + debug('failed to build PointsChange instance: ' + 'invalid td count: ${tdList.length}'); + return null; + } + + final operation = tdList[0].querySelector('a')?.innerText; + if (operation == null) { + debug('failed to build PointsChange: operation not found'); + return null; + } + final attrNameList = []; + final attrValueList = []; + for (final node in tdList[1].childNodes) { + if (node.nodeType == uh.Node.ELEMENT_NODE) { + final e = node as uh.Element; + if (e.localName == 'span') { + attrValueList.add(e.innerText.trim()); + } + } else if (node.nodeType == uh.Node.TEXT_NODE) { + final attrNameText = node.text?.trim(); + if (attrNameText?.isNotEmpty ?? false) { + attrNameList.add(attrNameText!); + } + } + } + if (attrNameList.length != attrValueList.length) { + debug('failed to build PointsChange: invalid attar name ' + 'value length: $attrNameList and $attrValueList'); + return null; + } + final changeMap = {}; + for (var i = 0; i < attrNameList.length; i++) { + changeMap[attrNameList[i]] = attrValueList[i]; + } + + final detail = tdList[2].innerText; + final redirectUrl = + tdList[2].querySelector('a')?.attributes['href']?.prependHost(); + final changedTime = tdList[3].innerText.trim().parseToDateTimeUtc8(); + if (changedTime == null) { + debug('failed to build PointsChange: invalid change time:' + '${tdList[3].innerText.trim()}'); + return null; + } + + return PointsChange( + operation: operation, + detail: detail, + redirectUrl: redirectUrl, + changeMap: changeMap, + time: changedTime, + ); + } + + /// Get tht formatted change. + String get changeMapString => + changeMap.entries.map((e) => '${e.key} ${e.value}').join(','); + + @override + List get props => [ + operation, + changeMap, + detail, + redirectUrl, + time, + ]; +} diff --git a/lib/features/points/repository/model/changelog_parameter.dart b/lib/features/points/repository/model/changelog_parameter.dart new file mode 100644 index 00000000..0d74d74c --- /dev/null +++ b/lib/features/points/repository/model/changelog_parameter.dart @@ -0,0 +1,63 @@ +/// Points value change types. +enum IncomeType { + /// Outcome, points decreased. + outcome(-1), + + /// Do not limit the search this type. + unlimited(0), + + /// Income, points increased. + income(1); + + const IncomeType(this.value); + + /// Value when used in [ChangelogParameter]. + final int value; +} + +/// Query parameters used in log request. +final class ChangelogParameter { + /// Constructor. + const ChangelogParameter({ + required this.extType, + required this.startTime, + required this.endTime, + required this.income, + required this.operation, + required this.pageNumer, + }); + + /// Points type + /// + /// Default: "0 + final String extType; + + /// Search start time. + /// + /// Format: "yyyy-MM-dd" + /// + /// Default: "" + final String startTime; + + /// Search end time. + /// + /// Format: "yyyy-MM-dd" + /// + /// Default: "" + final String endTime; + + /// Points increase/decrease. + final IncomeType income; + + /// Operation type. + final String operation; + + /// Result page number; + final int pageNumer; + + @override + String toString() { + return '&exttype=$extType&income=${income.value}&optype=$operation&' + 'starttime=$startTime&endtime=$endTime'; + } +} diff --git a/lib/features/points/repository/points_repository.dart b/lib/features/points/repository/points_repository.dart new file mode 100644 index 00000000..3f763797 --- /dev/null +++ b/lib/features/points/repository/points_repository.dart @@ -0,0 +1,52 @@ +import 'dart:io'; + +import 'package:tsdm_client/constants/url.dart'; +import 'package:tsdm_client/exceptions/exceptions.dart'; +import 'package:tsdm_client/features/points/repository/model/changelog_parameter.dart'; +import 'package:tsdm_client/instance.dart'; +import 'package:tsdm_client/shared/providers/net_client_provider/net_client_provider.dart'; +import 'package:tsdm_client/shared/providers/server_time_provider/server_time_provider.dart'; +import 'package:universal_html/html.dart' as uh; +import 'package:universal_html/parsing.dart'; + +/// Repository of points statistics page and points changelog page. +final class PointsRepository { + static const _statisticsPageUrl = + '$baseUrl/home.php?mod=spacecp&ac=credit&op=base'; + static const _changelogPageUrl = + '$baseUrl/home.php?mod=spacecp&op=log&ac=credit'; + + /// Fetch the points statistics page. + /// + /// # Exceptions + /// + /// * **HttpRequestFailedException** when http request failed. + Future fetchStatisticsPage() async { + final netClient = getIt.get(); + final resp = await netClient.get(_statisticsPageUrl); + if (resp.statusCode != HttpStatus.ok) { + throw HttpRequestFailedException(resp.statusCode!); + } + + final document = parseHtmlDocument(resp.data as String); + getIt.get().updateServerTimeWithDocument(document); + return document; + } + + /// Fetch the points changelog page with given [parameter]. + /// + /// # Exceptions + /// + /// * **HttpRequestFailedException** when http request failed. + Future fetchChangelogPage(ChangelogParameter parameter) async { + final netClient = getIt.get(); + final resp = await netClient.get('$_changelogPageUrl$parameter'); + if (resp.statusCode != HttpStatus.ok) { + throw HttpRequestFailedException(resp.statusCode!); + } + + final document = parseHtmlDocument(resp.data as String); + getIt.get().updateServerTimeWithDocument(document); + return document; + } +} diff --git a/lib/features/points/views/points_page.dart b/lib/features/points/views/points_page.dart new file mode 100644 index 00000000..65d8380c --- /dev/null +++ b/lib/features/points/views/points_page.dart @@ -0,0 +1,144 @@ +import 'package:easy_refresh/easy_refresh.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:tsdm_client/constants/layout.dart'; +import 'package:tsdm_client/extensions/build_context.dart'; +import 'package:tsdm_client/features/points/bloc/points_bloc.dart'; +import 'package:tsdm_client/features/points/repository/points_repository.dart'; + +/// Page to show current logged user's points statistics and changelog. +class PointsPage extends StatefulWidget { + /// Constructor + const PointsPage({super.key}); + + @override + State createState() => _PointsPageState(); +} + +class _PointsPageState extends State + with SingleTickerProviderStateMixin { + late final TabController _tabController; + late final EasyRefreshController _statisticsRefreshController; + late final ScrollController _statisticsScrollController; + late final EasyRefreshController _changelogRefreshController; + late final ScrollController _changelogScrollController; + + Widget _buildStatisticsTab( + BuildContext context, + PointsStatisticsState state, + ) { + if (state.status == PointsStatus.loading) { + return const Center(child: sizedCircularProgressIndicator); + } + _statisticsRefreshController.finishRefresh(); + return Padding( + padding: edgeInsetsL10T5R10B20, + child: EasyRefresh( + controller: _statisticsRefreshController, + scrollController: _statisticsScrollController, + header: const MaterialHeader(), + onRefresh: () { + context + .read() + .add(PointsStatisticsRefreshRequired()); + }, + child: SingleChildScrollView( + controller: _statisticsScrollController, + child: Column( + children: [ + ...state.pointsMap.entries.map( + (e) => ListTile(title: Text('${e.key} ${e.value}')), + ), + ...state.pointsRecentChangelog.map( + (e) => Card( + clipBehavior: Clip.hardEdge, + child: InkWell( + onTap: e.redirectUrl == null + ? null + : () async { + // TODO: Handle find post type url. + await context.dispatchAsUrl(e.redirectUrl!); + }, + child: Padding( + padding: edgeInsetsL15T15R15B15, + child: Column( + children: [ + Text(e.operation), + Text(e.changeMapString), + Text(e.detail), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + _statisticsRefreshController = EasyRefreshController( + controlFinishRefresh: true, + ); + _statisticsScrollController = ScrollController(); + _changelogRefreshController = EasyRefreshController( + controlFinishRefresh: true, + controlFinishLoad: true, + ); + _changelogScrollController = ScrollController(); + } + + @override + void dispose() { + _tabController.dispose(); + _statisticsRefreshController.dispose(); + _statisticsScrollController.dispose(); + _changelogRefreshController.dispose(); + _changelogScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + RepositoryProvider( + create: (_) => PointsRepository(), + ), + BlocProvider( + create: (context) => PointsStatisticsBloc( + pointsRepository: RepositoryProvider.of(context), + )..add(PointsStatisticsRefreshRequired()), + ), + ], + child: MultiBlocListener( + listeners: [ + BlocListener( + listener: (context, state) { + // + }, + ), + ], + child: Scaffold( + appBar: AppBar(), + body: TabBarView( + controller: _tabController, + children: [ + BlocBuilder( + builder: _buildStatisticsTab, + ), + // TODO: Changelog tab. + Text('changelog page'), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/profile/view/profile_page.dart b/lib/features/profile/view/profile_page.dart index e5f9055c..28a5262c 100644 --- a/lib/features/profile/view/profile_page.dart +++ b/lib/features/profile/view/profile_page.dart @@ -147,6 +147,12 @@ class _ProfilePageState extends State { if (widget.username == null && widget.uid == null) { // Current is current logged user's profile page. actions = [ + IconButton( + icon: const Icon(Icons.show_chart_outlined), + onPressed: () async { + await context.pushNamed(ScreenPaths.points); + }, + ), IconButton( icon: noticeIcon, onPressed: () async { diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 18c24e9c..d6072f67 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -11,6 +11,7 @@ import 'package:tsdm_client/features/my_thread/view/my_thread_page.dart'; import 'package:tsdm_client/features/notification/models/notice.dart'; import 'package:tsdm_client/features/notification/view/notification_detail_page.dart'; import 'package:tsdm_client/features/notification/view/notification_page.dart'; +import 'package:tsdm_client/features/points/views/points_page.dart'; import 'package:tsdm_client/features/profile/view/profile_page.dart'; import 'package:tsdm_client/features/rate/view/rate_post_page.dart'; import 'package:tsdm_client/features/search/view/search_page.dart'; @@ -192,6 +193,11 @@ final router = GoRouter( ); }, ), + AppRoute( + path: ScreenPaths.points, + parentNavigatorKey: _rootRouteKey, + builder: (_) => const PointsPage(), + ), ], ); diff --git a/lib/routes/screen_paths.dart b/lib/routes/screen_paths.dart index 314b4f20..6dd0b403 100644 --- a/lib/routes/screen_paths.dart +++ b/lib/routes/screen_paths.dart @@ -90,4 +90,10 @@ class ScreenPaths { /// Page to rate a post in thread. static const String ratePost = '/ratePost/:username/:pid/:floor/:rateAction'; + + /// The page to show current logged user's points statistics status + /// and changelog. + /// + /// Need to login before see the content. + static const String points = '/points'; }
+ /// operation + /// + /// attr1 + /// signed_value1 + /// attr2 + /// signed_value2 + /// attr3 + /// signed_value3 + /// ... + /// + /// detail + /// datetime