Skip to content

Commit

Permalink
feat(points): Init user points statistics page
Browse files Browse the repository at this point in the history
* User points statistics page need further optimization.
* User points changelog page is unimplemented.
  • Loading branch information
realth000 committed Feb 5, 2024
1 parent 7249437 commit 0367d1a
Show file tree
Hide file tree
Showing 11 changed files with 648 additions and 1 deletion.
2 changes: 1 addition & 1 deletion lib/extensions/universal_html.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
87 changes: 87 additions & 0 deletions lib/features/points/bloc/points_bloc.dart
Original file line number Diff line number Diff line change
@@ -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<PointsStatisticsState>;

/// Bloc of user points statistics page.
final class PointsStatisticsBloc
extends Bloc<PointsStatisticsEvent, PointsStatisticsState> {
/// Constructor.
PointsStatisticsBloc({required PointsRepository pointsRepository})
: _pointsRepository = pointsRepository,
super(const PointsStatisticsState()) {
on<PointsStatisticsRefreshRequired>(_onPointsStatisticsRefreshRequired);
}

final PointsRepository _pointsRepository;

Future<void> _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<String, String>, List<PointsChange>)? _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<String, String>.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 <table class="dt">
List<PointsChange> _buildChangeListFromTable(uh.Element element) {
final ret = element
.querySelectorAll('table > tbody > tr')
.skip(1)
.map(PointsChange.fromTrNode)
.whereType<PointsChange>()
.toList();
return ret;
}
}
34 changes: 34 additions & 0 deletions lib/features/points/bloc/points_event.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
part of 'points_bloc.dart';

/// Event of points statistics page.
sealed class PointsStatisticsEvent extends Equatable {
/// Constructor.
const PointsStatisticsEvent();

@override
List<Object?> 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<Object?> 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;
}
119 changes: 119 additions & 0 deletions lib/features/points/bloc/points_state.dart
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<PointsChange> pointsRecentChangelog;

/// Copy with
PointsStatisticsState copyWith({
PointsStatus? status,
Map<String, String>? pointsMap,
List<PointsChange>? pointsRecentChangelog,
}) {
return PointsStatisticsState(
status: status ?? this.status,
pointsMap: pointsMap ?? this.pointsMap,
pointsRecentChangelog:
pointsRecentChangelog ?? this.pointsRecentChangelog,
);
}

@override
List<Object?> 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<PointsChange> 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<PointsChange>? pointsFullChangelog,
int? pointsLogPageCurrentNumber,
int? pointsLogPageTotalNumber,
}) {
return PointsChangelogState(
pointsFullChangelog: pointsFullChangelog ?? this.pointsFullChangelog,
pointsLogPageCurrentNumber:
pointsLogPageCurrentNumber ?? this.pointsLogPageCurrentNumber,
pointsLogPageTotalNumber:
pointsLogPageTotalNumber ?? this.pointsLogPageTotalNumber,
);
}

@override
List<Object?> get props => [
status,
pointsFullChangelog,
pointsLogPageCurrentNumber,
pointsLogPageTotalNumber,
];
}
130 changes: 130 additions & 0 deletions lib/features/points/models/points_change.dart
Original file line number Diff line number Diff line change
@@ -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<String, String> 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 <table class="dt">.
///
/// In that table, each <tr> (except the table header) can be converted to an
/// instance of [PointsChange] :
///
/// <tr>
/// <td>
/// <a>operation</a>
/// </td>
/// <td>
/// attr1
/// <span class="xi1">signed_value1</span>
/// attr2
/// <span class="xi1">signed_value2</span>
/// attr3
/// <span class="xi1">signed_value3</span>
/// ...
/// </td>
/// <td>
/// <a href=link_to_the_thread>detail</a>
/// </td>
/// <td>datetime</td>
///
/// This function tries to build [PointsChange] from <tr> [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 = <String>[];
final attrValueList = <String>[];
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 = <String, String>{};
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<Object?> get props => [
operation,
changeMap,
detail,
redirectUrl,
time,
];
}
Loading

0 comments on commit 0367d1a

Please sign in to comment.