Skip to content

Commit

Permalink
feat(ui): Show post rate status in thread
Browse files Browse the repository at this point in the history
  • Loading branch information
realth000 committed Dec 6, 2023
1 parent 17444b6 commit 3c8ed56
Show file tree
Hide file tree
Showing 8 changed files with 297 additions and 1 deletion.
18 changes: 18 additions & 0 deletions lib/extensions/list.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@


extension Spacing<T> on List<T> {
List<T> insertBetween(T item) {
if (length < 1) {
return this;
}

final ret = skip(1).fold([first], (acc, x) {
acc
..add(item)
..add(x);
return acc;
}).toList();

return ret;
}
}
4 changes: 4 additions & 0 deletions lib/i18n/strings.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,5 +198,9 @@
"confirmInfo": "Purchase from $author with is price $price.\nThe author will get $authorProfit coins.\nYou will have $coinsLast coins left after purchase",
"successPurchase": "Purchase success",
"successPurchaseInfo": "Will refresh this page"
},
"rateCard": {
"title": "Rate log ($userCount)",
"total": "Total: $total"
}
}
4 changes: 4 additions & 0 deletions lib/i18n/strings_zh-CN.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,5 +198,9 @@
"confirmInfo": "从 $author 处以 $price 个天使币的价格购买帖子。\n作者会得到 $authorProfit 个天使币。\n购买后你还有 $coinsLast 个天使币。",
"successPurchase": "购买成功",
"successPurchaseInfo": "即将刷新页面"
},
"rateCard": {
"title": "评分记录($userCount)",
"total": "总计: $total"
}
}
4 changes: 4 additions & 0 deletions lib/i18n/strings_zh-TW.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,5 +198,9 @@
"confirmInfo": "从 $author 处以 $price 個天使幣的价格帖子。\n作者會得到 $authorProfit 個天使幣。\n購買后你還有 $coinsLast 個天使幣。",
"successPurchase": "購買成功",
"successPurchaseInfo": "即將重新整理頁面"
},
"rateCard": {
"title": "評分記錄($userCount)",
"total": "總計: $total"
}
}
18 changes: 18 additions & 0 deletions lib/models/post.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:tsdm_client/extensions/string.dart';
import 'package:tsdm_client/extensions/universal_html.dart';
import 'package:tsdm_client/models/locked.dart';
import 'package:tsdm_client/models/rate.dart';
import 'package:tsdm_client/models/user.dart';
import 'package:tsdm_client/utils/debug.dart';
import 'package:universal_html/html.dart' as uh;
Expand All @@ -16,6 +17,7 @@ class _PostInfo {
required this.data,
required this.replyAction,
this.locked,
this.rate,
});

/// Post ID.
Expand All @@ -39,6 +41,10 @@ class _PostInfo {
/// Need purchase to see full thread content.
Locked? locked;

/// `<dl id="ratelog_xxx">` in `<div class="pcb">`.
/// Rate records on this post.
Rate? rate;

/// Url to reply this post.
String? replyAction;
}
Expand Down Expand Up @@ -68,6 +74,8 @@ class Post {

String? get replyAction => _info.replyAction;

Rate? get rate => _info.rate;

/// Build [Post] from [uh.Element].
static _PostInfo _buildPostFromElement(uh.Element element) {
final trRootNode = element.querySelector('table > tbody > tr');
Expand Down Expand Up @@ -127,6 +135,15 @@ class Post {
'table > tbody > tr:nth-child(2) > td.tsdm_replybar > div.po > div > em > a')
?.firstHref();

final rateNode = postDataNode?.querySelector('div.pct > div.pcb > dl.rate');
Rate? rate;
if (rateNode != null) {
final r = Rate.fromRateLogNode(rateNode);
if (r.isValid()) {
rate = r;
}
}

return _PostInfo(
postID: postID,
postFloor: postFloor,
Expand All @@ -135,6 +152,7 @@ class Post {
data: postData ?? '',
locked: locked,
replyAction: replyAction,
rate: rate,
);
}

Expand Down
157 changes: 157 additions & 0 deletions lib/models/rate.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import 'package:tsdm_client/extensions/string.dart';
import 'package:tsdm_client/extensions/universal_html.dart';
import 'package:tsdm_client/models/user.dart';
import 'package:tsdm_client/utils/debug.dart';
import 'package:universal_html/html.dart' as uh;

/// Rate record for a single user.
class SingleRate {
SingleRate({
required this.user,
required this.attrValueList,
});

/// User info.
/// Name, user space url and avatar url is required.
final User user;

/// Rate content.
/// Values for each attr in this rate.
/// Should have same length with attrList in rate info table.
final List<String> attrValueList;
}

class _RateInfo {
_RateInfo({
required this.userCount,
required this.detailUrl,
required this.attrList,
required this.records,
required this.rateStatus,
});

_RateInfo.empty()
: userCount = null,
detailUrl = null,
attrList = [],
records = [],
rateStatus = null;

/// Count of users rated.
final int? userCount;

/// Url contains rate detail info.
final String? detailUrl;

/// Rated attributes.
/// Show as column header.
/// Should have same length with attrValueList in single rate record.
final List<String> attrList;

/// Records of rating.
final List<SingleRate> records;

/// Total rate status.
final String? rateStatus;
}

/// Rate record for a single post.
///
/// Contains a series of [SingleRate]s, including rated attributes and their
/// values. Users who rated also recorded.
class Rate {
/// Build a [Rate] from element <dl id="ratelog_xxx" class="rate">.
Rate.fromRateLogNode(uh.Element element)
: _info = _buildRateInfoFromNode(element);

final _RateInfo _info;

int? get userCount => _info.userCount;

String? get detailUrl => _info.detailUrl;

List<String> get attrList => _info.attrList;

List<SingleRate> get records => _info.records;

String? get rateStatus => _info.rateStatus;

static _RateInfo _buildRateInfoFromNode(uh.Element element) {
final rateHeaders =
element.querySelectorAll('table > tbody:nth-child(1) > tr > th');
if (rateHeaders.length < 2) {
return _RateInfo.empty();
}

final infoNode = rateHeaders.firstOrNull?.querySelector('a');
final userCount =
infoNode?.querySelector('span.xi1')?.firstEndDeepText()?.parseToInt();
final detailUrl = infoNode?.firstHref();
final attrList = rateHeaders
.skip(1)
.map((e) => e.querySelector('i')?.firstEndDeepText())
.whereType<String>()
.toList();

final recordNodeList =
element.querySelectorAll('table > tbody.ratl_l > tr');
final records =
recordNodeList.map(_parseSingleRate).whereType<SingleRate>().toList();
final rateStatus = element
.querySelector('p.ratc')
?.querySelectorAll('span.xi1')
.map((e) => e.firstEndDeepText())
.whereType<String>()
.toList()
.join(' ');

return _RateInfo(
userCount: userCount,
detailUrl: detailUrl,
attrList: attrList,
records: records,
rateStatus: rateStatus,
);
}

/// Try parse a [SingleRate] from [element] <tr id="xxx">
static SingleRate? _parseSingleRate(uh.Element element) {
final tdList = element.querySelectorAll('td');
if (tdList.length < 2) {
return null;
}
final userNode = tdList.firstOrNull;
final url = userNode?.querySelector('a:nth-child(1)')?.firstHref();
final avatarUrl =
userNode?.querySelector('a:nth-child(1) > img')?.imageUrl();
final name = userNode?.querySelector('a:nth-child(2)')?.firstEndDeepText();
final attrValueList =
tdList.skip(1).map((e) => e.firstEndDeepText() ?? '').toList();

if (url == null || name == null) {
return null;
}
return SingleRate(
user: User(
name: name,
url: url,
avatarUrl: avatarUrl,
),
attrValueList: attrValueList,
);
}

bool isValid() {
if (userCount == null ||
detailUrl == null ||
attrList.isEmpty ||
records.isEmpty ||
rateStatus == null) {
debug(
'invalid rate $userCount, $detailUrl, $attrList, $records, $rateStatus');
return false;
}

return true;
}
}
8 changes: 7 additions & 1 deletion lib/widgets/post_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:tsdm_client/models/user.dart';
import 'package:tsdm_client/packages/html_muncher/lib/html_muncher.dart';
import 'package:tsdm_client/widgets/cached_image_provider.dart';
import 'package:tsdm_client/widgets/locked_card.dart';
import 'package:tsdm_client/widgets/rate_card.dart';
import 'package:universal_html/parsing.dart';

/// Card for a [Post] model.
Expand Down Expand Up @@ -72,7 +73,12 @@ class _PostCardState extends ConsumerState<PostCard>
],
),
),
if (widget.post.locked != null) LockedCard(widget.post.locked!)
if (widget.post.locked != null) LockedCard(widget.post.locked!),
if (widget.post.rate != null)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 712),
child: RateCard(widget.post.rate!),
),
],
);
}
Expand Down
85 changes: 85 additions & 0 deletions lib/widgets/rate_card.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:tsdm_client/constants/layout.dart';
import 'package:tsdm_client/constants/url.dart';
import 'package:tsdm_client/generated/i18n/strings.g.dart';
import 'package:tsdm_client/models/rate.dart';
import 'package:tsdm_client/widgets/cached_image_provider.dart';

class RateCard extends ConsumerWidget {
const RateCard(this.rate, {super.key});

final Rate rate;

@override
Widget build(BuildContext context, WidgetRef ref) {
// Column width.
// The first column is user info and last column is always "rate reason",
// these two columns should have a flex column width.
// The rest columns are always short enough to constrains in fixed width.
final fixedColumnWidths = List.filled(rate.attrList.length - 1, 50);
final columnWidths = <int, TableColumnWidth>{
for (final (i, v) in fixedColumnWidths.indexed)
i + 1: FixedColumnWidth(v.toDouble())
};
columnWidths[0] = const FlexColumnWidth();
columnWidths[rate.attrList.length + 1] = const FlexColumnWidth();
final tableHeaders = [
context.t.rateCard.title(userCount: '${rate.userCount}'),
...rate.attrList
].map(Text.new).toList();

final bottom =
Text(context.t.rateCard.total(total: rate.rateStatus ?? '-'));

final tableContent = rate.records
.map((e) => TableRow(
children: [
Row(
children: [
SizedBox(
height: 50,
child: Center(
child: CircleAvatar(
backgroundImage: CachedImageProvider(
e.user.avatarUrl ?? noAvatarUrl,
context,
ref,
),
),
),
),
sizedBoxW5H5,
Expanded(
child: Text(
e.user.name,
textAlign: TextAlign.left,
)),
],
),
...e.attrValueList.map(Text.new),
],
))
.toList();

return Card(
child: Padding(
padding: edgeInsetsL15T15R15B15,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Table(
columnWidths: columnWidths,
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
children: [
TableRow(children: tableHeaders),
...tableContent,
],
),
bottom,
],
),
),
);
}
}

0 comments on commit 3c8ed56

Please sign in to comment.