Skip to content

Commit

Permalink
feat(bounty): Support thread bounty state and best answer
Browse files Browse the repository at this point in the history
This commit add support for parsing and showing the bounty state of
thread (if any) and the related best answer content (if any).
  • Loading branch information
realth000 committed Feb 16, 2024
1 parent 614b6a2 commit 1adbef1
Show file tree
Hide file tree
Showing 7 changed files with 295 additions and 0 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- 新增显示帖子的悬赏状态及对应的最佳答案。
- 支持筛选积分变更历史。
- 帖子内按页数显示分组。

Expand Down
9 changes: 9 additions & 0 deletions lib/i18n/strings.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -365,5 +365,14 @@
"open": "Open It",
"failedToOpen": "Failed to open",
"allTakenAway": "All packets were taken away"
},
"bountyCard": {
"title": "Bounty",
"price": "$price coins",
"resolved": "RESOLVED",
"processing": "PROCESSING"
},
"bountyAnswerCard": {
"title": "Best Answer"
}
}
9 changes: 9 additions & 0 deletions lib/i18n/strings_zh-CN.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -365,5 +365,14 @@
"open": "打开",
"failedToOpen": "打开失败",
"allTakenAway": "红包都领完了"
},
"bountyCard": {
"title": "悬赏",
"price": "$price 天使币",
"resolved": "已解决",
"processing": "进行中"
},
"bountyAnswerCard": {
"title": "最佳答案"
}
}
9 changes: 9 additions & 0 deletions lib/i18n/strings_zh-TW.i18n.json
Original file line number Diff line number Diff line change
Expand Up @@ -365,5 +365,14 @@
"open": "開啟",
"failedToOpen": "打開失敗",
"allTakenAway": "红包都領完了"
},
"bountyCard": {
"title": "懸賞",
"price": "$price 天使幣",
"resolved": "已解決",
"processing": "進行中"
},
"bountyAnswerCard": {
"title": "最佳答案"
}
}
91 changes: 91 additions & 0 deletions lib/packages/html_muncher/lib/src/html_muncher.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import 'package:tsdm_client/extensions/universal_html.dart';
import 'package:tsdm_client/packages/html_muncher/lib/src/types.dart';
import 'package:tsdm_client/packages/html_muncher/lib/src/web_colors.dart';
import 'package:tsdm_client/shared/models/locked.dart';
import 'package:tsdm_client/utils/debug.dart';
import 'package:tsdm_client/widgets/card/bounty_answer_card.dart';
import 'package:tsdm_client/widgets/card/bounty_card.dart';
import 'package:tsdm_client/widgets/card/code_card.dart';
import 'package:tsdm_client/widgets/card/lock_card/locked_card.dart';
import 'package:tsdm_client/widgets/card/review_card.dart';
Expand Down Expand Up @@ -423,6 +426,9 @@ class Muncher {
'locked': _buildLockedArea,
'cm': _buildReview,
'spoiler': _buildSpoiler,
'rusld': _buildUnresolvedBounty,
'rsld': _buildResolvedBounty,
'rwdbst': _buildBountyBestAnswer,
};

final alreadyInDiv = state.inDiv;
Expand Down Expand Up @@ -513,6 +519,91 @@ class Muncher {
);
}

/// Build for the thread bounty info area.
///
/// The bounty is processing, not resolved.
///
/// ```html
/// <div class="rusld z">
/// <cite>${price}<cite>
/// </div>
InlineSpan _buildUnresolvedBounty(uh.Element element) {
final price = element.querySelector('cite')?.innerText ?? '';
return WidgetSpan(child: BountyCard(price: price, resolved: false));
}
/// Build for the thread bounty info area.
///
/// The bounty is resolved.
///
/// ```html
/// <div class="rsld z">
/// <cite>${price}<cite>
/// </div>
/// ```
InlineSpan _buildResolvedBounty(uh.Element element) {
final price = element.querySelector('cite')?.innerText ?? '';
return WidgetSpan(child: BountyCard(price: price, resolved: true));
}
/// Build for the best answer of bounty area.
///
/// This answer only occurs with already resolved bounty.
///
/// * `USER_AVATAR_URL`: Avatar url of the answered user.
/// * `USER_SPACE_URL`: Profile url of the answered user.
/// * `USERNAME`: Username of the answered user.
/// * `PTID`: Thread id of the answer.
/// * `PID`: Post id of the answer.
/// * `USER_ANSWER`: Answer content.
///
/// ```html
/// <div class="rwdbst">
// <h3 class="psth">最佳答案</h3>
// <div class="pstl">
// <div class="psta">
// <img src="${USER_AVATAR_URL">
// </div>
// <div class="psti">
// <p class="xi2">
// <a href="${USER_SPACE_URL}" class="xw1">${USERNAME}</a>
// <a href="javascript:;" onclick="window.open('forum.php?mod=redirect&amp;goto=findpost&amp;ptid=${PTID}&amp;pid=${PID}')">查看完整内容</a></p>
// <div class="mtn">${USER_ANSWER}</div>
// </div>
// </div>
// </div>
/// ```
InlineSpan _buildBountyBestAnswer(uh.Element element) {
final userAvatarUrl =
element.querySelector('div.pstl > div.psta > img')?.imageUrl();
final userInfoNode =
element.querySelector('div.pstl > div.psti > p.xi2 > a');
final username = userInfoNode?.innerText.trim();
final userSpaceUrl = userInfoNode?.attributes['href'];
final answer = element
.querySelector('div.pstl > div.psti > div.mtn')
?.innerText
.trim();
if (userAvatarUrl == null ||
username == null ||
userSpaceUrl == null ||
answer == null) {
debug('failed to parse bounty answer: '
'avatar=$userAvatarUrl, username=$username, '
'userSpaceUrl=$userSpaceUrl, answer=$answer');
return const TextSpan();
}
return WidgetSpan(
child: BountyAnswerCard(
userAvatarUrl: userAvatarUrl,
username: username,
userSpaceUrl: userSpaceUrl,
answer: answer,
),
);
}
InlineSpan _buildA(uh.Element element) {
if (element.attributes.containsKey('href')) {
state.tapUrl = element.attributes['href'];
Expand Down
77 changes: 77 additions & 0 deletions lib/widgets/card/bounty_answer_card.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:tsdm_client/constants/layout.dart';
import 'package:tsdm_client/constants/url.dart';
import 'package:tsdm_client/extensions/build_context.dart';
import 'package:tsdm_client/generated/i18n/strings.g.dart';
import 'package:tsdm_client/widgets/cached_image/cached_image_provider.dart';

/// Widget to show the answer of a bounty in thread.
class BountyAnswerCard extends StatelessWidget {
/// Constructor.
const BountyAnswerCard({
required this.username,
required this.userSpaceUrl,
required this.userAvatarUrl,
required this.answer,
super.key,
});

/// User name of the answer.
final String username;

/// Profile url of the answer's user.
final String userSpaceUrl;

/// Avatar url of the answer's user.
final String userAvatarUrl;

/// Answer content.
final String answer;

@override
Widget build(BuildContext context) {
final secondaryColor = Theme.of(context).colorScheme.secondary;
return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: edgeInsetsL15T15R15B15,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.verified, size: 28, color: secondaryColor),
sizedBoxW10H10,
Text(
context.t.bountyAnswerCard.title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: secondaryColor,
),
),
],
),
sizedBoxW10H10,
ListTile(
contentPadding: EdgeInsets.zero,
leading: GestureDetector(
onTap: () async => context.dispatchAsUrl(userSpaceUrl),
child: CircleAvatar(
backgroundImage: CachedImageProvider(
userAvatarUrl,
fallbackImageUrl: noAvatarUrl,
context,
),
),
),
title: GestureDetector(
onTap: () async => context.dispatchAsUrl(userSpaceUrl),
child: Text(username),
),
),
Text(answer),
],
),
),
);
}
}
99 changes: 99 additions & 0 deletions lib/widgets/card/bounty_card.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:tsdm_client/constants/layout.dart';
import 'package:tsdm_client/extensions/list.dart';
import 'package:tsdm_client/generated/i18n/strings.g.dart';

/// Widget showing a bounty info in thread.
class BountyCard extends StatelessWidget {
/// Constructor.
const BountyCard({required this.resolved, required this.price, super.key});

/// Flag indicating the bounty state.
///
/// Is resolved or not.
final bool resolved;

/// Price of this bounty.
final String price;

@override
Widget build(BuildContext context) {
final secondaryColor = Theme.of(context).colorScheme.secondary;
final tertiaryColor = Theme.of(context).colorScheme.tertiary;
final bountyStatusTextStyle =
Theme.of(context).textTheme.bodyMedium?.copyWith(
color: tertiaryColor,
);
final bountyStatusTextResolvedStyle =
Theme.of(context).textTheme.bodyMedium?.copyWith(
color: secondaryColor,
);

// Bounty status.
late final Widget bountyStatusWidget;
if (resolved) {
bountyStatusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.done, color: secondaryColor),
sizedBoxW5H5,
Text(
context.t.bountyCard.resolved,
style: bountyStatusTextResolvedStyle,
),
],
);
} else {
bountyStatusWidget = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.pending, color: tertiaryColor),
sizedBoxW5H5,
Text(context.t.bountyCard.processing, style: bountyStatusTextStyle),
],
);
}

return Card(
margin: EdgeInsets.zero,
child: Padding(
padding: edgeInsetsL15T15R15B15,
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 100),
child: Column(
children: <Widget>[
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.t.bountyCard.title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: secondaryColor,
),
),
sizedBoxW20H20,
bountyStatusWidget,
],
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
FontAwesomeIcons.coins,
size: 20,
),
sizedBoxW5H5,
Text(
context.t.bountyCard.price(price: price),
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
].insertBetween(sizedBoxW10H10),
),
),
),
);
}
}

0 comments on commit 1adbef1

Please sign in to comment.