feat: read专栏内容渲染

This commit is contained in:
guozhigq
2024-09-26 23:45:58 +08:00
parent 1ed098fd81
commit 71566ba064
13 changed files with 856 additions and 60 deletions

View File

@ -154,12 +154,10 @@ class DynamicsController extends GetxController {
Iterable<Match> matches = digitRegExp.allMatches(jumpUrl);
String number = matches.first.group(0)!;
if (jumpUrl.contains('read')) {
number = 'cv$number';
Get.toNamed('/htmlRender', parameters: {
'url': url,
Get.toNamed('/read', parameters: {
'title': title,
'id': number,
'dynamicType': url.split('/')[1]
'articleType': url.split('/')[1]
});
} else {
Get.toNamed('/opus', parameters: {

View File

@ -17,26 +17,46 @@ class TextHelper {
static TextSpan buildTextSpan(
ModuleParagraphTextNode node, int? align, BuildContext context) {
switch (node.type) {
case 'TEXT_NODE_TYPE_WORD':
return TextSpan(
text: node.word?.words ?? '',
style: TextStyle(
fontSize:
node.word?.fontSize != null ? node.word!.fontSize! * 0.95 : 14,
fontWeight: node.word?.style?.bold != null
? FontWeight.bold
: FontWeight.normal,
height: align == 1 ? 2 : 1.5,
color: node.word?.color != null
? Color(
int.parse(node.word!.color!.substring(1, 7), radix: 16) +
0xFF000000)
: Theme.of(context).colorScheme.onBackground,
),
);
default:
return const TextSpan(text: '');
// 获取node的所有key
if (node.nodeType != null) {
return TextSpan(
text: node.word?.words ?? '',
style: TextStyle(
fontSize:
node.word?.fontSize != null ? node.word!.fontSize! * 0.95 : 14,
fontWeight: node.word?.style?.bold != null
? FontWeight.bold
: FontWeight.normal,
height: align == 1 ? 2 : 1.5,
color: node.word?.color != null
? Color(int.parse(node.word!.color!.substring(1, 7), radix: 16) +
0xFF000000)
: Theme.of(context).colorScheme.onBackground,
),
);
} else {
switch (node.type) {
case 'TEXT_NODE_TYPE_WORD':
return TextSpan(
text: node.word?.words ?? '',
style: TextStyle(
fontSize: node.word?.fontSize != null
? node.word!.fontSize! * 0.95
: 14,
fontWeight: node.word?.style?.bold != null
? FontWeight.bold
: FontWeight.normal,
height: align == 1 ? 2 : 1.5,
color: node.word?.color != null
? Color(
int.parse(node.word!.color!.substring(1, 7), radix: 16) +
0xFF000000)
: Theme.of(context).colorScheme.onBackground,
),
);
default:
return const TextSpan(text: '');
}
}
}
}

View File

@ -0,0 +1,89 @@
import 'dart:async';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/http/read.dart';
import 'package:pilipala/models/read/read.dart';
import 'package:pilipala/plugin/pl_gallery/hero_dialog_route.dart';
import 'package:pilipala/plugin/pl_gallery/interactiveviewer_gallery.dart';
class ReadPageController extends GetxController {
late String url;
RxString title = ''.obs;
late String id;
late String articleType;
Rx<ReadDataModel> cvData = ReadDataModel().obs;
final ScrollController scrollController = ScrollController();
final StreamController<bool> appbarStream = StreamController<bool>();
@override
void onInit() {
super.onInit();
title.value = Get.parameters['title'] ?? '';
id = Get.parameters['id']!;
articleType = Get.parameters['articleType']!;
scrollController.addListener(_scrollListener);
}
Future fetchCvData() async {
var res = await ReadHttp.parseArticleCv(id: id);
if (res['status']) {
cvData.value = res['data'];
title.value = cvData.value.readInfo!.title!;
}
return res;
}
void _scrollListener() {
final double offset = scrollController.position.pixels;
if (offset > 100) {
appbarStream.add(true);
} else {
appbarStream.add(false);
}
}
void onPreviewImg(picList, initIndex, context) {
Navigator.of(context).push(
HeroDialogRoute<void>(
builder: (BuildContext context) => InteractiveviewerGallery(
sources: picList,
initIndex: initIndex,
itemBuilder: (
BuildContext context,
int index,
bool isFocus,
bool enablePageView,
) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (enablePageView) {
Navigator.of(context).pop();
}
},
child: Center(
child: Hero(
tag: picList[index],
child: CachedNetworkImage(
fadeInDuration: const Duration(milliseconds: 0),
imageUrl: picList[index],
fit: BoxFit.contain,
),
),
),
);
},
onPageChanged: (int pageIndex) {},
),
),
);
}
@override
void onClose() {
scrollController.removeListener(_scrollListener);
appbarStream.close();
super.onClose();
}
}

View File

@ -0,0 +1,4 @@
library read;
export 'controller.dart';
export 'view.dart';

342
lib/pages/read/view.dart Normal file
View File

@ -0,0 +1,342 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:pilipala/common/widgets/html_render.dart';
import 'package:pilipala/common/widgets/network_img_layer.dart';
import 'package:pilipala/models/read/opus.dart';
import 'package:pilipala/models/read/read.dart';
import 'package:pilipala/pages/opus/text_helper.dart';
import 'package:pilipala/utils/utils.dart';
import 'controller.dart';
class ReadPage extends StatefulWidget {
const ReadPage({super.key});
@override
State<ReadPage> createState() => _ReadPageState();
}
class _ReadPageState extends State<ReadPage> {
final ReadPageController controller = Get.put(ReadPageController());
late Future _futureBuilderFuture;
@override
void initState() {
super.initState();
_futureBuilderFuture = controller.fetchCvData();
}
List<String> extractDataSrc(String input) {
final regex = RegExp(r'data-src="([^"]*)"');
final matches = regex.allMatches(input);
return matches.map((match) {
final dataSrc = match.group(1)!;
return dataSrc.startsWith('//') ? 'https:$dataSrc' : dataSrc;
}).toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: _buildAppBar(),
body: SingleChildScrollView(
controller: controller.scrollController,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTitle(),
_buildFutureContent(),
],
),
),
);
}
AppBar _buildAppBar() {
return AppBar(
title: StreamBuilder(
stream: controller.appbarStream.stream.distinct(),
initialData: false,
builder: (BuildContext context, AsyncSnapshot snapshot) {
return AnimatedOpacity(
opacity: snapshot.data ? 1 : 0,
curve: Curves.easeOut,
duration: const Duration(milliseconds: 500),
child: Obx(
() => Text(
controller.title.value,
style: const TextStyle(fontSize: 16),
),
),
);
},
),
actions: [
IconButton(
icon: const Icon(Icons.more_vert_rounded),
onPressed: () {},
),
const SizedBox(width: 16),
],
);
}
Widget _buildTitle() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: Obx(
() => Text(
controller.title.value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
letterSpacing: 1,
height: 1.5,
),
),
),
);
}
Widget _buildFutureContent() {
return FutureBuilder(
future: _futureBuilderFuture,
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.data == null) {
return const SizedBox();
}
if (snapshot.data['status']) {
return _buildContent(snapshot.data['data']);
} else {
return _buildError(snapshot.data['message']);
}
} else {
return _buildLoading();
}
},
);
}
Widget _buildContent(ReadDataModel cvData) {
final List<String> picList = _extractPicList(cvData);
final List<String> imgList = extractDataSrc(cvData.readInfo!.content!);
return Padding(
padding: EdgeInsets.fromLTRB(
16, 0, 16, MediaQuery.of(context).padding.bottom + 40),
child: cvData.readInfo!.opus == null
? _buildNonOpusContent(cvData, imgList)
: _buildOpusContent(cvData, picList),
);
}
List<String> _extractPicList(ReadDataModel cvData) {
final List<String> picList = [];
if (cvData.readInfo!.opus != null) {
final List<ModuleParagraph> paragraphs =
cvData.readInfo!.opus!.content!.paragraphs!;
for (var paragraph in paragraphs) {
if (paragraph.paraType == 2) {
for (var pic in paragraph.pic!.pics!) {
picList.add(pic.url!);
}
}
}
}
return picList;
}
Widget _buildNonOpusContent(ReadDataModel cvData, List<String> imgList) {
return Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: 30),
child: _buildStatsWidget(cvData),
),
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: _buildAuthorWidget(cvData),
),
HtmlRender(
htmlContent: cvData.readInfo!.content!,
imgList: imgList,
),
],
);
}
Widget _buildOpusContent(ReadDataModel cvData, List<String> picList) {
return Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 30),
child: _buildStatsWidget(cvData),
),
Padding(
padding: const EdgeInsets.only(bottom: 20),
child: _buildAuthorWidget(cvData),
),
...cvData.readInfo!.opus!.content!.paragraphs!.map(
(ModuleParagraph paragraph) {
return Column(
children: [
if (paragraph.paraType == 1)
_buildTextParagraph(paragraph)
else if (paragraph.paraType == 2)
..._buildPics(paragraph, picList)
else
const SizedBox(),
],
);
},
),
],
);
}
Widget _buildTextParagraph(ModuleParagraph paragraph) {
return Container(
alignment: TextHelper.getAlignment(paragraph.align),
margin: const EdgeInsets.only(bottom: 10),
child: Text.rich(
TextSpan(
children: paragraph.text?.nodes?.map((node) {
return TextHelper.buildTextSpan(node, paragraph.align, context);
}).toList() ??
[],
),
),
);
}
Widget _buildError(String message) {
return SizedBox(
height: 100,
child: Center(
child: Text(message),
),
);
}
Widget _buildLoading() {
return const SizedBox(
height: 100,
child: Center(
child: CircularProgressIndicator(),
),
);
}
Widget _buildStatsWidget(ReadDataModel cvData) {
return Row(
children: [
StyledText(Utils.CustomStamp_str(
timestamp: cvData.readInfo!.publishTime!,
date: 'YY-MM-DD hh:mm',
toInt: false,
)),
const SizedBox(width: 10),
StyledText('${Utils.numFormat(cvData.readInfo!.stats!['view'])}浏览'),
const StyledText(' · '),
StyledText('${cvData.readInfo!.stats!['like']}点赞'),
// const StyledText(' · '),
// StyledText('${cvData.readInfo!.stats!['reply']}评论'),
],
);
}
Widget _buildAuthorWidget(ReadDataModel cvData) {
final Author author = cvData.readInfo!.author!;
return Row(
children: [
NetworkImgLayer(
width: 48,
height: 48,
type: 'avatar',
src: author.face,
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
author.name!,
style: TextStyle(
color: author.vip!.nicknameColor != null
? Color(author.vip!.nicknameColor!)
: null,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 6),
Image.asset(
'assets/images/lv/lv${author.level}.png',
height: 11,
),
],
),
Row(
children: [
StyledText('粉丝: ${Utils.numFormat(author.fans)}'),
const SizedBox(width: 10),
StyledText(
'文章: ${Utils.numFormat(cvData.readInfo!.totalArtNum)}'),
],
),
],
),
],
);
}
List<Widget> _buildPics(ModuleParagraph paragraph, List<String> picList) {
return paragraph.pic?.pics
?.map(
(Pic pic) => Center(
child: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10),
child: InkWell(
onTap: () {
controller.onPreviewImg(
picList,
picList.indexOf(pic.url!),
context,
);
},
child: NetworkImgLayer(
src: pic.url,
width: (Get.size.width - 32) * pic.scale!,
height:
(Get.size.width - 32) * pic.scale! / pic.aspectRatio!,
type: 'emote',
),
),
),
),
)
.toList() ??
[];
}
}
class StyledText extends StatelessWidget {
final String text;
const StyledText(this.text, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Text(
text,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.outline,
),
);
}
}

View File

@ -14,11 +14,10 @@ Widget searchArticlePanel(BuildContext context, ctr, list) {
itemBuilder: (context, index) {
return InkWell(
onTap: () {
Get.toNamed('/htmlRender', parameters: {
'url': 'www.bilibili.com/read/cv${list[index].id}',
Get.toNamed('/read', parameters: {
'title': list[index].subTitle,
'id': 'cv${list[index].id}',
'dynamicType': 'read'
'id': list[index].id.toString(),
'articleType': 'read'
});
},
child: Padding(