This commit is contained in:
2026-03-07 17:24:59 +08:00
parent 4418ebecac
commit b0ec8ab4bd
417 changed files with 42546 additions and 2 deletions

View File

@@ -0,0 +1,318 @@
import 'dart:async';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/event_bus.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/comic/detail_info.dart';
import 'package:flutter_dmzj/models/db/comic_history.dart';
import 'package:flutter_dmzj/modules/comic/detail/comic_detail_related_page.dart';
import 'package:flutter_dmzj/requests/comic_request.dart';
import 'package:flutter_dmzj/requests/user_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/app_settings_service.dart';
import 'package:flutter_dmzj/services/db_service.dart';
import 'package:flutter_dmzj/services/user_service.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class ComicDetailControler extends BaseController {
final int comicId;
ComicDetailControler(this.comicId);
final ComicRequest request = ComicRequest();
final UserRequest userRequest = UserRequest();
Rx<ComicDetailInfo> detail = Rx<ComicDetailInfo>(ComicDetailInfo.empty());
var expandDescription = false.obs;
/// 是否已订阅
var subscribeStatus = false.obs;
/// 是否已收藏
/// 收藏是收藏到本地的,订阅是同步到动漫之家服务器的
var favorited = false.obs;
/// 阅读记录
Rx<ComicHistory?> history = Rx<ComicHistory?>(null);
/// 更新漫画记录
StreamSubscription<dynamic>? updateComicSubscription;
@override
void onInit() {
updateComicSubscription = EventBus.instance.listen(
EventBus.kUpdatedComicHistory,
(id) {
if (id == comicId) {
getHistory();
}
},
);
favorited.value = DBService.instance.hasComicFavorited(comicId: comicId);
// 从本地读取订阅状态
subscribeStatus.value =
UserService.instance.subscribedComicIds.contains(comicId);
getHistory();
loadDetail();
loadSubscribeStatus();
//updateSubscribeRead();
super.onInit();
}
void refreshDetail() {
getHistory();
loadDetail();
loadSubscribeStatus();
}
/// 更新订阅的阅读状态
void updateSubscribeRead() {
try {
userRequest.subscribeRead(id: comicId, type: AppConstant.kTypeComic);
} catch (e) {
Log.logPrint(e);
}
}
@override
void onClose() {
updateComicSubscription?.cancel();
super.onClose();
}
void getHistory() {
var comicHistory = DBService.instance.getComicHistory(comicId);
if (comicHistory != null) {
history.value = comicHistory;
history.update((val) {});
}
}
void refreshV1() async {
try {
var result =
await request.comicDetail(comicId: comicId, priorityV1: true);
if (result.volumes.isEmpty) {
return;
}
if (result.isHide && AppSettingsService.instance.collectHideComic.value) {
favorite();
}
detail.update((val) {
val!.volumes = result.volumes;
});
} catch (e) {
SmartDialog.showToast("无法获取章节");
}
}
/// 加载信息
void loadDetail() async {
try {
pageLoadding.value = true;
pageError.value = false;
var result = await request.comicDetail(comicId: comicId);
detail.value = result;
if (result.volumes.isEmpty && !result.isHide) {
refreshV1();
}
if (result.isHide && AppSettingsService.instance.collectHideComic.value) {
favorite();
}
} catch (e) {
pageError.value = true;
errorMsg.value = e.toString();
} finally {
pageLoadding.value = false;
}
}
/// 检查订阅状态
void loadSubscribeStatus() async {
try {
var result = await userRequest.checkSubscribeStatus(
objId: comicId,
type: AppConstant.kTypeComic,
);
subscribeStatus.value = result;
if (subscribeStatus.value) {
UserService.instance.subscribedComicIds.add(comicId);
} else {
UserService.instance.subscribedComicIds.remove(comicId);
}
} catch (e) {
Log.logPrint(e);
}
}
/// 查看评论
void comment() {
AppNavigator.toComment(objId: comicId, type: AppConstant.kTypeComic);
}
/// 分享
void share() {
if (detail.value.id == 0) {
return;
}
Utils.share(
"http://m.idmzj.com/info/${detail.value.comicPy}.html",
content: detail.value.title,
);
}
/// 订阅
void subscribe() async {
var result = await (subscribeStatus.value
? UserService.instance
.cancelSubscribe([comicId], AppConstant.kTypeComic)
: UserService.instance.addSubscribe([comicId], AppConstant.kTypeComic));
if (result) {
subscribeStatus.value = !subscribeStatus.value;
}
}
/// 下载
void download() {
AppNavigator.toComicDownloadSelect(comicId);
}
/// 开始/继续阅读
void read() {
if (detail.value.volumes.isEmpty) {
SmartDialog.showToast("没有可阅读的章节");
return;
}
if (detail.value.volumes.first.chapters.isEmpty) {
SmartDialog.showToast("没有可阅读的章节");
return;
}
//查找记录
if (history.value != null && history.value!.chapterId != 0) {
ComicDetailVolume? volume;
ComicDetailChapterItem? chapter;
for (var volumeItem in detail.value.volumes) {
var chapterItem = volumeItem.chapters.firstWhereOrNull(
(x) => x.chapterId == history.value!.chapterId,
);
if (chapterItem != null) {
volume = volumeItem;
chapter = chapterItem;
break;
}
}
if (volume != null && chapter != null) {
var chapters = List<ComicDetailChapterItem>.from(volume.chapters);
//正序
chapters.sort((a, b) => a.chapterOrder.compareTo(b.chapterOrder));
AppNavigator.toComicReader(
comicId: comicId,
comicTitle: detail.value.title,
comicCover: detail.value.cover,
chapters: chapters,
chapter: chapter,
isLongComic: detail.value.isLong,
);
} else {
SmartDialog.showToast("未找到历史记录对应章节,将从头开始阅读");
readStart();
}
} else {
readStart();
}
}
void readStart() {
//从头开始
var volume = detail.value.volumes.first;
var chapters = List<ComicDetailChapterItem>.from(volume.chapters);
//正序
chapters.sort((a, b) => a.chapterOrder.compareTo(b.chapterOrder));
var chapter = chapters.first;
AppNavigator.toComicReader(
comicId: comicId,
comicCover: detail.value.cover,
comicTitle: detail.value.title,
chapters: chapters,
chapter: chapter,
isLongComic: detail.value.isLong,
);
}
void readChapter(ComicDetailVolume volume, ComicDetailChapterItem item) {
//禁止观看VIP章节
if (item.isVip) {
SmartDialog.showToast("请使用动漫之家官方APP观看VIP章节");
return;
}
var chapters = List<ComicDetailChapterItem>.from(volume.chapters);
//正序
chapters.sort((a, b) => a.chapterOrder.compareTo(b.chapterOrder));
AppNavigator.toComicReader(
comicId: comicId,
comicCover: detail.value.cover,
comicTitle: detail.value.title,
chapters: chapters,
chapter: item,
isLongComic: detail.value.isLong,
);
}
void related() async {
try {
SmartDialog.showLoading();
var data = await request.related(id: comicId);
SmartDialog.dismiss(status: SmartStatus.loading);
AppNavigator.showBottomSheet(
ComicDetailRelatedPage(data),
isScrollControlled: true,
);
} catch (e) {
SmartDialog.showToast(e.toString());
} finally {
SmartDialog.dismiss(status: SmartStatus.loading);
}
}
void toAuthorDetail(ComicDetailTag e) {
if (e.tagId == 0) {
//神隐漫画没有ID直接跳转搜索
AppNavigator.toComicSearch(keyword: e.tagName);
} else {
AppNavigator.toComicAuthorDetail(e.tagId);
}
}
void toCategoryDetail(ComicDetailTag e) {
if (e.tagId == 0) {
//神隐漫画没有ID直接跳转搜索
AppNavigator.toComicSearch(keyword: e.tagName);
} else {
AppNavigator.toComicCategoryDetail(e.tagId);
}
}
void favorite() {
if (detail.value.id == 0) {
return;
}
if (!DBService.instance.hasComicFavorited(comicId: comicId)) {
DBService.instance.putComicFavorite(
comicId: comicId,
title: detail.value.title,
cover: detail.value.cover,
);
favorited.value = true;
SmartDialog.showToast("已将漫画添加至本地收藏");
}
}
void cancelFavorite() {
DBService.instance.removeComicFavorite(comicId: comicId);
favorited.value = false;
SmartDialog.showToast("已从本地收藏删除漫画");
}
}

View File

@@ -0,0 +1,533 @@
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_color.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/modules/comic/detail/comic_detail_controller.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/status/app_error_widget.dart';
import 'package:flutter_dmzj/widgets/status/app_loadding_widget.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
class ComicDetailPage extends StatelessWidget {
final int id;
final ComicDetailControler controller;
ComicDetailPage(this.id, {super.key})
: controller = Get.put(
ComicDetailControler(id),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Obx(
() => Text(
controller.detail.value.title.isEmpty
? "漫画详情"
: controller.detail.value.title,
),
),
actions: [
Obx(
() => IconButton(
onPressed: controller.favorited.value
? controller.cancelFavorite
: controller.favorite,
icon: Icon(controller.favorited.value
? Remix.star_fill
: Remix.star_line),
),
),
IconButton(
onPressed: controller.share,
icon: const Icon(Icons.share),
),
],
),
body: Stack(
children: [
Obx(
() => Offstage(
offstage: controller.detail.value.id == 0,
child: EasyRefresh(
header: const MaterialHeader(),
onRefresh: controller.refreshDetail,
child: ListView(
padding: AppStyle.edgeInsetsA12,
children: [
_buildHeader(),
Obx(
() => Offstage(
offstage: controller.history.value == null,
child: Column(
children: [
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
"上次看到:${controller.history.value?.chapterName ?? ""}${controller.history.value?.page}",
style: Get.textTheme.titleSmall,
),
trailing: const Icon(Icons.chevron_right),
onTap: () {
controller.read();
},
),
Divider(
color: Colors.grey.withOpacity(.2),
height: 1.0,
),
],
),
),
),
_buildChapter(),
],
),
),
),
),
Obx(
() => Offstage(
offstage: !controller.pageLoadding.value,
child: const AppLoaddingWidget(),
),
),
Obx(
() => Offstage(
offstage: !controller.pageError.value,
child: AppErrorWidget(
errorMsg: controller.errorMsg.value,
onRefresh: () => controller.loadDetail(),
),
),
),
],
),
floatingActionButton: FloatingActionButton(
elevation: 2,
onPressed: controller.read,
child: const Icon(Icons.play_circle_outline_rounded),
),
bottomNavigationBar: BottomAppBar(
child: SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: Obx(
() => TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.subscribe,
icon: Icon(
controller.subscribeStatus.value
? Remix.heart_fill
: Remix.heart_line,
size: 20,
),
label: Text(controller.subscribeStatus.value ? "取消" : "订阅"),
),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.comment,
icon: const Icon(
Remix.chat_2_line,
size: 20,
),
label: const Text("评论"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.download,
icon: const Icon(
Remix.download_line,
size: 20,
),
label: const Text("下载"),
),
),
// Expanded(
// child: TextButton.icon(
// style: TextButton.styleFrom(
// textStyle: const TextStyle(fontSize: 14),
// ),
// onPressed: controller.related,
// icon: const Icon(
// Remix.links_line,
// size: 20,
// ),
// label: const Text("相关"),
// ),
// ),
],
),
),
),
);
}
Widget _buildHeader() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
//信息
Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
NetImage(
controller.detail.value.cover,
width: 120,
height: 160,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
controller.detail.value.title,
style: Get.textTheme.titleMedium,
),
AppStyle.vGap8,
_buildInfoItems(
iconData: Remix.user_smile_line,
children: controller.detail.value.authors
.map(
(e) => GestureDetector(
onTap: () => controller.toAuthorDetail(e),
child: Text(
e.tagName,
style: TextStyle(
fontSize: 14,
height: 1.2,
decoration: TextDecoration.underline,
color: Get.isDarkMode
? Colors.white
: AppColor.black333,
),
),
),
)
.toList(),
),
// _buildInfo(
// title: controller.detail.value.types
// .map((e) => e.tagName)
// .join("/"),
// iconData: Remix.hashtag,
// ),
_buildInfoItems(
iconData: Remix.hashtag,
children: controller.detail.value.types
.map(
(e) => GestureDetector(
onTap: () => controller.toCategoryDetail(e),
child: Text(
e.tagName,
style: TextStyle(
fontSize: 14,
height: 1.2,
decoration: TextDecoration.underline,
color: Get.isDarkMode
? Colors.white
: AppColor.black333,
),
),
),
)
.toList(),
),
// _buildInfo(
// title: "人气 ${controller.detail.value.hitNum}",
// iconData: Remix.fire_line,
// ),
// _buildInfo(
// title: "订阅 ${controller.detail.value.subscribeNum}",
// iconData: Remix.heart_line,
// ),
_buildInfo(
title:
"${Utils.formatTimestampToDate(controller.detail.value.lastUpdatetime)} ${controller.detail.value.status.map((e) => e.tagName).join("/")}",
iconData: Icons.schedule,
),
],
),
),
],
),
Obx(
() => Positioned(
right: 0,
top: 0,
child: Offstage(
offstage: !controller.detail.value.isVip,
child: Image.asset(
"assets/images/vip_comic.png",
width: 36,
height: 36,
),
),
),
),
],
),
AppStyle.vGap12,
GestureDetector(
onTap: () {
controller.expandDescription.value =
!controller.expandDescription.value;
},
child: Text(
controller.detail.value.description,
style: const TextStyle(
color: Colors.grey,
fontSize: 14,
),
maxLines: controller.expandDescription.value ? 999 : 2,
overflow: TextOverflow.ellipsis,
),
),
AppStyle.vGap12,
Divider(
color: Colors.grey.withOpacity(.2),
height: 1.0,
),
],
);
}
Widget _buildChapter() {
return Column(
children: controller.detail.value.volumes.isEmpty
? [
const Padding(
padding: AppStyle.edgeInsetsA24,
child: Text(
"(~ ̄▽ ̄)\n没有可阅读的章节\n漫画可能已下架或您没有阅读的权限",
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 14),
),
)
]
: controller.detail.value.volumes
.map(
(item) => Obx(
() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: AppStyle.edgeInsetsV8,
child: Row(
children: [
Expanded(
child: Text(
"${item.title}(共${item.chapters.length}话)",
style: Get.textTheme.titleSmall,
),
),
item.sortType.value == 1
? TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
item.sortType.value = 0;
item.sort();
},
icon: const Icon(
Remix.sort_asc,
size: 20,
),
label: const Text("升序"),
)
: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
item.sortType.value = 1;
item.sort();
},
icon: const Icon(
Remix.sort_desc,
size: 20,
),
label: const Text("倒序"),
),
],
),
),
LayoutBuilder(builder: (ctx, constraints) {
var count = constraints.maxWidth ~/ 160;
if (count < 3) count = 3;
return Obx(
() => MasonryGridView.count(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
itemCount:
(item.showMoreButton && !item.showAll.value)
? 15
: item.chapters.length,
itemBuilder: (_, i) {
if (item.showMoreButton &&
!item.showAll.value &&
i == 14) {
return Tooltip(
message: "展开全部章节",
child: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: Colors.grey,
textStyle: const TextStyle(fontSize: 14),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size.fromHeight(40),
),
onPressed: () {
item.showAll.value = true;
},
child: const Icon(Icons.arrow_drop_down),
),
);
}
return Tooltip(
message: item.chapters[i].chapterTitle,
child: Obx(
() => Stack(
children: [
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: item
.chapters[i].chapterId ==
controller
.history.value?.chapterId
? Theme.of(ctx).colorScheme.primary
: Get.textTheme.bodyMedium!.color,
textStyle:
const TextStyle(fontSize: 14),
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
minimumSize:
const Size.fromHeight(40),
),
onPressed: () {
controller.readChapter(
item, item.chapters[i]);
},
child: Text(
item.chapters[i].chapterTitle,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
),
Positioned(
left: -2,
top: 0,
child: Offstage(
offstage: !item.chapters[i].isVip,
child: Image.asset(
"assets/images/vip_chapter.png",
height: 16,
),
),
),
],
),
),
);
},
crossAxisCount: count,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
);
})
],
),
),
)
.toList(),
);
}
Widget _buildInfo({
required String title,
IconData iconData = Icons.tag,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
iconData,
color: Colors.grey,
size: 16,
),
AppStyle.hGap8,
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 14,
color: Get.isDarkMode ? Colors.white : AppColor.black333,
),
),
),
],
),
);
}
Widget _buildInfoItems({
required List<Widget> children,
IconData iconData = Icons.tag,
}) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
iconData,
color: Colors.grey,
size: 16,
),
AppStyle.hGap8,
Expanded(
child: Wrap(
spacing: 8,
children: children,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,164 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/comic/comic_related_model.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/shadow_card.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:get/get.dart';
class ComicDetailRelatedPage extends StatelessWidget {
final ComicRelatedModel related;
const ComicDetailRelatedPage(this.related, {super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
height: MediaQuery.of(context).size.height * 0.7,
child: Column(
children: [
ListTile(
title: const Text("作品相关"),
trailing: IconButton(
onPressed: () {
AppNavigator.closePage();
},
icon: const Icon(Icons.close),
),
contentPadding: AppStyle.edgeInsetsL12,
),
const Divider(
height: 1,
),
Expanded(
child: ListView(
padding: AppStyle.edgeInsetsA12.copyWith(top: 0),
children: [
...related.authorComics
.map(
(e) =>
buildCard("${e.authorName}的其他作品", e.data, onTap: () {
AppNavigator.toComicAuthorDetail(e.authorId);
}),
)
.toList(),
buildCard("同类题材作品", related.themeComics),
buildCard("轻小说", related.novels, isComic: false),
],
),
),
],
),
);
}
Widget buildCard(String title, List<ComicRelatedItemModel> list,
{Function()? onTap, bool isComic = true}) {
return Visibility(
visible: list.isNotEmpty,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: AppStyle.edgeInsetsV8,
child: Row(
children: [
Expanded(
child: Text(
title,
style: Get.textTheme.titleSmall,
),
),
Visibility(
visible: onTap != null,
child: IconButton(
onPressed: onTap,
icon: const Icon(Icons.chevron_right),
),
),
],
)),
LayoutBuilder(builder: (ctx, constraints) {
var count = constraints.maxWidth ~/ 160;
if (count < 3) count = 3;
return MasonryGridView.count(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
itemCount: list.length,
crossAxisCount: count,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
itemBuilder: (_, i) {
var item = list[i];
return ShadowCard(
onTap: () {
if (isComic) {
AppNavigator.toComicDetail(item.id);
} else {
AppNavigator.toNovelDetail(item.id);
}
},
radius: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
AspectRatio(
aspectRatio: 27 / 36,
child: NetImage(
item.cover,
borderRadius: 4,
),
),
Positioned(
right: 0,
top: 0,
child: Container(
decoration: BoxDecoration(
color: item.status == "连载中"
? Get.theme.colorScheme.primary
: Colors.orange,
borderRadius: const BorderRadius.only(
topRight: Radius.circular(4),
bottomLeft: Radius.circular(4),
),
),
padding: AppStyle.edgeInsetsH8
.copyWith(top: 2, bottom: 2),
child: Text(
item.status,
style: const TextStyle(
fontSize: 12,
color: Colors.white,
),
),
),
),
],
),
AppStyle.vGap4,
Padding(
padding: AppStyle.edgeInsetsA4,
child: Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
height: 1.2,
),
),
),
],
),
);
},
);
})
],
),
);
}
}