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,45 @@
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/comic/author_model.dart';
import 'package:flutter_dmzj/requests/comic_request.dart';
import 'package:flutter_dmzj/services/user_service.dart';
import 'package:get/get.dart';
class ComicAuthorDetailController extends BaseController {
final int id;
ComicAuthorDetailController(this.id);
final ComicRequest request = ComicRequest();
Rx<ComicAuthorModel?> detail = Rx<ComicAuthorModel?>(null);
@override
void onInit() {
loadData();
super.onInit();
}
void loadData() async {
try {
pageLoadding.value = true;
pageError.value = false;
var result = await request.authorDetail(id: id);
detail.value = result;
} catch (e) {
pageError.value = true;
errorMsg.value = e.toString();
} finally {
pageLoadding.value = false;
}
}
void subscribeAll() {
if (detail.value == null) {
return;
}
UserService.instance.addSubscribe(
detail.value!.data.map((e) => e.id).toList(),
AppConstant.kTypeComic,
);
}
}

View File

@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/comic/author_model.dart';
import 'package:flutter_dmzj/modules/comic/author_detail/author_detail_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/user_service.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:get/get.dart';
import 'package:remixicon/remixicon.dart';
class ComicAuthorDetailPage extends StatelessWidget {
final int id;
final ComicAuthorDetailController controller;
ComicAuthorDetailPage(this.id, {super.key})
: controller = Get.put(
ComicAuthorDetailController(id),
tag: "$id",
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: Obx(
() => Row(
mainAxisSize: MainAxisSize.min,
children: [
NetImage(
controller.detail.value?.cover ?? "",
borderRadius: 24,
width: 32,
height: 32,
),
AppStyle.hGap8,
Text(controller.detail.value?.nickname ?? "作者"),
],
),
),
actions: [
TextButton.icon(
onPressed: controller.subscribeAll,
icon: const Icon(Remix.heart_line),
label: const Text("全部订阅"),
),
],
),
body: Obx(
() => Stack(
children: [
Offstage(
offstage: controller.detail.value == null,
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: controller.detail.value?.data.length ?? 0,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (_, i) {
var item = controller.detail.value!.data[i];
return buildItem(item);
},
),
),
Obx(
() => Offstage(
offstage: !controller.pageLoadding.value,
child: const AppLoaddingWidget(),
),
),
Obx(
() => Offstage(
offstage: !controller.pageError.value,
child: AppErrorWidget(
errorMsg: controller.errorMsg.value,
onRefresh: () => controller.loadData(),
),
),
),
],
),
),
);
}
Widget buildItem(ComicAuthorComicModel item) {
return InkWell(
onTap: () {
AppNavigator.toComicDetail(item.id);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.cover,
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
AppStyle.vGap4,
Text(item.status,
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
Center(
child: Obx(
() => UserService.instance.subscribedComicIds.contains(item.id)
? IconButton(
icon: const Icon(Icons.favorite),
onPressed: () {
UserService.instance.cancelSubscribe(
[item.id],
AppConstant.kTypeComic,
);
},
)
: IconButton(
icon: const Icon(Icons.favorite_border),
onPressed: () {
UserService.instance.addSubscribe(
[item.id],
AppConstant.kTypeComic,
);
},
),
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/comic/category_comic_model.dart';
import 'package:flutter_dmzj/models/comic/category_filter_model.dart';
import 'package:flutter_dmzj/requests/comic_request.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class CategoryDetailController
extends BasePageController<ComicCategoryComicModel> {
final int id;
CategoryDetailController(this.id);
final ComicRequest request = ComicRequest();
RxList<ComicCategoryFilterModel> filters = RxList<ComicCategoryFilterModel>();
@override
void onInit() {
loadFilter();
super.onInit();
}
String getTitle() {
var items = filters.where((x) => x.selectId.value != 0 && x.title != "排序");
if (items.isEmpty) {
return "全部漫画";
} else {
return items
.map((e) =>
e.items.firstWhere((x) => x.tagId == e.selectId.value).tagName)
.join("-");
}
}
void loadFilter() async {
try {
filters.value = await request.categoryFilter();
for (var item in filters) {
var tag = item.items.firstWhereOrNull((x) => x.tagId == id);
if (tag != null) {
item.selectId.value = tag.tagId;
}
}
filters.insert(
0,
ComicCategoryFilterModel(
title: "排序",
items: [
ComicCategoryFilterItemModel(tagId: 1, tagName: "更新排序"),
ComicCategoryFilterItemModel(tagId: 2, tagName: "热度排序"),
],
)..selectId.value = 1,
);
filters.insert(
1,
ComicCategoryFilterModel(
title: "状态",
items: [
ComicCategoryFilterItemModel(tagId: 0, tagName: "全部"),
ComicCategoryFilterItemModel(tagId: 1, tagName: "连载中"),
ComicCategoryFilterItemModel(tagId: 2, tagName: "已完结"),
],
),
);
} catch (e) {
SmartDialog.showToast(e.toString());
}
}
@override
Future<List<ComicCategoryComicModel>> getData(int page, int pageSize) async {
if (filters.isEmpty) {
return await request.categoryComic(id: id, page: page);
} else {
var sort = filters.first.selectId.value;
var status = filters[1].selectId.value;
return await request.categoryComic(
id: filters.last.selectId.value,
sort: sort,
page: page,
status: status,
);
}
}
}

View File

@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/modules/comic/category_detail/category_detail_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_grid_view.dart';
import 'package:flutter_dmzj/widgets/shadow_card.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
class CategoryDetailPage extends StatelessWidget {
final int id;
final CategoryDetailController controller;
CategoryDetailPage(this.id, {super.key})
: controller = Get.put(
CategoryDetailController(id),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Obx(
() => Text(
controller.getTitle(),
),
),
actions: [
Builder(
builder: (BuildContext context) => IconButton(
icon: const Icon(Remix.filter_line),
onPressed: () {
Scaffold.of(context).openEndDrawer();
},
),
)
],
),
endDrawer: Drawer(
child: Obx(
() => SafeArea(
child: ListView.builder(
padding: AppStyle.edgeInsetsA12.copyWith(top: 12),
itemCount: controller.filters.length,
itemBuilder: (context, i) {
var item = controller.filters[i];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: AppStyle.edgeInsetsV12,
child: Text(
item.title,
style: Get.textTheme.titleMedium,
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: item.items
.map(
(x) => OutlinedButton(
style: OutlinedButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: x.tagId == item.selectId.value
? Theme.of(context).colorScheme.primary
: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: x.tagId == item.selectId.value
? Theme.of(context)
.colorScheme
.secondary
: Colors.transparent,
),
),
),
child: Text(
x.tagName,
style: const TextStyle(
fontSize: 14,
),
),
onPressed: () async {
item.selectId.value = x.tagId;
Navigator.pop(context);
controller.refreshData();
},
),
)
.toList(),
),
],
);
},
),
),
),
),
body: LayoutBuilder(builder: (context, constraints) {
var count = constraints.maxWidth ~/ 160;
if (count < 3) count = 3;
return PageGridView(
pageController: controller,
firstRefresh: true,
crossAxisCount: count,
padding: AppStyle.edgeInsetsA12,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
itemBuilder: (context, i) {
var item = controller.list[i];
return ShadowCard(
onTap: () {
AppNavigator.toComicDetail(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 == "连载中"
? Theme.of(context).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.edgeInsetsH4,
child: Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
height: 1.2,
),
),
),
AppStyle.vGap4,
Padding(
padding: AppStyle.edgeInsetsH4,
child: Text(
item.authors ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.grey,
fontSize: 12.0,
height: 1.2,
),
),
),
AppStyle.vGap4,
],
),
);
},
);
}),
);
}
}

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,
),
),
),
],
),
);
},
);
})
],
),
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/comic/category_item_model.dart';
import 'package:flutter_dmzj/requests/comic_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
class ComicCategoryController
extends BasePageController<ComicCategoryItemModel> {
final ComicRequest request = ComicRequest();
@override
Future<List<ComicCategoryItemModel>> getData(int page, int pageSize) async {
if (page > 1) {
return [];
}
var ls = await request.categores();
return ls;
}
void toDetail(ComicCategoryItemModel item) {
AppNavigator.toComicCategoryDetail(item.tagId);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/modules/comic/home/category/comic_category_controller.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_grid_view.dart';
import 'package:flutter_dmzj/widgets/shadow_card.dart';
import 'package:get/get.dart';
class ComicCategoryView extends StatelessWidget {
final ComicCategoryController controller;
ComicCategoryView({Key? key})
: controller = Get.put(ComicCategoryController()),
super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
var count = constraints.maxWidth ~/ 160;
if (count < 3) count = 3;
return KeepAliveWrapper(
child: PageGridView(
pageController: controller,
firstRefresh: true,
loadMore: false,
crossAxisCount: count,
padding: AppStyle.edgeInsetsH12.copyWith(bottom: 12),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
itemBuilder: (context, i) {
var item = controller.list[i];
return ShadowCard(
onTap: () {
controller.toDetail(item);
},
child: Column(
children: [
AspectRatio(
aspectRatio: 1.0,
child: NetImage(
item.cover,
borderRadius: 8,
),
),
Padding(
padding: AppStyle.edgeInsetsA8,
child: Text(
item.title,
textAlign: TextAlign.center,
style: const TextStyle(height: 1),
),
),
],
),
);
},
),
);
});
}
}

View File

@@ -0,0 +1,57 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/event_bus.dart';
import 'package:flutter_dmzj/modules/comic/home/category/comic_category_controller.dart';
//import 'package:flutter_dmzj/modules/comic/home/category/comic_category_controller.dart';
import 'package:flutter_dmzj/modules/comic/home/latest/comic_latest_controller.dart';
import 'package:flutter_dmzj/modules/comic/home/rank/comic_rank_controller.dart';
import 'package:flutter_dmzj/modules/comic/home/recommend/comic_recommend_controller.dart';
//import 'package:flutter_dmzj/modules/comic/home/special/comic_special_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:get/get.dart';
class ComicHomeController extends GetxController
with GetTickerProviderStateMixin {
late TabController tabController;
StreamSubscription<dynamic>? streamSubscription;
@override
void onInit() {
streamSubscription = EventBus.instance.listen(
EventBus.kBottomNavigationBarClicked,
(index) {
if (index == 0) {
refreshOrScrollTop();
}
},
);
tabController = TabController(length: 4, vsync: this);
super.onInit();
}
void refreshOrScrollTop() {
var tabIndex = tabController.index;
BasePageController? controller;
if (tabIndex == 0) {
controller = Get.find<ComicRecommendController>();
} else if (tabIndex == 1) {
controller = Get.find<ComicLatestController>();
} else if (tabIndex == 2) {
controller = Get.find<ComicCategoryController>();
} else if (tabIndex == 3) {
controller = Get.find<ComicRankController>();
}
// else if (tabIndex == 4) {
// controller = Get.find<ComicSpecialController>();
// }
controller?.scrollToTopOrRefresh();
}
void search() {
AppNavigator.toComicSearch();
}
}

View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/platform_utils.dart';
import 'package:flutter_dmzj/modules/comic/home/category/comic_category_view.dart';
import 'package:flutter_dmzj/modules/comic/home/comic_home_controller.dart';
import 'package:flutter_dmzj/modules/comic/home/latest/comic_latest_view.dart';
import 'package:flutter_dmzj/modules/comic/home/rank/comic_rank_view.dart';
import 'package:flutter_dmzj/modules/comic/home/recommend/comic_recommend_view.dart';
//import 'package:flutter_dmzj/modules/comic/home/special/comic_special_view.dart';
import 'package:flutter_dmzj/widgets/tab_appbar.dart';
import 'package:flutter_dmzj/widgets/windows_tab_page.dart';
import 'package:get/get.dart';
class ComicHomePage extends GetView<ComicHomeController> {
const ComicHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
if (PlatformUtils.isWindows) {
return WindowsTabPage(
tabs: [
WindowsTabItem(label: '推荐', body: ComicRecommendView()),
WindowsTabItem(label: '更新', body: ComicLatestView()),
WindowsTabItem(label: '分类', body: ComicCategoryView()),
WindowsTabItem(label: '排行', body: ComicRankView()),
],
headerAction: IconButton(
onPressed: controller.search,
icon: const Icon(Icons.search),
),
);
}
return Scaffold(
appBar: TabAppBar(
tabs: const [
Tab(text: "推荐"),
Tab(text: "更新"),
Tab(text: "分类"),
Tab(text: "排行"),
// Tab(text: "专题"),
],
controller: controller.tabController,
action: IconButton(
onPressed: controller.search,
icon: const Icon(
Icons.search,
),
),
),
body: TabBarView(
controller: controller.tabController,
children: [
ComicRecommendView(),
ComicLatestView(),
ComicCategoryView(),
ComicRankView(),
//ComicSpecialView(),
],
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/comic/update_item_model.dart';
import 'package:flutter_dmzj/requests/comic_request.dart';
import 'package:get/get.dart';
class ComicLatestController extends BasePageController<ComicUpdateItemModel> {
final ComicRequest request = ComicRequest();
Map types = {
"全部漫画": 100,
"原创漫画": 1,
"译制漫画": 0,
};
var type = 100.obs;
@override
Future<List<ComicUpdateItemModel>> getData(int page, int pageSize) async {
var ls = await request.latest(type: type.value, page: page);
return ls;
}
}

View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/comic/update_item_model.dart';
import 'package:flutter_dmzj/modules/comic/home/latest/comic_latest_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/user_service.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
class ComicLatestView extends StatelessWidget {
final ComicLatestController controller;
ComicLatestView({Key? key})
: controller = Get.put(ComicLatestController()),
super(key: key);
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: Column(
children: [
Row(
children: [
AppStyle.hGap12,
...controller.types.keys.map(
(e) => buildFilterButton(
title: e,
value: controller.types[e],
),
),
],
),
AppStyle.vGap12,
Expanded(
child: PageListView(
pageController: controller,
firstRefresh: true,
showPageLoadding: false,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
),
),
],
),
);
}
Widget buildItem(ComicUpdateItemModel item) {
return InkWell(
onTap: () {
AppNavigator.toComicDetail(item.comicId.toInt());
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.cover ?? '',
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text.rich(
TextSpan(children: [
const WidgetSpan(
child: Icon(
Icons.account_circle,
color: Colors.grey,
size: 18,
)),
const TextSpan(
text: " ",
),
TextSpan(
text: item.authors,
style:
const TextStyle(color: Colors.grey, fontSize: 14))
]),
),
const SizedBox(height: 2),
Text(item.types ?? '',
style: const TextStyle(color: Colors.grey, fontSize: 14)),
const SizedBox(height: 2),
Text(item.lastUpdateChapterName ?? '',
style: const TextStyle(color: Colors.grey, fontSize: 14)),
const SizedBox(height: 2),
Text("更新于${Utils.formatTimestamp(item.lastUpdatetime ?? 0)}",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
Center(
child: Obx(
() => UserService.instance.subscribedComicIds
.contains(item.comicId.toInt())
? IconButton(
icon: const Icon(Icons.favorite),
onPressed: () {
UserService.instance.cancelSubscribe(
[item.comicId.toInt()],
AppConstant.kTypeComic,
);
},
)
: IconButton(
icon: const Icon(Icons.favorite_border),
onPressed: () {
UserService.instance.addSubscribe(
[item.comicId.toInt()],
AppConstant.kTypeComic,
);
},
),
),
)
],
),
),
);
}
Widget buildFilterButton({required String title, required int value}) {
return Container(
height: 32,
margin: AppStyle.edgeInsetsR8,
child: Obx(
() => TextButton(
style: TextButton.styleFrom(
foregroundColor:
controller.type.value == value ? Get.theme.colorScheme.primary : Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: AppStyle.radius24,
side: BorderSide(
color:
controller.type.value == value ? Get.theme.colorScheme.primary : Colors.grey,
),
),
textStyle: const TextStyle(fontSize: 14),
padding: AppStyle.edgeInsetsH16,
),
onPressed: () {
if (controller.type.value == value) {
return;
}
controller.type.value = value;
controller.refreshData();
},
child: Text(title),
),
),
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/comic/rank_item_model.dart';
import 'package:flutter_dmzj/requests/comic_request.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class ComicRankController extends BasePageController<ComicRankListItemModel> {
final ComicRequest request = ComicRequest();
RxMap<int, String> tags = {
0: "全部分类",
}.obs;
var tag = 0.obs;
Map<int, String> byTimes = {
0: "日排行",
1: "周排行",
2: "月排行",
3: "总排行",
};
var byTime = 3.obs;
Map<int, String> rankTypes = {
0: "人气排行",
1: "吐槽排行",
2: "订阅排行",
};
var rankType = 0.obs;
@override
void onInit() {
loadFilter();
super.onInit();
}
void loadFilter() async {
try {
tags.value = await request.rankFilter();
} catch (e) {
SmartDialog.showToast(e.toString());
}
}
@override
Future<List<ComicRankListItemModel>> getData(int page, int pageSize) async {
var ls = await request.rank(
tagId: tag.value,
byTime: byTime.value,
rankType: rankType.value,
page: page,
);
return ls;
}
}

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/comic/rank_item_model.dart';
import 'package:flutter_dmzj/modules/comic/home/rank/comic_rank_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/user_service.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
class ComicRankView extends StatelessWidget {
final ComicRankController controller;
ComicRankView({Key? key})
: controller = Get.put(ComicRankController()),
super(key: key);
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: Column(
children: [
Obx(
() => Row(
children: [
buildFilter(
// ignore: invalid_use_of_protected_member
types: controller.tags.value,
value: controller.tag.value,
onSelected: (e) {
controller.tag.value = e;
controller.refreshData();
},
),
buildFilter(
types: controller.byTimes,
value: controller.byTime.value,
onSelected: (e) {
controller.byTime.value = e;
controller.refreshData();
},
),
buildFilter(
types: controller.rankTypes,
value: controller.rankType.value,
onSelected: (e) {
controller.rankType.value = e;
controller.refreshData();
},
),
],
),
),
AppStyle.vGap12,
Expanded(
child: PageListView(
pageController: controller,
firstRefresh: true,
showPageLoadding: false,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
),
),
],
),
);
}
Widget buildFilter({
required Map<int, String> types,
required int value,
required Function(int) onSelected,
}) {
return Expanded(
child: PopupMenuButton<int>(
onSelected: onSelected,
itemBuilder: (c) => types.keys
.map(
(k) => CheckedPopupMenuItem<int>(
value: k,
checked: k == value,
child: Text(types[k] ?? ""),
),
)
.toList(),
child: SizedBox(
height: 36,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
types[value] ?? "",
),
const Icon(
Icons.arrow_drop_down,
color: Colors.grey,
)
],
),
),
),
);
}
Widget buildItem(ComicRankListItemModel item) {
return InkWell(
onTap: () {
AppNavigator.toComicDetail(item.comicId.toInt());
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.cover ?? '',
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text.rich(
TextSpan(children: [
const WidgetSpan(
child: Icon(
Icons.account_circle,
color: Colors.grey,
size: 18,
)),
const TextSpan(
text: " ",
),
TextSpan(
text: item.authors,
style:
const TextStyle(color: Colors.grey, fontSize: 14))
]),
),
const SizedBox(height: 2),
Text(item.types ?? '-',
style: const TextStyle(color: Colors.grey, fontSize: 14)),
const SizedBox(height: 2),
Text(item.lastUpdateChapterName ?? '-',
style: const TextStyle(color: Colors.grey, fontSize: 14)),
const SizedBox(height: 2),
Text("更新于${Utils.formatTimestamp(item.lastUpdatetime ?? 0)}",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
Center(
child: Obx(
() => UserService.instance.subscribedComicIds
.contains(item.comicId.toInt())
? IconButton(
icon: const Icon(Icons.favorite),
onPressed: () {
UserService.instance.cancelSubscribe(
[item.comicId.toInt()],
AppConstant.kTypeComic,
);
},
)
: IconButton(
icon: const Icon(Icons.favorite_border),
onPressed: () {
UserService.instance.addSubscribe(
[item.comicId.toInt()],
AppConstant.kTypeComic,
);
},
),
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,188 @@
import 'dart:async';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:flutter_dmzj/models/comic/recommend_model.dart';
import 'package:flutter_dmzj/modules/comic/home/comic_home_controller.dart';
import 'package:flutter_dmzj/requests/comic_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/user_service.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher_string.dart';
class ComicRecommendController extends BasePageController<ComicRecommendModel> {
final ComicRequest request = ComicRequest();
StreamSubscription<dynamic>? subLogin;
StreamSubscription<dynamic>? subLogout;
@override
void onInit() {
subLogin = UserService.loginedStream.listen((event) {
loadSubscribe();
});
subLogout = UserService.logoutStream.listen((event) {
list.removeWhere((x) => x.categoryId == 49);
});
super.onInit();
}
@override
Future<List<ComicRecommendModel>> getData(int page, int pageSize) async {
var ls = await request.recommend();
// ls.insert(
// ls.length > 3 ? 2 : 1,
// ComicRecommendModel(
// categoryId: 50,
// title: "随便看看",
// sort: 6,
// data: [],
// ),
// );
//loadRandom();
if (UserService.instance.logined.value) {
loadSubscribe();
}
return ls;
}
// /// 加载随机漫画
// Future<void> loadRandom() async {
// try {
// var result = await request.refreshRecommend(50);
// var index = list.indexWhere((x) => x.categoryId == 50);
// if (index != -1) {
// list[index].data = result;
// } else {
// list.insert(
// list.length > 3 ? 2 : 1,
// result,
// );
// }
// } catch (e) {
// Log.logPrint(e);
// }
// }
/// 刷新国漫
Future<void> refreshGuoman() async {
try {
var index = list.indexWhere((x) => x.categoryId == 111);
var result =
await request.refreshRecommend(111, size: 6, page: list[index].page);
if (index != -1) {
list[index].data = result;
list[index].page++;
list.refresh();
}
} catch (e) {
Log.logPrint(e);
}
}
/// 刷新近期必看
Future<void> refreshRecommend() async {
try {
var index = list.indexWhere((x) => x.categoryId == 110);
var result = await request.refreshRecommend(110, page: list[index].page);
if (index != -1) {
list[index].data = result;
list[index].page++;
list.refresh();
}
} catch (e) {
Log.logPrint(e);
}
}
/// 加载订阅
void loadSubscribe() async {
try {
var result = await request.recommendSubscribe();
var index = list.indexWhere((x) => x.categoryId == 49);
if (index != -1) {
list[index] = result;
} else {
list.insert(1, result);
}
} catch (e) {
Log.logPrint(e);
}
}
/// 刷新热门连载
Future<void> refreshHot() async {
try {
var index = list.indexWhere((x) => x.categoryId == 112);
var result =
await request.refreshRecommend(112, page: list[index].page, size: 6);
if (index != -1) {
list[index].data = result;
list[index].page++;
list.refresh();
}
} catch (e) {
Log.logPrint(e);
}
}
void openDetail(ComicRecommendItemModel item) {
//漫画=1
if (item.type == null || item.type == 1) {
AppNavigator.toComicDetail(
item.objId ?? item.id ?? 0,
);
} else if (item.type == 5) {
//专题=5
AppNavigator.toSpecialDetail(
item.objId ?? 0,
);
} else if (item.type == 6) {
//网页=6
AppNavigator.toWebView(item.url ?? "");
} else if (item.type == 7) {
//新闻=7
AppNavigator.toNewsDetail(
url: item.url ?? "",
newsId: item.objId ?? 0,
title: item.title,
);
} else if (item.type == 8) {
//作者=8
AppNavigator.toComicAuthorDetail(item.objId ?? 0);
} else if (item.type == 13) {
//社区=13
//直接跳转至网页
launchUrlString(
"http://m.forum.idmzj.com/thread/detail?tid=${item.objId}",
mode: LaunchMode.externalApplication,
);
// AppNavigator.toWebView(
// "http://m.forum.dmzj.com/thread/detail?tid=${item.objId}",
// );
} else {
SmartDialog.showToast("未知类型,无法跳转");
}
}
void toSpecial() {
var homeController = Get.find<ComicHomeController>();
homeController.tabController.animateTo(4);
}
void toMySubscribe() {
AppNavigator.toUserSubscribe();
}
@override
void onClose() {
subLogin?.cancel();
subLogout?.cancel();
super.onClose();
}
}

View File

@@ -0,0 +1,372 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/comic/recommend_model.dart';
import 'package:flutter_dmzj/modules/comic/home/recommend/comic_recommend_controller.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:flutter_dmzj/widgets/refresh_until_widget.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:flutter_swiper_view/flutter_swiper_view.dart';
import 'package:get/get.dart';
class ComicRecommendView extends StatelessWidget {
final ComicRecommendController controller;
ComicRecommendView({Key? key})
: controller = Get.put(ComicRecommendController()),
super(key: key);
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: PageListView(
pageController: controller,
padding: AppStyle.edgeInsetsH12,
firstRefresh: true,
loadMore: false,
showPageLoadding: true,
itemBuilder: (context, i) {
var item = controller.list[i];
//大图推荐
if (item.categoryId == 109) {
return buildBanner(item);
}
//随便看看
// if (item.categoryId == 50) {
// return buildCard(
// context,
// child: buildTreeColumnGridView(item.data),
// title: item.title.toString(),
// action: buildRefresh(onRefresh: controller.loadRandom),
// );
// }
//我的订阅
if (item.categoryId == 49) {
return buildCard(
context,
child: buildTreeColumnGridView(item.data),
title: item.title.toString(),
action: buildShowMore(onTap: controller.toMySubscribe),
);
}
//近期必看\国漫\热门连载\最新上架
if (item.categoryId == 110 ||
item.categoryId == 111 ||
item.categoryId == 112 ||
item.categoryId == 56) {
Widget? action;
//刷新国漫
if (item.categoryId == 110) {
action = buildRefresh(onRefresh: controller.refreshRecommend);
}
if (item.categoryId == 111) {
action = buildRefresh(onRefresh: controller.refreshGuoman);
}
if (item.categoryId == 112) {
action = buildRefresh(onRefresh: controller.refreshHot);
}
return buildCard(
context,
child: buildTreeColumnGridView(item.data),
title: item.title.toString(),
action: action,
);
}
//火热专题\美漫大事件\条漫
if (item.categoryId == 48 ||
item.categoryId == 53 ||
item.categoryId == 55) {
return buildCard(
context,
child: buildTwoColumnGridView(item.data),
title: item.title.toString(),
action: item.categoryId == 48
? buildShowMore(onTap: controller.toSpecial)
: null,
);
}
//大师
if (item.categoryId == 51) {
return buildCard(
context,
child: buildAuthorGridView(item.data),
title: item.title.toString(),
);
}
return buildCard(
context,
child: buildTreeColumnGridView(item.data),
title: item.title.toString(),
);
},
),
);
}
Widget buildCard(
BuildContext context, {
required Widget child,
required String title,
Widget? action,
}) {
return Padding(
padding: AppStyle.edgeInsetsB8,
child: Container(
decoration: BoxDecoration(
borderRadius: AppStyle.radius8,
),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16, height: 1.0, fontWeight: FontWeight.bold),
),
),
SizedBox(
height: 48,
child: action,
),
],
),
child,
],
),
),
);
}
Widget buildShowMore({required Function() onTap}) {
return GestureDetector(
onTap: onTap,
child: const Row(
children: [
Text(
"查看更多",
style: TextStyle(fontSize: 14, color: Colors.grey),
),
Icon(Icons.chevron_right, size: 18, color: Colors.grey),
],
),
);
}
Widget buildRefresh({required Future Function() onRefresh}) {
return RefreshUntilWidget(onRefresh: onRefresh, text: "换一批");
}
Widget buildBanner(ComicRecommendModel item) {
return Padding(
padding: AppStyle.edgeInsetsB12,
child: ClipRRect(
borderRadius: AppStyle.radius4,
child: AspectRatio(
aspectRatio: 75 / 40,
child: Swiper(
itemWidth: 750,
itemHeight: 400,
autoplay: true,
itemCount: item.data.length,
itemBuilder: (_, i) => NetImage(
item.data[i].cover,
width: 750,
height: 400,
),
onTap: (i) {
controller.openDetail(item.data[i]);
},
pagination: SwiperCustomPagination(
builder: (BuildContext context, SwiperPluginConfig config) {
return Align(
alignment: Alignment.bottomCenter,
child: Container(
padding: const EdgeInsets.only(
left: 8,
right: 12,
top: 4,
bottom: 4,
),
//color: Colors.black12,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black38,
Colors.transparent,
],
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
item.data[config.activeIndex].title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14, color: Colors.white),
),
),
AppStyle.hGap8,
PageIndicator(
controller: config.pageController!,
count: config.itemCount,
size: 10,
layout: PageIndicatorLayout.SCALE,
),
],
),
),
);
},
),
),
),
),
);
}
Widget buildTreeColumnGridView(List<ComicRecommendItemModel> items) {
return MasonryGridView.count(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
itemCount: items.length,
itemBuilder: (_, i) {
var item = items[i];
return InkWell(
onTap: () => controller.openDetail(item),
borderRadius: AppStyle.radius4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: AppStyle.radius4,
child: AspectRatio(
aspectRatio: 27 / 36,
child: NetImage(
item.cover,
width: 270,
height: 360,
),
),
),
AppStyle.vGap8,
Text(
item.title,
maxLines: 1,
style: const TextStyle(height: 1.2),
overflow: TextOverflow.ellipsis,
),
Text(
item.subTitle ?? item.status ?? '',
maxLines: 1,
style: const TextStyle(
height: 1.2,
fontSize: 12,
color: Colors.grey,
overflow: TextOverflow.ellipsis,
),
),
AppStyle.vGap8,
],
),
);
},
);
}
Widget buildAuthorGridView(List<ComicRecommendItemModel> items) {
return MasonryGridView.count(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
itemCount: items.length,
itemBuilder: (_, i) {
var item = items[i];
return InkWell(
onTap: () => controller.openDetail(item),
borderRadius: AppStyle.radius8,
child: Padding(
padding: AppStyle.edgeInsetsA12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
NetImage(
item.cover,
width: 56,
height: 56,
borderRadius: 32,
),
Padding(
padding: AppStyle.edgeInsetsV8,
child: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(height: 1.2, fontSize: 12),
),
),
],
),
),
);
},
);
}
Widget buildTwoColumnGridView(List<ComicRecommendItemModel> items) {
return MasonryGridView.count(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
itemCount: items.length,
itemBuilder: (_, i) {
var item = items[i];
return InkWell(
onTap: () => controller.openDetail(item),
borderRadius: AppStyle.radius4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: AppStyle.radius4,
child: AspectRatio(
aspectRatio: 32 / 17,
child: NetImage(
item.cover,
width: 320,
height: 170,
),
),
),
Padding(
padding: AppStyle.edgeInsetsV8,
child: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(height: 1.2),
),
),
],
),
);
},
);
}
}

View File

@@ -0,0 +1,23 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/comic/special_model.dart';
import 'package:flutter_dmzj/requests/comic_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
class ComicSpecialController extends BasePageController<ComicSpecialModel> {
final ComicRequest request = ComicRequest();
@override
Future<List<ComicSpecialModel>> getData(int page, int pageSize) async {
var ls = await request.special(page: page - 1);
return ls;
}
void toDetail(ComicSpecialModel item) {
if (item.pageType == 3) {
AppNavigator.toSpecialDetail(item.id);
} else {
AppNavigator.toWebView(item.pageUrl);
}
}
}

View File

@@ -0,0 +1,65 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/modules/comic/home/special/comic_special_controller.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:flutter_dmzj/widgets/shadow_card.dart';
import 'package:get/get.dart';
class ComicSpecialView extends StatelessWidget {
final ComicSpecialController controller;
ComicSpecialView({Key? key})
: controller = Get.put(ComicSpecialController()),
super(key: key);
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: PageListView(
pageController: controller,
firstRefresh: true,
showPageLoadding: false,
padding: AppStyle.edgeInsetsA12.copyWith(top: 0),
separatorBuilder: (context, i) => AppStyle.vGap12,
itemBuilder: (context, i) {
var item = controller.list[i];
return ShadowCard(
onTap: () {
controller.toDetail(item);
},
radius: 8,
child: Column(
children: [
AspectRatio(
aspectRatio: 710 / 284,
child: NetImage(
item.smallCover,
width: 710,
height: 354,
),
),
Padding(
padding: AppStyle.edgeInsetsA8,
child: Row(
children: [
Expanded(child: Text(item.title)),
Text(
Utils.formatTimestampToDate(item.createTime),
style: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
),
],
),
),
],
),
);
},
),
);
}
}

View File

@@ -0,0 +1,843 @@
import 'dart:async';
import 'dart:io';
import 'package:battery_plus/battery_plus.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/app_error.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/services/app_settings_service.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:flutter_dmzj/models/comic/chapter_info.dart';
import 'package:flutter_dmzj/models/comic/detail_info.dart';
import 'package:flutter_dmzj/models/comic/view_point_model.dart';
import 'package:flutter_dmzj/requests/comic_request.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';
import 'package:preload_page_view/preload_page_view.dart';
import 'package:remixicon/remixicon.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class ComicReaderController extends BaseController {
/// 是否为条漫
final bool isLongComic;
final int comicId;
final String comicTitle;
final String comicCover;
final ComicDetailChapterItem chapter;
final List<ComicDetailChapterItem> chapters;
final FocusNode focusNode = FocusNode();
final ComicRequest request = ComicRequest();
ComicReaderController({
required this.comicId,
required this.comicTitle,
required this.chapters,
required this.chapter,
required this.comicCover,
required this.isLongComic,
}) {
chapterIndex.value = chapters.indexOf(chapter);
}
/// APP设置控制器
final settings = AppSettingsService.instance;
/// 预加载控制器
final PreloadPageController preloadPageController = PreloadPageController();
/// 上下模式控制器
final ItemScrollController itemScrollController = ItemScrollController();
/// 监听上下滚动
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
/// 章节详情
Rx<ComicChapterDetail> detail =
Rx<ComicChapterDetail>(ComicChapterDetail.empty());
/// 连接信息监听
StreamSubscription<ConnectivityResult>? connectivitySubscription;
/// 电量信息监听
StreamSubscription<BatteryState>? batterySubscription;
/// 当处于放大图片时,锁定滑动手势
var lockSwipe = false.obs;
/// 当前章节索引
var chapterIndex = 0.obs;
/// 当前页面
var currentIndex = 0.obs;
/// 初始化
var initialIndex = 0;
/// 是否显示控制器
var showControls = false.obs;
/// 阅读方向
var direction = 0.obs;
/// 左手模式
bool get leftHandMode => settings.comicReaderLeftHandMode.value;
/// 翻页动画
bool get pageAnimation => settings.comicReaderPageAnimation.value;
/// 观点、吐槽
RxList<ComicViewPointModel> viewPoints = RxList<ComicViewPointModel>();
/// 连接类型
Rx<ConnectivityResult> connectivityType =
Rx<ConnectivityResult>(ConnectivityResult.other);
/// 电量信息
Rx<int> batteryLevel = 0.obs;
/// 显示电量
RxBool showBattery = true.obs;
@override
void onInit() {
initConnectivity();
initBattery();
if (isLongComic) {
direction.value = ReaderDirection.kUpToDown;
} else {
direction.value = settings.comicReaderDirection.value;
}
if (settings.comicReaderFullScreen.value) {
setFull();
}
itemPositionsListener.itemPositions.addListener(updateItemPosition);
loadDetail();
super.onInit();
}
/// 初始化电池信息
void initBattery() async {
try {
//没有电池的Mac似乎会闪退,暂时屏蔽Mac
//https://github.com/xiaoyaocz/flutter_dmzj/discussions/146
if (Platform.isMacOS) {
showBattery.value = false;
return;
}
var battery = Battery();
batterySubscription =
battery.onBatteryStateChanged.listen((BatteryState state) async {
try {
var level = await battery.batteryLevel;
batteryLevel.value = level;
showBattery.value = true;
} catch (e) {
showBattery.value = false;
}
});
batteryLevel.value = await battery.batteryLevel;
showBattery.value = true;
} catch (e) {
showBattery.value = false;
}
}
/// 初始化连接状态
void initConnectivity() async {
var connectivity = Connectivity();
connectivitySubscription =
connectivity.onConnectivityChanged.listen((ConnectivityResult result) {
//提醒
if (connectivityType.value != result &&
result == ConnectivityResult.mobile) {
SmartDialog.showToast("您已切换至数据网络,请注意流量消耗");
}
connectivityType.value = result;
});
connectivityType.value = await connectivity.checkConnectivity();
}
@override
void onClose() {
focusNode.dispose();
connectivitySubscription?.cancel();
batterySubscription?.cancel();
exitFull();
itemPositionsListener.itemPositions.removeListener(updateItemPosition);
uploadHistory();
super.onClose();
}
void updateItemPosition() {
var items = itemPositionsListener.itemPositions.value;
if (items.isEmpty) {
return;
}
var index = items
.where((ItemPosition position) => position.itemTrailingEdge > 0)
.reduce((ItemPosition min, ItemPosition position) =>
position.itemTrailingEdge < min.itemTrailingEdge ? position : min)
.index;
currentIndex.value = index;
}
/// 加载信息
void loadDetail() async {
try {
pageLoadding.value = true;
pageError.value = false;
detail.value = ComicChapterDetail.empty();
var chapterId = chapters[chapterIndex.value].chapterId;
if (chapters[chapterIndex.value].isVip) {
//禁止观看VIP章节
throw AppError("请使用动漫之家官方APP观看VIP章节");
}
//loadViewPoints();
var result = await request.chapterDetail(
comicId: comicId,
chapterId: chapterId,
useHD: AppSettingsService.instance.comicReaderHD.value,
);
var his = DBService.instance.getComicHistory(comicId);
if (his != null && his.chapterId == chapterId && his.page != 0) {
var hisIndex = (his.page - 1) < 0 ? 0 : his.page - 1;
if (hisIndex >= result.pageUrls.length - 1) {
hisIndex = 0;
}
initialIndex = hisIndex;
} else {
initialIndex = 0;
}
currentIndex.value = initialIndex;
// if (settings.comicReaderShowViewPoint.value) {
// result.pageUrls.add("TC");
// }
detail.value = result;
Future.delayed(const Duration(milliseconds: 100), () {
jumpToPage(initialIndex);
});
//上传记录
uploadHistory();
} catch (e) {
pageError.value = true;
errorMsg.value = e.toString();
setShowControls();
} finally {
pageLoadding.value = false;
}
}
/// 加载吐槽、观点
void loadViewPoints() async {
try {
viewPoints.clear();
var result = await request.viewPoints(
comicId: comicId,
chapterId: chapters[chapterIndex.value].chapterId,
);
result.sort((a, b) => b.num.value.compareTo(a.num.value));
viewPoints.value = result;
} catch (e) {
//SmartDialog.showToast("读取吐槽失败");
Log.logPrint(e.toString());
}
}
/// 设置显示/隐藏控制按钮
void setShowControls() {
if (settings.comicReaderFullScreen.value) {
if (showControls.value) {
setFull();
} else {
setFullEdge();
}
}
Future.delayed(const Duration(milliseconds: 100), () {
showControls.value = !showControls.value;
});
}
/// 显示目录
void showMenu() async {
setShowControls();
showModalBottomSheet(
context: Get.context!,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
constraints: const BoxConstraints(
maxWidth: 500,
),
builder: (context) => Column(
children: [
ListTile(
title: Text("目录(${chapters.length})"),
trailing: IconButton(
onPressed: Get.back,
icon: const Icon(Icons.close),
),
contentPadding: AppStyle.edgeInsetsL12,
),
Divider(
height: 1.0,
color: Theme.of(context).dividerColor.withOpacity(.2),
),
Expanded(
child: ScrollablePositionedList.separated(
initialScrollIndex: chapterIndex.value,
itemCount: chapters.length,
separatorBuilder: (_, i) => Divider(
indent: 12,
endIndent: 12,
height: 1.0,
color: Theme.of(context).dividerColor.withOpacity(.2),
),
itemBuilder: (_, i) {
var item = chapters[i];
return ListTile(
selected: i == chapterIndex.value,
selectedTileColor:
Theme.of(context).colorScheme.secondaryContainer,
selectedColor:
Theme.of(context).colorScheme.onSecondaryContainer,
title: Text(item.chapterTitle),
subtitle: item.updateTime != 0
? Text(
"更新于${Utils.formatTimestampToDate(item.updateTime)}")
: null,
onTap: () {
chapterIndex.value = i;
loadDetail();
Get.back();
},
);
},
),
),
],
),
routeSettings: const RouteSettings(name: "/modalBottomSheet"),
);
}
/// 下一章
void nextChapter() {
if (chapterIndex.value == chapters.length - 1) {
SmartDialog.showToast("后面没有了");
return;
}
chapterIndex.value += 1;
loadDetail();
}
/// 上一章
void forwardChapter() {
if (chapterIndex.value == 0) {
SmartDialog.showToast("前面没有了");
return;
}
chapterIndex.value -= 1;
loadDetail();
}
/// 下一页
void nextPage() {
var value = currentIndex.value;
Log.w("下一页$value");
var max = detail.value.pageUrls.length;
if (value >= max - 1) {
nextChapter();
} else {
jumpToPage(value + 1, anime: true);
}
}
/// 上一页
void forwardPage() {
var value = currentIndex.value;
Log.w("上一页$value");
if (value == 0) {
forwardChapter();
} else {
jumpToPage(value - 1, anime: true);
}
}
/// 跳转页数
void jumpToPage(int page, {bool anime = false}) {
//竖向
if (direction.value == ReaderDirection.kUpToDown) {
itemScrollController.jumpTo(index: page);
} else {
anime && pageAnimation
? preloadPageController.animateToPage(page,
duration: const Duration(milliseconds: 200), curve: Curves.linear)
: preloadPageController.jumpToPage(page);
}
}
/// 查看吐槽
void showComment() {
setShowControls();
TextEditingController tucaoController = TextEditingController();
showModalBottomSheet(
context: Get.context!,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
constraints: const BoxConstraints(
maxWidth: 500,
),
isScrollControlled: true,
useSafeArea: true,
builder: (context) => Column(
children: [
ListTile(
title: Text("吐槽(${viewPoints.length})"),
trailing: IconButton(
onPressed: Get.back,
icon: const Icon(Icons.close),
),
contentPadding: AppStyle.edgeInsetsL12,
),
Divider(
height: 1.0,
color: Theme.of(context).dividerColor.withOpacity(.2),
),
Expanded(
child: EasyRefresh(
header: const MaterialHeader(),
onRefresh: () async {
loadViewPoints();
},
child: Obx(
() => settings.comicReaderOldViewPoint.value
? SingleChildScrollView(
child: Padding(
padding: AppStyle.edgeInsetsA12,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: viewPoints.map<Widget>((item) {
return InkWell(
onTap: () {
likeViewPoint(item);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.primary,
borderRadius: AppStyle.radius8,
),
child: Text(
item.content,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onPrimary),
),
),
);
}).toList(),
),
),
)
: ListView.separated(
padding: EdgeInsets.zero,
itemCount: viewPoints.length,
separatorBuilder: (_, i) => Divider(
indent: 12,
endIndent: 12,
height: 1.0,
color: Theme.of(context).dividerColor.withOpacity(.2),
),
itemBuilder: (_, i) {
var item = viewPoints[i];
return Padding(
padding: AppStyle.edgeInsetsA12
.copyWith(top: 8, bottom: 8),
child: Row(
children: [
Expanded(
child: Text(
item.content,
style: const TextStyle(
fontSize: 15,
),
),
),
AppStyle.hGap12,
TextButton.icon(
style: TextButton.styleFrom(
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
likeViewPoint(item);
},
icon: const Icon(
Remix.thumb_up_line,
size: 16,
),
label: Obx(() => Text("${item.num.value}")),
),
],
),
);
},
),
),
),
),
Container(
padding: AppStyle.edgeInsetsA8.copyWith(
bottom: 8 + AppStyle.bottomBarHeight,
),
child: TextField(
controller: tucaoController,
onSubmitted: (e) {
sendViewPoint(e);
},
decoration: InputDecoration(
hintText: "发表吐槽",
contentPadding: AppStyle.edgeInsetsH12,
border: const OutlineInputBorder(),
suffixIcon: TextButton(
onPressed: () {
sendViewPoint(tucaoController.text);
},
child: const Text("发布"),
),
),
),
),
],
),
routeSettings: const RouteSettings(name: "/modalBottomSheet"),
);
}
/// 显示设置
void showSettings() {
setShowControls();
showModalBottomSheet(
context: Get.context!,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
constraints: const BoxConstraints(
maxWidth: 500,
),
builder: (context) => Column(
children: [
ListTile(
title: const Text("设置"),
trailing: IconButton(
onPressed: Get.back,
icon: const Icon(Icons.close),
),
contentPadding: AppStyle.edgeInsetsL12,
),
Expanded(
child: Obx(
() => ListView(
padding: AppStyle.edgeInsetsA12,
children: [
buildBGItem(
context,
child: SwitchListTile(
value: settings.comicReaderHD.value,
onChanged: (e) {
settings.setComicReaderHD(e);
loadDetail();
},
title: const Text("优先加载高清图"),
subtitle: const Text("部分单行本可能未分页"),
),
),
//AppStyle.vGap12,
Visibility(
//条漫不允许修改阅读方向
visible: !isLongComic,
child: Padding(
padding: AppStyle.edgeInsetsT12,
child: buildBGItem(
context,
child: ListTile(
title: const Text("阅读方向"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
buildSelectedButton(
onTap: () {
setDirection(ReaderDirection.kLeftToRight);
},
selected: settings.comicReaderDirection.value ==
ReaderDirection.kLeftToRight,
child: const Icon(Remix.arrow_right_line),
),
AppStyle.hGap8,
buildSelectedButton(
onTap: () {
setDirection(ReaderDirection.kRightToLeft);
},
selected: settings.comicReaderDirection.value ==
ReaderDirection.kRightToLeft,
child: const Icon(Remix.arrow_left_line),
),
AppStyle.hGap8,
buildSelectedButton(
onTap: () {
setDirection(ReaderDirection.kUpToDown);
},
selected: settings.comicReaderDirection.value ==
ReaderDirection.kUpToDown,
child: const Icon(Remix.arrow_down_line),
)
],
),
),
),
),
),
AppStyle.vGap12,
buildBGItem(
context,
child: SwitchListTile(
value: settings.comicReaderLeftHandMode.value,
onChanged: (e) {
settings.setComicReaderLeftHandMode(e);
},
title: const Text("操作反转"),
subtitle: const Text("点击左侧下一页,右侧上一页"),
),
),
AppStyle.vGap12,
buildBGItem(
context,
child: SwitchListTile(
value: settings.comicReaderFullScreen.value,
onChanged: (e) {
settings.setComicReaderFullScreen(e);
if (e) {
setFull();
} else {
exitFull();
}
},
title: const Text("全屏阅读"),
),
),
AppStyle.vGap12,
buildBGItem(
context,
child: SwitchListTile(
value: settings.comicReaderShowStatus.value,
onChanged: (e) {
settings.setComicReaderShowStatus(e);
},
title: const Text("显示状态信息"),
),
),
AppStyle.vGap12,
buildBGItem(
context,
child: SwitchListTile(
value: settings.comicReaderPageAnimation.value,
onChanged: (e) {
settings.setComicReaderPageAnimation(e);
},
title: const Text("翻页动画"),
),
),
],
),
),
),
],
),
);
}
Widget buildBGItem(BuildContext context, {required Widget child}) {
return Container(
decoration: BoxDecoration(
borderRadius: AppStyle.radius8,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: child,
);
}
Widget buildSelectedButton(
{required Widget child, bool selected = false, Function()? onTap}) {
return Builder(builder: (context) {
return OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor:
selected ? Theme.of(context).colorScheme.primary : Colors.grey,
side: BorderSide(
color:
selected ? Theme.of(context).colorScheme.primary : Colors.grey,
),
),
onPressed: onTap,
child: child,
);
});
}
void setDirection(int value) {
initialIndex = currentIndex.value;
settings.setComicReaderDirection(value);
direction.value = value;
if (initialIndex != 0) {
Future.delayed(const Duration(milliseconds: 200), () {
jumpToPage(initialIndex);
});
}
}
void setShowViewPoint(bool value) {
if (value) {
if (!detail.value.pageUrls.contains("TC")) {
detail.update((val) {
val!.pageUrls.add("TC");
});
}
} else {
if (detail.value.pageUrls.contains("TC")) {
detail.update((val) {
val!.pageUrls.remove("TC");
});
}
}
}
void uploadHistory() {
var chapter = chapters[chapterIndex.value];
UserService.instance.updateComicHistory(
comicId: comicId,
chapterId: chapter.chapterId,
page: currentIndex.value + 1,
comicName: comicTitle,
comicCover: comicCover,
chapterName: chapter.chapterTitle,
);
}
/// 进入全屏
void setFull() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: [],
);
}
/// 进入全屏edgeToEdge模式
void setFullEdge() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
overlays: SystemUiOverlay.values,
);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
));
}
/// 退出全屏
void exitFull() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
overlays: SystemUiOverlay.values,
);
}
void likeViewPoint(ComicViewPointModel item) async {
try {
await request.likeViewPoint(comicId: comicId, id: item.id);
item.num.value += 1;
} catch (e) {
SmartDialog.showToast(e.toString());
}
}
void sendViewPoint(String content) async {
if (!await UserService.instance.login()) {
SmartDialog.showToast("请先登录");
return;
}
if (content.isEmpty) {
SmartDialog.showToast("内容不能为空");
return;
}
Get.back();
try {
SmartDialog.showLoading();
await request.sendViewPoint(
comicId: comicId,
chapterId: chapters[chapterIndex.value].chapterId,
content: content,
page: currentIndex.value + 1,
);
loadViewPoints();
} catch (e) {
SmartDialog.showToast(e.toString());
} finally {
SmartDialog.dismiss(status: SmartStatus.loading);
}
}
void keyDown(LogicalKeyboardKey key) {
if (key == LogicalKeyboardKey.arrowLeft ||
key == LogicalKeyboardKey.pageUp) {
if (leftHandMode) {
nextPage();
} else {
forwardPage();
}
} else if (key == LogicalKeyboardKey.arrowRight ||
key == LogicalKeyboardKey.pageDown) {
if (leftHandMode) {
forwardPage();
} else {
nextPage();
}
}
}
}

View File

@@ -0,0 +1,612 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:flutter_dmzj/modules/comic/reader/comic_reader_controller.dart';
import 'package:flutter_dmzj/widgets/custom_header.dart';
import 'package:flutter_dmzj/widgets/local_image.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:get/get.dart';
import 'package:photo_view/photo_view.dart';
import 'package:preload_page_view/preload_page_view.dart';
import 'package:remixicon/remixicon.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class ComicReaderPage extends GetView<ComicReaderController> {
const ComicReaderPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return KeyboardListener(
onKeyEvent: (e) {
if (e.runtimeType == KeyUpEvent) {
controller.keyDown(e.logicalKey);
Log.d(e.toString());
}
},
focusNode: controller.focusNode,
autofocus: true,
child: Theme(
data: Theme.of(context),
child: Scaffold(
resizeToAvoidBottomInset: false,
body: Stack(
children: [
Obx(
() => Offstage(
offstage: controller.detail.value.chapterId == 0,
child: GestureDetector(
onTap: () {
controller.setShowControls();
},
child:
controller.direction.value == ReaderDirection.kUpToDown
? buildVertical(context)
: buildHorizontal(context),
),
),
),
Positioned.fill(
child: Row(
children: [
Expanded(
flex: 1,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
controller.leftHandMode
? controller.nextPage()
: controller.forwardPage();
},
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.transparent,
),
),
),
Expanded(
flex: 8,
child: Container(),
),
Expanded(
flex: 1,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
controller.leftHandMode
? controller.forwardPage()
: controller.nextPage();
},
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.transparent,
),
),
),
],
),
),
Obx(
() => Offstage(
offstage: !controller.pageLoadding.value,
child: const AppLoaddingWidget(),
),
),
Obx(
() => Offstage(
offstage: !controller.pageError.value,
child: AppErrorWidget(
errorMsg: controller.errorMsg.value,
onRefresh: () => controller.loadDetail(),
),
),
),
Positioned(
right: 0,
bottom: 0,
child: Obx(
() => Offstage(
offstage: !controller.settings.comicReaderShowStatus.value,
child: Container(
decoration: const BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(8),
),
),
padding:
AppStyle.edgeInsetsA12.copyWith(top: 4, bottom: 4),
child: Obx(
() => Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
buildConnectivity(),
buildBattery(),
Container(
constraints: const BoxConstraints(maxWidth: 100),
child: Text(
controller.detail.value.chapterTitle,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
height: 1.0,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
AppStyle.hGap8,
Text(
"${controller.currentIndex.value + 1} / ${controller.detail.value.pageUrls.length}",
style: const TextStyle(
fontSize: 12,
height: 1.0,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
),
),
//顶部
Obx(
() => AnimatedPositioned(
top: controller.showControls.value
? 0
: -(64 + AppStyle.statusBarHeight),
left: 0,
right: 0,
duration: const Duration(milliseconds: 100),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
),
height: 64 + AppStyle.statusBarHeight,
padding: EdgeInsets.only(top: AppStyle.statusBarHeight),
child: Row(
children: [
IconButton(
onPressed: Get.back,
icon: const Icon(Icons.arrow_back),
),
AppStyle.hGap12,
Expanded(
child: Obx(
() => Text(
controller.chapters[controller.chapterIndex.value]
.chapterTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
],
),
),
),
),
//底部
Obx(
() => AnimatedPositioned(
bottom: controller.showControls.value
? 0
: -(136 + AppStyle.bottomBarHeight),
left: 0,
right: 0,
duration: const Duration(milliseconds: 100),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
height: 136 + AppStyle.bottomBarHeight,
padding: EdgeInsets.only(bottom: AppStyle.bottomBarHeight),
alignment: Alignment.center,
child: Container(
constraints: const BoxConstraints(
maxWidth: 600,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
buildSilderBar(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton.filledTonal(
onPressed: controller.forwardChapter,
icon: const Icon(Remix.skip_back_line),
tooltip: "上一话",
),
IconButton.filledTonal(
onPressed: controller.showMenu,
icon: const Icon(Remix.file_list_line),
tooltip: "目录",
),
IconButton.filledTonal(
onPressed: controller.showSettings,
icon: const Icon(Remix.settings_line),
tooltip: "设置",
),
IconButton.filledTonal(
onPressed: controller.nextChapter,
icon: const Icon(Remix.skip_forward_line),
tooltip: "下一话",
),
],
),
],
),
),
),
),
),
],
),
),
),
);
}
Widget buildHorizontal(BuildContext context) {
return EasyRefresh(
header: MaterialHeader2(
triggerOffset: 80,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: AppStyle.radius24,
),
padding: AppStyle.edgeInsetsA12,
child: Icon(
Icons.arrow_circle_left,
color: Theme.of(context).colorScheme.primary,
),
),
),
footer: MaterialFooter2(
triggerOffset: 80,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: AppStyle.radius24,
),
padding: AppStyle.edgeInsetsA12,
child: Icon(
Icons.arrow_circle_right,
color: Theme.of(context).colorScheme.primary,
),
),
),
refreshOnStart: false,
onRefresh: () async {
controller.forwardChapter();
},
onLoad: () async {
controller.nextChapter();
},
child: PreloadPageView.builder(
controller: controller.preloadPageController,
onPageChanged: (e) {
controller.currentIndex.value = e;
},
reverse: controller.direction.value == ReaderDirection.kRightToLeft,
physics: controller.lockSwipe.value
? const NeverScrollableScrollPhysics()
: null,
itemCount: controller.detail.value.pageUrls.length,
preloadPagesCount: 4,
itemBuilder: (_, i) {
var url = controller.detail.value.pageUrls[i];
// if (i == controller.detail.value.pageUrls.length - 1 && url == "TC") {
// return buildViewPoints();
// }
return PhotoView.customChild(
wantKeepAlive: true,
initialScale: 1.0,
onScaleEnd: (context, detail, e) {
controller.lockSwipe.value = (e.scale ?? 1) > 1.0;
},
child: controller.detail.value.isLocal
? LocalImage(url, fit: BoxFit.contain)
: NetImage(
url,
fit: BoxFit.contain,
progress: true,
),
);
},
),
);
}
Widget buildVertical(BuildContext context) {
return EasyRefresh(
header: MaterialHeader2(
triggerOffset: 80,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: AppStyle.radius24,
),
padding: AppStyle.edgeInsetsA12,
child: Icon(
Icons.arrow_circle_up,
color: Theme.of(context).colorScheme.primary,
),
),
),
footer: MaterialFooter2(
triggerOffset: 80,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: AppStyle.radius24,
),
padding: AppStyle.edgeInsetsA12,
child: Icon(
Icons.arrow_circle_down,
color: Theme.of(context).colorScheme.primary,
),
),
),
refreshOnStart: false,
onRefresh: () async {
controller.forwardChapter();
},
onLoad: () async {
controller.nextChapter();
},
child: ScrollablePositionedList.builder(
itemScrollController: controller.itemScrollController,
itemCount: controller.detail.value.pageUrls.length,
itemPositionsListener: controller.itemPositionsListener,
itemBuilder: (_, i) {
// if (i == controller.detail.value.pageUrls.length - 1 &&
// controller.detail.value.pageUrls[i] == "TC") {
// return buildViewPoints(shrinkWrap: true);
// }
var url = controller.detail.value.pageUrls[i];
return Container(
constraints: const BoxConstraints(
minHeight: 200,
),
child: controller.detail.value.isLocal
? LocalImage(url, fit: BoxFit.contain)
: NetImage(
url,
fit: BoxFit.fitWidth,
progress: true,
),
);
},
),
);
}
Widget buildSilderBar() {
return Obx(
() {
var value = controller.currentIndex.value + 1.0;
var max = controller.detail.value.pageUrls.length.toDouble();
if (value > max) {
return const SizedBox(
height: 48,
);
}
return SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: Slider(
value: value,
max: max,
onChanged: (e) {
controller.jumpToPage((e - 1).toInt());
},
),
),
],
),
);
},
);
}
Widget buildViewPoints({bool shrinkWrap = false}) {
return Obx(
() => ListView(
shrinkWrap: shrinkWrap,
physics: shrinkWrap ? const NeverScrollableScrollPhysics() : null,
padding: EdgeInsets.zero,
children: [
ListTile(
title: Text("吐槽(${controller.viewPoints.length})"),
),
Padding(
padding: AppStyle.edgeInsetsH12,
child: Wrap(
spacing: 8,
runSpacing: 8,
children: controller.viewPoints
.take(10)
.map(
(e) => OutlinedButton(
style: OutlinedButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
controller.likeViewPoint(e);
},
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
e.content,
style: const TextStyle(
fontSize: 14, color: Colors.white),
),
AppStyle.hGap12,
const Icon(
Remix.thumb_up_line,
size: 16,
),
AppStyle.hGap4,
Obx(
() => Text(
"${e.num.value}",
style: const TextStyle(
fontSize: 14,
),
),
),
],
),
),
)
.toList(),
),
),
Container(
alignment: Alignment.center,
width: 100,
margin: AppStyle.edgeInsetsA12,
child: OutlinedButton(
onPressed: () {
controller.showComment();
},
child: const Text("查看更多"),
),
),
AppStyle.vGap12,
],
),
);
}
Widget buildConnectivity() {
var connectivityType = controller.connectivityType.value;
IconData icon = Remix.wifi_line;
var name = "WiFi";
switch (connectivityType) {
case ConnectivityResult.bluetooth:
icon = Remix.wifi_line;
name = "蓝牙";
break;
case ConnectivityResult.ethernet:
icon = Remix.computer_line;
name = "有线";
break;
case ConnectivityResult.mobile:
icon = Remix.base_station_line;
name = "流量";
break;
case ConnectivityResult.wifi:
icon = Remix.wifi_line;
name = "WiFi";
break;
case ConnectivityResult.vpn:
icon = Remix.shield_keyhole_line;
name = "VPN";
break;
case ConnectivityResult.none:
icon = Remix.wifi_off_line;
name = "无网络";
break;
case ConnectivityResult.other:
icon = Remix.question_line;
name = "未知";
break;
default:
}
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
icon,
size: 12,
color: Colors.white,
),
AppStyle.hGap4,
Text(
name,
style: const TextStyle(
fontSize: 12,
height: 1.0,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
AppStyle.hGap8,
],
);
}
Widget buildBattery() {
var battery = controller.batteryLevel.value;
// IconData icon = Icons.battery_0_bar;
// if (battery >= 90) {
// icon = Icons.battery_full;
// } else if (battery < 90 && battery >= 80) {
// icon = Icons.battery_6_bar;
// } else if (battery < 80 && battery >= 70) {
// icon = Icons.battery_5_bar;
// } else if (battery < 70 && battery >= 50) {
// icon = Icons.battery_4_bar;
// } else if (battery < 50 && battery >= 30) {
// icon = Icons.battery_3_bar;
// } else if (battery < 30 && battery >= 20) {
// icon = Icons.battery_2_bar;
// } else if (battery < 20 && battery >= 10) {
// icon = Icons.battery_1_bar;
// } else {
// icon = Icons.battery_0_bar;
// }
return Visibility(
visible: controller.showBattery.value,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Icon(
// icon,
// size: 16,
// ),
// AppStyle.hGap4,
Text(
"电量 $battery%",
style: const TextStyle(
fontSize: 12,
height: 1.0,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
AppStyle.hGap8,
],
),
);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/dialog_utils.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:flutter_dmzj/models/comic/search_item.dart';
import 'package:flutter_dmzj/requests/comic_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:get/get.dart';
class ComicSearchController extends BasePageController<SearchComicItem> {
final String keyword;
ComicSearchController(this.keyword) {
searchController = TextEditingController(text: keyword);
showHotWord.value = keyword.isEmpty;
}
late TextEditingController searchController;
final ComicRequest request = ComicRequest();
String _keyword = "";
RxMap<int, String> hotWords = <int, String>{}.obs;
var showHotWord = true.obs;
@override
void onInit() {
// loadHotWord();
if (keyword.isNotEmpty) {
submit();
}
super.onInit();
}
void submit() async {
if (searchController.text.isEmpty) {
list.clear();
showHotWord.value = true;
return;
}
if (int.tryParse(searchController.text) != null &&
await numberJumpComic()) {
return;
}
if (searchController.text.startsWith("id:\\") && await handelJumpComic()) {
return;
}
showHotWord.value = false;
_keyword = searchController.text;
refreshData();
}
Future<bool> handelJumpComic() async {
var id = int.tryParse(searchController.text.replaceAll("id:\\", "")) ?? 0;
if (id != 0) {
AppNavigator.toComicDetail(id);
return true;
} else {
return false;
}
}
Future numberJumpComic() async {
if (!await DialogUtils.showAlertDialog(
"你输入了纯数字,是否跳转至对应的漫画?",
title: "漫画ID跳转",
)) {
return false;
}
return await handelJumpComic();
}
@override
Future<List<SearchComicItem>> getData(int page, int pageSize) async {
if (searchController.text.isEmpty) {
return [];
}
// if (AppSettingsService.instance.comicSearchUseWebApi.value) {
// //WEB接口不能分页
// if (page > 1) {
// return [];
// }
// return await request.searchWeb(keyword: _keyword);
// } else {
return await request.search(keyword: _keyword, page: page);
//}
}
void loadHotWord() async {
try {
hotWords.value = await request.searchHotWord();
} catch (e) {
Log.logPrint(e);
}
}
}

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/comic/search_item.dart';
import 'package:flutter_dmzj/modules/comic/search/comic_search_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
class ComicSearchPage extends StatelessWidget {
final String keyword;
final ComicSearchController controller;
ComicSearchPage({this.keyword = "", super.key})
: controller = Get.put(ComicSearchController(keyword));
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
titleSpacing: 8,
title: SizedBox(
height: 40,
child: TextField(
controller: controller.searchController,
autofocus: true,
decoration: InputDecoration(
hintText: "搜索漫画",
contentPadding: AppStyle.edgeInsetsH12,
border: const OutlineInputBorder(),
prefixIcon: SizedBox(
width: 48,
child: IconButton(
onPressed: () {
AppNavigator.closePage();
},
icon: const Icon(Icons.arrow_back),
),
),
suffixIcon: SizedBox(
width: 48,
child: IconButton(
onPressed: controller.submit,
icon: const Icon(Icons.search),
),
),
),
onSubmitted: (e) {
controller.submit();
},
),
),
),
body: Stack(
children: [
PageListView(
pageController: controller,
firstRefresh: false,
showPageLoadding: true,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
),
// Positioned.fill(
// child: Obx(
// () => Offstage(
// offstage: !controller.showHotWord.value,
// child: SingleChildScrollView(
// child: Column(
// children: [
// const ListTile(
// title: Text("热门搜索"),
// ),
// Padding(
// padding: AppStyle.edgeInsetsH12.copyWith(bottom: 12),
// child: Wrap(
// spacing: 8,
// runSpacing: 8,
// children: controller.hotWords.keys
// .map(
// (e) => OutlinedButton(
// style: OutlinedButton.styleFrom(
// tapTargetSize:
// MaterialTapTargetSize.shrinkWrap,
// ),
// onPressed: () {
// AppNavigator.toComicDetail(e);
// },
// child: Text(controller.hotWords[e] ?? ""),
// ),
// )
// .toList(),
// ),
// )
// ],
// ),
// ),
// ),
// ),
// ),
],
),
);
}
Widget buildItem(SearchComicItem item) {
return InkWell(
onTap: () {
AppNavigator.toComicDetail(item.comicId);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.cover,
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text.rich(
TextSpan(children: [
const WidgetSpan(
child: Icon(
Icons.account_circle,
color: Colors.grey,
size: 18,
)),
const TextSpan(
text: " ",
),
TextSpan(
text: item.author,
style:
const TextStyle(color: Colors.grey, fontSize: 14))
]),
),
AppStyle.vGap4,
Text(item.tags,
style: const TextStyle(color: Colors.grey, fontSize: 14)),
AppStyle.vGap4,
Text(item.lastChapterName,
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,147 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/comic/detail_info.dart';
import 'package:flutter_dmzj/requests/comic_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/comic_download_service.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class ComicSelectChapterController extends BaseController {
final int comicId;
ComicSelectChapterController(this.comicId);
final ComicRequest request = ComicRequest();
RxList<ComicDetailVolume> volumes = RxList<ComicDetailVolume>();
RxSet<int> chapterIds = RxSet<int>();
String comicTitle = "";
String comicCover = "";
bool islong = false;
@override
void onInit() {
loadDetail();
super.onInit();
}
void refreshV1() async {
try {
var result =
await request.comicDetail(comicId: comicId, priorityV1: true);
if (result.volumes.isEmpty) {
SmartDialog.showToast("没有找到任何章节");
return;
}
comicTitle = result.title;
comicCover = result.cover;
islong = result.isLong;
for (var volume in result.volumes) {
volume.sortType.value = 1;
volume.sort();
}
volumes.value = result.volumes;
} catch (e) {
SmartDialog.showToast("无法获取章节");
}
}
/// 加载信息
void loadDetail() async {
try {
pageLoadding.value = true;
pageError.value = false;
var result = await request.comicDetail(comicId: comicId);
comicTitle = result.title;
comicCover = result.cover;
islong = result.isLong;
if (result.volumes.isEmpty && !result.isHide) {
refreshV1();
} else {
for (var volume in result.volumes) {
volume.sortType.value = 1;
volume.sort();
}
volumes.value = result.volumes;
}
} catch (e) {
pageError.value = true;
errorMsg.value = e.toString();
} finally {
pageLoadding.value = false;
}
}
void selectItem(ComicDetailChapterItem item) {
//禁止下载VIP章节
if (item.isVip) {
SmartDialog.showToast("请使用动漫之家官方APP下载VIP章节");
return;
}
if (chapterIds.contains(item.chapterId)) {
chapterIds.remove(item.chapterId);
} else {
chapterIds.add(item.chapterId);
}
}
void selectAll() {
for (var volume in volumes) {
for (var chapter in volume.chapters) {
if (chapter.isVip) {
continue;
}
var id = "${comicId}_${chapter.chapterId}";
if (!ComicDownloadService.instance.downloadIds.contains(id)) {
chapterIds.add(chapter.chapterId);
}
}
}
}
void cleanAll() {
chapterIds.clear();
}
void toDownloadManage() {
AppNavigator.toComicDownloadManage(1);
}
void startDownload() {
if (chapterIds.isEmpty) {
SmartDialog.showToast("请选择需要下载的章节");
return;
}
for (var id in chapterIds) {
//搜索章节
ComicDetailVolume? volume;
ComicDetailChapterItem? chapter;
for (var item in volumes) {
var chapterItem =
item.chapters.firstWhereOrNull((y) => y.chapterId == id);
if (chapterItem != null) {
volume = item;
chapter = chapterItem;
break;
}
}
if (volume == null || chapter == null) {
continue;
}
ComicDownloadService.instance.addTask(
comicId: comicId,
chapterId: chapter.chapterId,
chapterSort: chapter.chapterOrder,
volumeName: volume.title,
comicTitle: comicTitle,
comicCover: comicCover,
chapterName: chapter.chapterTitle,
isVip: chapter.isVip,
isLongComic: islong,
);
}
chapterIds.clear();
SmartDialog.showToast("已添加到下载列表下载过程中请保持APP在前台运行");
}
}

View File

@@ -0,0 +1,259 @@
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/comic/detail_info.dart';
import 'package:flutter_dmzj/modules/comic/select_chapter/comic_select_chapter_controller.dart';
import 'package:flutter_dmzj/services/comic_download_service.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 ComicSelectChapterPage extends StatelessWidget {
final int comicId;
final ComicSelectChapterController controller;
ComicSelectChapterPage(this.comicId, {super.key})
: controller = Get.put(
ComicSelectChapterController(comicId),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("选择下载章节"),
actions: [
TextButton(
onPressed: controller.toDownloadManage,
child: const Text("下载管理"),
),
],
),
body: Stack(
children: [
EasyRefresh(
header: const MaterialHeader(),
onRefresh: controller.loadDetail,
child: _buildVolumes(),
),
Obx(
() => Offstage(
offstage: !controller.pageLoadding.value,
child: const AppLoaddingWidget(),
),
),
Obx(
() => Offstage(
offstage: !controller.pageError.value,
child: AppErrorWidget(
errorMsg: controller.errorMsg.value,
onRefresh: () => controller.loadDetail(),
),
),
),
],
),
bottomNavigationBar: BottomAppBar(
child: SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.selectAll,
icon: const Icon(
Remix.checkbox_line,
size: 20,
),
label: const Text("全选"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.cleanAll,
icon: const Icon(
Remix.checkbox_blank_line,
size: 20,
),
label: const Text("取消选中"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.startDownload,
icon: const Icon(
Remix.download_line,
size: 20,
),
label:
Obx(() => Text("下载选中(${controller.chapterIds.length})")),
),
),
],
),
),
),
);
}
Widget _buildVolumes() {
return Obx(
() => ListView.builder(
padding: AppStyle.edgeInsetsA12,
itemCount: controller.volumes.length,
itemBuilder: (_, i) {
var item = controller.volumes[i];
return _buildChapters(item);
},
),
);
}
Widget _buildChapters(ComicDetailVolume item) {
return 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),
),
);
}
var chapter = item.chapters[i];
return Tooltip(
message: chapter.chapterTitle,
child: Obx(
() => Stack(
children: [
OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: controller.chapterIds
.contains(chapter.chapterId)
? Get.theme.colorScheme.primary
: Get.textTheme.bodyMedium!.color,
side: controller.chapterIds
.contains(chapter.chapterId)
? BorderSide(color: Get.theme.colorScheme.primary)
: null,
textStyle: const TextStyle(fontSize: 14),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size.fromHeight(40),
),
onPressed: ComicDownloadService.instance.downloadIds
.contains("${comicId}_${chapter.chapterId}")
? null
: () => controller.selectItem(chapter),
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,
),
);
})
],
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/comic/special_detail_model.dart';
import 'package:flutter_dmzj/requests/comic_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/user_service.dart';
import 'package:get/get.dart';
class SpecialDetailController extends BaseController {
final int id;
SpecialDetailController(this.id);
final ComicRequest request = ComicRequest();
Rx<ComicSpecialDetailModel?> detail = Rx<ComicSpecialDetailModel?>(null);
@override
void onInit() {
loadData();
super.onInit();
}
void loadData() async {
try {
pageLoadding.value = true;
pageError.value = false;
var result = await request.specialDetail(id: id);
detail.value = result;
} catch (e) {
handleError(e, showPageError: true);
} finally {
pageLoadding.value = false;
}
}
void subscribeAll() {
if (detail.value == null) {
return;
}
UserService.instance.addSubscribe(
detail.value!.comics.map((e) => e.id).toList(),
AppConstant.kTypeComic,
);
}
void share() {
if (detail.value == null) {
return;
}
Utils.share(
"http://m.idmzj.com/zhuanti/${detail.value!.pageUrl}",
content: detail.value?.title ?? "",
);
}
void comment() {
if (detail.value == null) {
return;
}
AppNavigator.toComment(objId: id, type: AppConstant.kTypeSpecial);
}
}

View File

@@ -0,0 +1,221 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/comic/special_detail_model.dart';
import 'package:flutter_dmzj/modules/comic/special_detail/special_detail_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/user_service.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:get/get.dart';
import 'package:remixicon/remixicon.dart';
class SpecialDetailPage extends StatelessWidget {
final int id;
final SpecialDetailController controller;
SpecialDetailPage(this.id, {super.key})
: controller = Get.put(
SpecialDetailController(id),
tag: "$id",
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Obx(
() => Text(controller.detail.value?.title ?? "专题"),
),
),
body: Obx(
() => Stack(
children: [
Offstage(
offstage: controller.detail.value == null,
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: (controller.detail.value?.comics.length ?? 0) + 1,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (_, i) {
if (i == 0) {
return buildHeader();
}
var item = controller.detail.value!.comics[i - 1];
return buildItem(item);
},
),
),
Obx(
() => Offstage(
offstage: !controller.pageLoadding.value,
child: const AppLoaddingWidget(),
),
),
Obx(
() => Offstage(
offstage: !controller.pageError.value,
child: AppErrorWidget(
errorMsg: controller.errorMsg.value,
onRefresh: () => controller.loadData(),
),
),
),
],
),
),
bottomNavigationBar: BottomAppBar(
child: SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.subscribeAll,
icon: const Icon(
Remix.heart_line,
size: 20,
),
label: const Text("订阅全部"),
),
),
Expanded(
child: Obx(
() => TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.comment,
icon: const Icon(
Remix.chat_2_line,
size: 20,
),
label: Text(
"评论(${controller.detail.value?.commentAmount ?? 0})"),
),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.share,
icon: const Icon(
Remix.share_box_line,
size: 20,
),
label: const Text("分享"),
),
),
],
),
),
),
);
}
Widget buildItem(ComicSpecialComicModel item) {
return InkWell(
onTap: () {
AppNavigator.toComicDetail(item.id);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.cover,
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
AppStyle.vGap4,
Text(item.recommendBrief,
style: const TextStyle(color: Colors.grey, fontSize: 14)),
AppStyle.vGap4,
Text(item.recommendReason,
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
Center(
child: Obx(
() => UserService.instance.subscribedComicIds.contains(item.id)
? IconButton(
icon: const Icon(Icons.favorite),
onPressed: () {
UserService.instance.cancelSubscribe(
[item.id],
AppConstant.kTypeComic,
);
},
)
: IconButton(
icon: const Icon(Icons.favorite_border),
onPressed: () {
UserService.instance.addSubscribe(
[item.id],
AppConstant.kTypeComic,
);
},
),
),
)
],
),
),
);
}
Widget buildHeader() {
if (controller.detail.value == null) {
return const SizedBox();
}
var detail = controller.detail.value!;
return Padding(
padding: AppStyle.edgeInsetsA12,
child: Column(
children: [
Container(
constraints: const BoxConstraints(
maxWidth: 500,
),
child: AspectRatio(
aspectRatio: 710 / 354,
child: NetImage(
detail.mobileHeaderPic,
borderRadius: 8,
width: 710,
height: 354,
),
),
),
AppStyle.vGap12,
Text(
detail.description,
),
],
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/comment/comment_item.dart';
import 'package:flutter_dmzj/requests/comment_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
class AddCommentController extends BaseController {
final int type;
final int objId;
final CommentItem? replyItem;
AddCommentController({
required this.objId,
required this.type,
this.replyItem,
});
final CommentRequest request = CommentRequest();
final TextEditingController textEditingController = TextEditingController();
void submit() async {
if (textEditingController.text.isEmpty) {
SmartDialog.showToast("内容不能为空");
return;
}
try {
SmartDialog.showLoading();
if (replyItem == null) {
await request.sendComment(
objId: objId,
type: type,
content: textEditingController.text,
);
} else {
await request.sendComment(
objId: objId,
type: type,
content: textEditingController.text,
toCommentId: replyItem!.id.toString(),
originCommentId: replyItem!.originId.toString(),
toUid: replyItem!.userId.toString(),
);
}
SmartDialog.showToast("发表成功");
AppNavigator.closePage();
} catch (e) {
SmartDialog.showToast(e.toString());
} finally {
SmartDialog.dismiss(status: SmartStatus.loading);
}
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/comment/comment_item.dart';
import 'package:flutter_dmzj/modules/common/comment/add_comment_controller.dart';
import 'package:get/get.dart';
class AddCommentPage extends StatelessWidget {
final int type;
final int objId;
final CommentItem? replyItem;
final AddCommentController controller;
AddCommentPage({
Key? key,
required this.objId,
required this.type,
this.replyItem,
}) : controller = Get.put(
AddCommentController(objId: objId, type: type, replyItem: replyItem),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
),
super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("添加评论"),
),
body: ListView(
padding: AppStyle.edgeInsetsA12,
children: [
Visibility(
visible: replyItem != null,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.withOpacity(.2),
borderRadius: AppStyle.radius4,
),
margin: AppStyle.edgeInsetsB12,
padding: AppStyle.edgeInsetsA8,
child: Text("${replyItem?.nickname}${replyItem?.content}"),
),
),
TextField(
controller: controller.textEditingController,
decoration: const InputDecoration(
hintText: "你想说点什么...",
border: OutlineInputBorder(),
),
onSubmitted: (e) {
controller.submit();
},
minLines: 4,
maxLines: 6,
maxLength: 1000,
),
AppStyle.vGap12,
ElevatedButton(
onPressed: controller.submit,
child: const Text("发布"),
),
],
),
);
}
}

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class CommentController extends GetxController
with GetSingleTickerProviderStateMixin {
final int type;
final int objId;
CommentController(this.type, this.objId);
late TabController tabController = TabController(length: 2, vsync: this);
}

View File

@@ -0,0 +1,33 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/comment/comment_item.dart';
import 'package:flutter_dmzj/requests/comment_request.dart';
class CommentListController extends BasePageController<CommentItem> {
final int type;
final int objId;
final bool isHot;
final CommentRequest request = CommentRequest();
CommentListController({
required this.type,
required this.objId,
required this.isHot,
});
@override
Future<List<CommentItem>> getData(int page, int pageSize) async {
if (isHot) {
return await request.getComment(
type: type,
objId: objId,
page: page,
sort: 2,
);
} else {
return await request.getComment(
type: type,
objId: objId,
page: page,
);
}
}
}

View File

@@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/modules/common/comment/comment_list_controller.dart';
import 'package:flutter_dmzj/widgets/comment_item_widget.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
class CommentListView extends StatelessWidget {
final int type;
final int objId;
final bool isHot;
final CommentListController controller;
CommentListView({
Key? key,
required this.objId,
required this.type,
required this.isHot,
}) : controller = Get.put(
CommentListController(objId: objId, type: type, isHot: isHot),
tag: "${objId}_${type}_${isHot ? 1 : 0}",
),
super(key: key);
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: PageListView(
pageController: controller,
firstRefresh: true,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 4,
),
itemBuilder: (context, i) {
var item = controller.list[i];
return CommentItemWidget(item);
},
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/modules/common/comment/comment_list_view.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:get/get.dart';
class CommentPage extends StatelessWidget {
final int objId;
final int type;
const CommentPage({required this.objId, required this.type, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: Container(
alignment: Alignment.center,
child: TabBar(
isScrollable: true,
labelPadding: AppStyle.edgeInsetsH24,
tabAlignment: TabAlignment.start,
indicatorSize: TabBarIndicatorSize.label,
indicatorColor: Theme.of(context).colorScheme.primary,
labelColor: Theme.of(context).colorScheme.primary,
unselectedLabelColor:
Get.isDarkMode ? Colors.white70 : Colors.black87,
tabs: const [
Tab(text: "最新评论"),
Tab(text: "热门评论"),
],
),
),
),
body: TabBarView(
children: [
CommentListView(objId: objId, type: type, isHot: false),
CommentListView(objId: objId, type: type, isHot: true),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
AppNavigator.toAddComment(objId: objId, type: type);
},
child: const Icon(Icons.add),
),
),
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/modules/common/download/comic/comic_downloaded_view.dart';
import 'package:flutter_dmzj/modules/common/download/comic/comic_downloading_view.dart';
import 'package:flutter_dmzj/services/comic_download_service.dart';
import 'package:get/get.dart';
class ComicDownloadPage extends StatelessWidget {
final int type;
const ComicDownloadPage(this.type, {super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
initialIndex: type,
child: Scaffold(
appBar: AppBar(
title: Container(
alignment: Alignment.center,
padding: const EdgeInsets.only(right: 56),
child: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
indicatorSize: TabBarIndicatorSize.label,
indicatorColor: Theme.of(context).colorScheme.primary,
labelColor: Theme.of(context).colorScheme.primary,
unselectedLabelColor:
Get.isDarkMode ? Colors.white70 : Colors.black87,
tabs: [
const Tab(text: "已完成"),
Obx(
() => Tab(
text: ComicDownloadService.instance.taskQueues.isEmpty
? "下载中"
: "下载中(${ComicDownloadService.instance.taskQueues.length})"),
)
],
),
),
),
body: const TabBarView(
children: [
ComicDownloadedView(),
ComicDownloadingView(),
],
),
),
);
}
}

View File

@@ -0,0 +1,182 @@
import 'dart:async';
import 'package:flutter_dmzj/app/event_bus.dart';
import 'package:flutter_dmzj/models/comic/detail_info.dart';
import 'package:flutter_dmzj/models/db/comic_history.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/comic_download_service.dart';
import 'package:flutter_dmzj/services/db_service.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class ComicDownloadedDetailController extends GetxController {
final ComicDownloadedItem info;
ComicDownloadedDetailController(this.info);
/// 阅读记录
Rx<ComicHistory?> history = Rx<ComicHistory?>(null);
/// 更新漫画记录
StreamSubscription<dynamic>? updateComicSubscription;
/// 编辑模式
var editMode = false.obs;
RxSet<ComicDetailChapterItem> selectItems = RxSet<ComicDetailChapterItem>();
@override
void onInit() {
updateComicSubscription = EventBus.instance.listen(
EventBus.kUpdatedComicHistory,
(id) {
if (id == info.comicId) {
getHistory();
}
},
);
getHistory();
super.onInit();
}
@override
void onClose() {
updateComicSubscription?.cancel();
super.onClose();
}
void getHistory() {
var comicHistory = DBService.instance.getComicHistory(info.comicId);
if (comicHistory != null) {
history.value = comicHistory;
history.update((val) {});
}
}
/// 开始/继续阅读
void read() {
if (info.volumes.isEmpty) {
SmartDialog.showToast("没有可阅读的章节");
return;
}
if (info.volumes.first.chapters.isEmpty) {
SmartDialog.showToast("没有可阅读的章节");
return;
}
//查找记录
if (history.value != null && history.value!.chapterId != 0) {
ComicDetailVolume? volume;
ComicDetailChapterItem? chapter;
for (var volumeItem in info.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: info.comicId,
comicTitle: info.comicName,
comicCover: info.comicCover,
chapters: chapters,
chapter: chapter,
isLongComic: info.isLongComic,
);
} else {
SmartDialog.showToast("未找到历史记录对应章节,将从头开始阅读");
readStart();
}
} else {
readStart();
}
}
void readStart() {
//从头开始
var volume = info.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: info.comicId,
comicCover: info.comicCover,
comicTitle: info.comicName,
chapters: chapters,
chapter: chapter,
isLongComic: info.isLongComic,
);
}
void readChapter(ComicDetailVolume volume, ComicDetailChapterItem item) {
var chapters = List<ComicDetailChapterItem>.from(volume.chapters);
//正序
chapters.sort((a, b) => a.chapterOrder.compareTo(b.chapterOrder));
AppNavigator.toComicReader(
comicId: info.comicId,
comicCover: info.comicCover,
comicTitle: info.comicName,
chapters: chapters,
chapter: item,
isLongComic: info.isLongComic,
);
}
void toDetail() {
AppNavigator.toComicDetail(info.comicId);
}
void toAddDownload() {
AppNavigator.toComicDownloadSelect(info.comicId);
}
void setEditMode() {
selectItems.clear();
editMode.value = true;
}
void exitEditMode() {
selectItems.clear();
editMode.value = false;
}
var isSelectAll = false;
void selectAll() {
if (isSelectAll) {
selectItems.clear();
isSelectAll = false;
return;
}
for (var volume in info.volumes) {
for (var chapter in volume.chapters) {
selectItems.add(chapter);
}
}
isSelectAll = true;
}
void delete() {
for (var item in selectItems) {
ComicDownloadService.instance.deleteChapter(info.comicId, item.chapterId);
}
exitEditMode();
SmartDialog.showToast("删除成功");
AppNavigator.closePage();
}
void selectItem(ComicDetailChapterItem item) {
if (selectItems.contains(item)) {
selectItems.remove(item.chapterId);
} else {
selectItems.add(item);
}
}
}

View File

@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/comic/detail_info.dart';
import 'package:flutter_dmzj/modules/common/download/comic/comic_downloaded_detail_controller.dart';
import 'package:flutter_dmzj/services/comic_download_service.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
class ComicDownloadedDetailPage extends StatelessWidget {
final ComicDownloadedItem info;
final ComicDownloadedDetailController controller;
ComicDownloadedDetailPage(this.info, {super.key})
: controller = Get.put(
ComicDownloadedDetailController(info),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(info.comicName),
),
body: ListView.builder(
padding: AppStyle.edgeInsetsA12,
itemCount: info.volumes.length,
itemBuilder: (_, i) {
var item = info.volumes[i];
return _buildChapters(item);
},
),
bottomNavigationBar: BottomAppBar(
child: SizedBox(
height: 48,
child: Obx(
() => Column(
children: [
Visibility(
visible: !controller.editMode.value,
child: Row(
children: [
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.setEditMode,
icon: const Icon(
Remix.checkbox_line,
size: 20,
),
label: const Text("选择"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.toDetail,
icon: const Icon(
Remix.information_line,
size: 20,
),
label: const Text("详情"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.toAddDownload,
icon: const Icon(
Remix.add_line,
size: 20,
),
label: const Text("追加"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.read,
icon: const Icon(
Remix.play_line,
size: 20,
),
label: const Text("阅读"),
),
),
],
),
),
Visibility(
visible: controller.editMode.value,
child: Row(
children: [
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.selectAll,
icon: const Icon(
Remix.checkbox_line,
size: 20,
),
label: const Text("全选"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.delete,
icon: const Icon(
Remix.delete_bin_line,
size: 20,
),
label: const Text("删除"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.exitEditMode,
icon: const Icon(
Remix.close_line,
size: 20,
),
label: const Text("取消"),
),
),
],
),
),
],
),
),
),
),
);
}
Widget _buildChapters(ComicDetailVolume item) {
return 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),
),
);
}
var chapter = item.chapters[i];
return Tooltip(
message: chapter.chapterTitle,
child: Obx(
() => controller.editMode.value
? OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor:
controller.selectItems.contains(chapter)
? Get.theme.colorScheme.primary
: Get.textTheme.bodyMedium!.color,
side: controller.selectItems.contains(chapter)
? BorderSide(color: Get.theme.colorScheme.primary)
: null,
textStyle: const TextStyle(fontSize: 14),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size.fromHeight(40),
),
onPressed: () {
controller.selectItem(chapter);
},
child: Text(
item.chapters[i].chapterTitle,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
)
: OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: item.chapters[i].chapterId ==
controller.history.value?.chapterId
? Get.theme.colorScheme.primary
: Get.textTheme.bodyMedium!.color,
textStyle: const TextStyle(fontSize: 14),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size.fromHeight(40),
),
onPressed: () {
controller.readChapter(item, chapter);
},
child: Text(
item.chapters[i].chapterTitle,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
),
),
),
);
},
crossAxisCount: count,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
);
})
],
),
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/comic_download_service.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/status/app_empty_widget.dart';
import 'package:get/get.dart';
class ComicDownloadedView extends StatelessWidget {
const ComicDownloadedView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Obx(
() => Stack(
children: [
EasyRefresh(
header: const MaterialHeader(),
onRefresh: () async {
ComicDownloadService.instance.updateDownlaoded();
},
child: ListView.separated(
itemCount: ComicDownloadService.instance.downloaded.length,
separatorBuilder: (_, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (_, i) {
var item = ComicDownloadService.instance.downloaded[i];
return buildItem(item);
},
),
),
Offstage(
offstage: ComicDownloadService.instance.downloaded.isNotEmpty,
child: AppEmptyWidget(
onRefresh: () {
ComicDownloadService.instance.updateDownlaoded();
},
),
),
],
),
);
}
Widget buildItem(ComicDownloadedItem item) {
return InkWell(
onTap: () {
AppNavigator.toComicDownloadDetail(item);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.comicCover,
width: 60,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.comicName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
AppStyle.vGap4,
Text(
"已下载${item.chapterCount}",
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/db/download_status.dart';
import 'package:flutter_dmzj/services/comic_download_service.dart';
import 'package:flutter_dmzj/services/download_task/comic_downloader.dart';
import 'package:flutter_dmzj/widgets/status/app_empty_widget.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
class ComicDownloadingView extends StatelessWidget {
const ComicDownloadingView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: Obx(
() => Stack(
children: [
ListView.separated(
itemCount: ComicDownloadService.instance.taskQueues.length,
separatorBuilder: (_, i) => const Divider(
height: 1,
),
itemBuilder: (_, i) {
var task = ComicDownloadService.instance.taskQueues[i];
return buildItem(task);
},
),
Offstage(
offstage: ComicDownloadService.instance.taskQueues.isNotEmpty,
child: const AppEmptyWidget(),
),
],
),
),
),
BottomAppBar(
child: SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: ComicDownloadService.instance.pauseAll,
icon: const Icon(
Remix.pause_line,
size: 20,
),
label: const Text("暂停全部"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: ComicDownloadService.instance.resumeAll,
icon: const Icon(
Remix.download_line,
size: 20,
),
label: const Text("开始全部"),
),
),
],
),
),
),
],
);
}
Widget buildItem(ComicDownloader task) {
return Obx(
() => Padding(
padding: AppStyle.edgeInsetsA12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"${task.info.value.volumeName} - ${task.info.value.chapterName}",
),
Text(
task.info.value.comicName,
style: Get.textTheme.bodySmall,
),
Row(
children: [
Expanded(
child: ClipRRect(
borderRadius: AppStyle.radius4,
child: LinearProgressIndicator(
value: task.info.value.total > 0
? (task.info.value.index + 1) / task.info.value.total
: 0,
),
),
),
AppStyle.hGap8,
Text(
"${task.info.value.index + 1}/${task.info.value.total}",
style: Get.textTheme.bodySmall,
),
],
),
Row(
children: [
Expanded(
child: Text(
parseStatus(task.info.value.status),
style: Get.textTheme.bodySmall,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
buildButton(
icon: Icons.refresh_rounded,
text: "重试",
visible: task.status == DownloadStatus.error ||
task.status == DownloadStatus.errorLoad,
onPressed: () {
task.retry();
},
),
buildButton(
icon: Icons.play_arrow_rounded,
visible: task.status == DownloadStatus.wait ||
task.status == DownloadStatus.pauseCellular,
text: "开始",
onPressed: () {
task.start();
},
),
buildButton(
icon: Icons.play_arrow_rounded,
visible: task.status == DownloadStatus.pause,
text: "继续",
onPressed: () {
task.resume();
},
),
buildButton(
icon: Icons.pause_rounded,
visible: task.status == DownloadStatus.downloading,
text: "暂停",
onPressed: () {
task.pause();
},
),
buildButton(
icon: Icons.cancel_outlined,
text: "取消",
onPressed: () {
task.cancel();
},
),
],
),
],
),
],
),
),
);
}
String parseStatus(DownloadStatus status) {
switch (status) {
case DownloadStatus.cancel:
return "已取消";
case DownloadStatus.complete:
return "已完成";
case DownloadStatus.downloading:
return "下载中";
case DownloadStatus.error:
return "下载失败";
case DownloadStatus.errorLoad:
return "无法读取信息";
case DownloadStatus.loadding:
return "读取信息中";
case DownloadStatus.pause:
return "暂停中";
case DownloadStatus.pauseCellular:
return "等待Wi-Fi";
case DownloadStatus.wait:
return "等待下载";
case DownloadStatus.waitNetwork:
return "等待网络连接";
default:
return status.toString();
}
}
Widget buildButton({
required String text,
required IconData icon,
Function()? onPressed,
bool visible = true,
}) {
return Visibility(
visible: visible,
child: Padding(
padding: AppStyle.edgeInsetsL4,
child: TextButton.icon(
style: TextButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
textStyle: const TextStyle(fontSize: 14),
),
onPressed: onPressed,
icon: Icon(
icon,
size: 16,
),
label: Text(text),
),
),
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/modules/common/download/novel/novel_downloaded_view.dart';
import 'package:flutter_dmzj/modules/common/download/novel/novel_downloading_view.dart';
import 'package:flutter_dmzj/services/novel_download_service.dart';
import 'package:get/get.dart';
class NovelDownloadPage extends StatelessWidget {
final int type;
const NovelDownloadPage(this.type, {super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
initialIndex: type,
child: Scaffold(
appBar: AppBar(
title: Container(
alignment: Alignment.center,
padding: const EdgeInsets.only(right: 56),
child: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
indicatorSize: TabBarIndicatorSize.label,
indicatorColor: Theme.of(context).colorScheme.primary,
labelColor: Theme.of(context).colorScheme.primary,
unselectedLabelColor:
Get.isDarkMode ? Colors.white70 : Colors.black87,
tabs: [
const Tab(text: "已完成"),
Obx(
() => Tab(
text: NovelDownloadService.instance.taskQueues.isEmpty
? "下载中"
: "下载中(${NovelDownloadService.instance.taskQueues.length})"),
)
],
),
),
),
body: const TabBarView(
children: [
NovelDownloadedView(),
NovelDownloadingView(),
],
),
),
);
}
}

View File

@@ -0,0 +1,182 @@
import 'dart:async';
import 'package:flutter_dmzj/app/event_bus.dart';
import 'package:flutter_dmzj/models/db/novel_history.dart';
import 'package:flutter_dmzj/models/novel/novel_detail_model.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/novel_download_service.dart';
import 'package:flutter_dmzj/services/db_service.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class NovelDownloadedDetailController extends GetxController {
final NovelDownloadedItem info;
NovelDownloadedDetailController(this.info);
/// 阅读记录
Rx<NovelHistory?> history = Rx<NovelHistory?>(null);
/// 更新漫画记录
StreamSubscription<dynamic>? updateNovelSubscription;
/// 编辑模式
var editMode = false.obs;
RxSet<NovelDetailChapter> selectItems = RxSet<NovelDetailChapter>();
@override
void onInit() {
updateNovelSubscription = EventBus.instance.listen(
EventBus.kUpdatedNovelHistory,
(id) {
if (id == info.novelId) {
getHistory();
}
},
);
getHistory();
super.onInit();
}
@override
void onClose() {
updateNovelSubscription?.cancel();
super.onClose();
}
void getHistory() {
var novelHistory = DBService.instance.getNovelHistory(info.novelId);
if (novelHistory != null) {
history.value = novelHistory;
history.update((val) {});
}
}
/// 开始/继续阅读
void read() {
if (info.volumes.isEmpty) {
SmartDialog.showToast("没有可阅读的章节");
return;
}
if (info.volumes.first.chapters.isEmpty) {
SmartDialog.showToast("没有可阅读的章节");
return;
}
//查找记录
if (history.value != null && history.value!.chapterId != 0) {
NovelDetailChapter? chapter;
for (var volumeItem in info.volumes) {
var chapterItem = volumeItem.chapters.firstWhereOrNull(
(x) => x.chapterId == history.value!.chapterId,
);
if (chapterItem != null) {
chapter = chapterItem;
break;
}
}
if (chapter != null) {
List<NovelDetailChapter> chapters = [];
for (var volume in info.volumes) {
chapters.addAll(volume.chapters);
}
AppNavigator.toNovelReader(
novelId: info.novelId,
novelCover: info.novelCover,
novelTitle: info.novelName,
chapter: chapter,
chapters: chapters,
);
} else {
SmartDialog.showToast("未找到历史记录对应章节,将从头开始阅读");
readStart();
}
} else {
readStart();
}
}
void readStart() {
//从头开始
List<NovelDetailChapter> chapters = [];
for (var volume in info.volumes) {
chapters.addAll(volume.chapters);
}
var chapter = chapters.first;
AppNavigator.toNovelReader(
novelId: info.novelId,
novelCover: info.novelCover,
novelTitle: info.novelName,
chapter: chapter,
chapters: chapters,
);
}
void readChapter(NovelDetailVolume volume, NovelDetailChapter item) {
List<NovelDetailChapter> chapters = [];
for (var volume in info.volumes) {
chapters.addAll(volume.chapters);
}
AppNavigator.toNovelReader(
novelId: info.novelId,
novelCover: info.novelCover,
novelTitle: info.novelName,
chapters: chapters,
chapter: item,
);
}
void toDetail() {
AppNavigator.toNovelDetail(info.novelId);
}
void toAddDownload() {
AppNavigator.toNovelDownloadSelect(info.novelId);
}
void setEditMode() {
selectItems.clear();
editMode.value = true;
}
void exitEditMode() {
selectItems.clear();
editMode.value = false;
}
var isSelectAll = false;
void selectAll() {
if (isSelectAll) {
selectItems.clear();
isSelectAll = false;
return;
}
for (var volume in info.volumes) {
selectItems.addAll(volume.chapters);
}
isSelectAll = true;
}
void delete() {
for (var item in selectItems) {
NovelDownloadService.instance
.deleteChapter(info.novelId, item.volumeId, item.chapterId);
}
exitEditMode();
SmartDialog.showToast("删除成功");
AppNavigator.closePage();
}
void selectItem(NovelDetailChapter item) {
if (selectItems.contains(item)) {
selectItems.remove(item.chapterId);
} else {
selectItems.add(item);
}
}
}

View File

@@ -0,0 +1,289 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/novel/novel_detail_model.dart';
import 'package:flutter_dmzj/modules/common/download/novel/novel_downloaded_detail_controller.dart';
import 'package:flutter_dmzj/services/novel_download_service.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
class NovelDownloadedDetailPage extends StatelessWidget {
final NovelDownloadedItem info;
final NovelDownloadedDetailController controller;
NovelDownloadedDetailPage(this.info, {super.key})
: controller = Get.put(
NovelDownloadedDetailController(info),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(info.novelName),
),
body: ListView.builder(
padding: AppStyle.edgeInsetsA12,
itemCount: info.volumes.length,
itemBuilder: (_, i) {
var item = info.volumes[i];
return _buildChapters(item);
},
),
bottomNavigationBar: BottomAppBar(
child: SizedBox(
height: 48,
child: Obx(
() => Column(
children: [
Visibility(
visible: !controller.editMode.value,
child: Row(
children: [
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.setEditMode,
icon: const Icon(
Remix.checkbox_line,
size: 20,
),
label: const Text("选择"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.toDetail,
icon: const Icon(
Remix.information_line,
size: 20,
),
label: const Text("详情"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.toAddDownload,
icon: const Icon(
Remix.add_line,
size: 20,
),
label: const Text("追加"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.read,
icon: const Icon(
Remix.play_line,
size: 20,
),
label: const Text("阅读"),
),
),
],
),
),
Visibility(
visible: controller.editMode.value,
child: Row(
children: [
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.selectAll,
icon: const Icon(
Remix.checkbox_line,
size: 20,
),
label: const Text("全选"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.delete,
icon: const Icon(
Remix.delete_bin_line,
size: 20,
),
label: const Text("删除"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.exitEditMode,
icon: const Icon(
Remix.close_line,
size: 20,
),
label: const Text("取消"),
),
),
],
),
),
],
),
),
),
),
);
}
Widget _buildChapters(NovelDetailVolume item) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: AppStyle.edgeInsetsV8,
child: Row(children: [
Expanded(
child: Text(
"${item.volumeName}(共${item.chapters.length}话)",
style: Get.textTheme.titleSmall,
),
),
]),
),
ListView.separated(
itemCount: item.chapters.length,
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
separatorBuilder: (_, i) => const Divider(
height: 1,
),
itemBuilder: (_, i) {
var chapter = item.chapters[i];
return Obx(
() => controller.editMode.value
? CheckboxListTile(
title: Text(
chapter.chapterName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Get.textTheme.bodyMedium!.copyWith(
color: controller.history.value?.chapterId ==
chapter.chapterId
? Get.theme.colorScheme.primary
: null,
),
),
contentPadding: AppStyle.edgeInsetsA4,
visualDensity: const VisualDensity(
vertical: VisualDensity.minimumDensity),
value: controller.selectItems.contains(chapter),
onChanged: (e) {
controller.selectItem(chapter);
},
)
: ListTile(
title: Text(
chapter.chapterName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Get.textTheme.bodyMedium!.copyWith(
color: controller.history.value?.chapterId ==
chapter.chapterId
? Get.theme.colorScheme.primary
: null,
),
),
contentPadding: AppStyle.edgeInsetsA4,
visualDensity: const VisualDensity(
vertical: VisualDensity.minimumDensity),
onTap: () {
controller.readChapter(item, chapter);
},
),
);
})
// 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: item.chapters.length,
// itemBuilder: (_, i) {
// var chapter = item.chapters[i];
// return Tooltip(
// message: chapter.chapterName,
// child: Obx(
// () => controller.editMode.value
// ? OutlinedButton(
// style: OutlinedButton.styleFrom(
// foregroundColor:
// controller.selectItems.contains(chapter)
// ? Colors.blue
// : Get.textTheme.bodyMedium!.color,
// side: controller.selectItems.contains(chapter)
// ? const BorderSide(color: Colors.blue)
// : null,
// textStyle: const TextStyle(fontSize: 14),
// tapTargetSize: MaterialTapTargetSize.shrinkWrap,
// minimumSize: const Size.fromHeight(40),
// ),
// onPressed: () {
// controller.selectItem(chapter);
// },
// child: Text(
// item.chapters[i].chapterName,
// textAlign: TextAlign.center,
// overflow: TextOverflow.ellipsis,
// ),
// )
// : OutlinedButton(
// style: OutlinedButton.styleFrom(
// foregroundColor: item.chapters[i].chapterId ==
// controller.history.value?.chapterId
// ? Colors.blue
// : Get.textTheme.bodyMedium!.color,
// textStyle: const TextStyle(fontSize: 14),
// tapTargetSize: MaterialTapTargetSize.shrinkWrap,
// minimumSize: const Size.fromHeight(40),
// ),
// onPressed: () {
// controller.readChapter(item, chapter);
// },
// child: Text(
// item.chapters[i].chapterName,
// textAlign: TextAlign.center,
// overflow: TextOverflow.ellipsis,
// ),
// ),
// ),
// );
// },
// crossAxisCount: count,
// crossAxisSpacing: 8,
// mainAxisSpacing: 8,
// );
// })
],
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/novel_download_service.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/status/app_empty_widget.dart';
import 'package:get/get.dart';
class NovelDownloadedView extends StatelessWidget {
const NovelDownloadedView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Obx(
() => Stack(
children: [
EasyRefresh(
header: const MaterialHeader(),
onRefresh: () async {
NovelDownloadService.instance.updateDownlaoded();
},
child: ListView.separated(
itemCount: NovelDownloadService.instance.downloaded.length,
separatorBuilder: (_, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (_, i) {
var item = NovelDownloadService.instance.downloaded[i];
return buildItem(item);
},
),
),
Offstage(
offstage: NovelDownloadService.instance.downloaded.isNotEmpty,
child: AppEmptyWidget(
onRefresh: () {
NovelDownloadService.instance.updateDownlaoded();
},
),
),
],
),
);
}
Widget buildItem(NovelDownloadedItem item) {
return InkWell(
onTap: () {
AppNavigator.toNovelDownloadDetail(item);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.novelCover,
width: 60,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.novelName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
AppStyle.vGap4,
Text(
"已下载${item.chapterCount}",
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/db/download_status.dart';
import 'package:flutter_dmzj/services/novel_download_service.dart';
import 'package:flutter_dmzj/services/download_task/novel_downloader.dart';
import 'package:flutter_dmzj/widgets/status/app_empty_widget.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
class NovelDownloadingView extends StatelessWidget {
const NovelDownloadingView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: Obx(
() => Stack(
children: [
ListView.separated(
itemCount: NovelDownloadService.instance.taskQueues.length,
separatorBuilder: (_, i) => const Divider(
height: 1,
),
itemBuilder: (_, i) {
var task = NovelDownloadService.instance.taskQueues[i];
return buildItem(task);
},
),
Offstage(
offstage: NovelDownloadService.instance.taskQueues.isNotEmpty,
child: const AppEmptyWidget(),
),
],
),
),
),
BottomAppBar(
child: SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: NovelDownloadService.instance.pauseAll,
icon: const Icon(
Remix.pause_line,
size: 20,
),
label: const Text("暂停全部"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: NovelDownloadService.instance.resumeAll,
icon: const Icon(
Remix.download_line,
size: 20,
),
label: const Text("开始全部"),
),
),
],
),
),
),
],
);
}
Widget buildItem(NovelDownloader task) {
return Obx(
() => Padding(
padding: AppStyle.edgeInsetsA12,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"${task.info.value.volumeName} - ${task.info.value.chapterName}",
),
Text(
task.info.value.novelName,
style: Get.textTheme.bodySmall,
),
Row(
children: [
Expanded(
child: Text(
parseStatus(task.info.value.status),
style: Get.textTheme.bodySmall,
),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
buildButton(
icon: Icons.refresh_rounded,
text: "重试",
visible: task.status == DownloadStatus.error ||
task.status == DownloadStatus.errorLoad,
onPressed: () {
task.retry();
},
),
buildButton(
icon: Icons.play_arrow_rounded,
visible: task.status == DownloadStatus.wait ||
task.status == DownloadStatus.pauseCellular,
text: "开始",
onPressed: () {
task.start();
},
),
buildButton(
icon: Icons.play_arrow_rounded,
visible: task.status == DownloadStatus.pause,
text: "继续",
onPressed: () {
task.resume();
},
),
buildButton(
icon: Icons.pause_rounded,
visible: task.status == DownloadStatus.downloading,
text: "暂停",
onPressed: () {
task.pause();
},
),
buildButton(
icon: Icons.cancel_outlined,
text: "取消",
onPressed: () {
task.cancel();
},
),
],
),
],
),
],
),
),
);
}
String parseStatus(DownloadStatus status) {
switch (status) {
case DownloadStatus.cancel:
return "已取消";
case DownloadStatus.complete:
return "已完成";
case DownloadStatus.downloading:
return "下载中";
case DownloadStatus.error:
return "下载失败";
case DownloadStatus.errorLoad:
return "无法读取信息";
case DownloadStatus.loadding:
return "读取信息中";
case DownloadStatus.pause:
return "暂停中";
case DownloadStatus.pauseCellular:
return "等待Wi-Fi";
case DownloadStatus.wait:
return "等待下载";
case DownloadStatus.waitNetwork:
return "等待网络连接";
default:
return status.toString();
}
}
Widget buildButton({
required String text,
required IconData icon,
Function()? onPressed,
bool visible = true,
}) {
return Visibility(
visible: visible,
child: Padding(
padding: AppStyle.edgeInsetsL4,
child: TextButton.icon(
style: TextButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
textStyle: const TextStyle(fontSize: 14),
),
onPressed: onPressed,
icon: Icon(
icon,
size: 16,
),
label: Text(text),
),
),
);
}
}

View File

@@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
class EmptyPage extends StatelessWidget {
const EmptyPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MediaQuery.of(context).size.width <= AppConstant.kTabletWidth
? Container()
: Scaffold(
resizeToAvoidBottomInset: false,
body: Center(
child: Image.asset(
"assets/images/logo_dmzj.png",
height: 80,
),
),
);
}
}

View File

@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
class TestSubRoutePage extends StatelessWidget {
const TestSubRoutePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("测试路由"),
leading: IconButton(
onPressed: () {
AppNavigator.closePage();
},
icon: const Icon(Icons.arrow_back),
),
),
body: Center(
child: ElevatedButton(
child: const Text("Back"),
onPressed: () {
AppNavigator.closePage();
},
),
),
);
}
}

View File

@@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_color.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:flutter_dmzj/services/user_service.dart';
import 'package:get/get.dart';
import 'package:webview_flutter/webview_flutter.dart';
class WebViewPageController extends BaseController {
final String url;
WebViewPageController(this.url);
final WebViewController webViewController = WebViewController();
var title = "加载中".obs;
@override
void onInit() {
initWebView();
super.onInit();
}
void initWebView() async {
webViewController.setJavaScriptMode(JavaScriptMode.unrestricted);
webViewController.setBackgroundColor(
Get.isDarkMode ? Colors.black : AppColor.backgroundColor);
webViewController.setNavigationDelegate(
NavigationDelegate(
onPageStarted: (String url) {
pageLoadding.value = true;
},
onPageFinished: (String url) async {
pageLoadding.value = false;
title.value = (await webViewController.getTitle()) ?? "";
},
onNavigationRequest: (NavigationRequest request) {
var uri = Uri.parse(request.url);
Log.d(request.url);
if (uri.scheme == "https" || uri.scheme == "http") {
return NavigationDecision.navigate;
}
return NavigationDecision.prevent;
},
),
);
webViewController.loadRequest(Uri.parse(url), headers: {
"Cookie": UserService.instance.userProfile.value?.cookieVal ?? "",
});
/// TODO 无法加载Mixed Content
/// 19年的问题了Flutter还没解决...
/// https://github.com/flutter/flutter/issues/43595
}
void refreshWeb() {
webViewController.reload();
}
}

View File

@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/modules/common/webview/webview_controller.dart';
import 'package:flutter_dmzj/widgets/status/app_error_widget.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:webview_flutter/webview_flutter.dart';
class WebViewPage extends StatelessWidget {
final String url;
final WebViewPageController controller;
WebViewPage({required this.url, Key? key})
: controller = Get.put(
WebViewPageController(url),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
),
super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Obx(() => Text(controller.title.value)),
),
body: Stack(
children: [
Obx(
() => Offstage(
offstage: controller.pageLoadding.value,
child: WebViewWidget(
controller: controller.webViewController,
),
),
),
Obx(
() => Offstage(
offstage: !controller.pageError.value,
child: AppErrorWidget(
errorMsg: controller.errorMsg.value,
onRefresh: () => controller.refreshWeb(),
),
),
),
],
),
bottomNavigationBar: Stack(
children: [
BottomAppBar(
child: SizedBox(
height: 56,
child: Row(
children: [
Expanded(
child: IconButton(
onPressed: () {
controller.webViewController.goBack();
},
icon: const Icon(
Icons.chevron_left,
),
),
),
Expanded(
child: IconButton(
onPressed: () {
controller.webViewController.reload();
},
icon: const Icon(
Icons.refresh,
),
),
),
Expanded(
child: IconButton(
onPressed: () {
controller.webViewController.goForward();
},
icon: const Icon(
Icons.chevron_right,
),
),
),
Expanded(
child: IconButton(
onPressed: () async {
Utils.share(
(await controller.webViewController.currentUrl())
.toString(),
);
},
icon: const Icon(
Icons.share,
size: 20,
),
),
),
Expanded(
child: IconButton(
onPressed: () async {
var url =
await controller.webViewController.currentUrl();
if (url != null) {
launchUrlString(url,
mode: LaunchMode.externalApplication);
}
},
icon: const Icon(
Icons.open_in_browser,
),
),
),
],
),
),
),
Positioned.fill(
top: 0,
left: 0,
child: Obx(
() => Offstage(
offstage: !controller.pageLoadding.value,
child: Container(
alignment: Alignment.topLeft,
child: const LinearProgressIndicator(),
),
),
),
)
],
),
);
}
}

View File

@@ -0,0 +1,100 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/platform_utils.dart';
import 'package:flutter_dmzj/services/app_settings_service.dart';
import 'package:flutter_dmzj/app/dialog_utils.dart';
import 'package:flutter_dmzj/app/event_bus.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/modules/comic/home/comic_home_page.dart';
import 'package:flutter_dmzj/modules/news/home/news_home_controller.dart';
import 'package:flutter_dmzj/modules/news/home/news_home_page.dart';
import 'package:flutter_dmzj/modules/novel/home/novel_home_controller.dart';
import 'package:flutter_dmzj/modules/novel/home/novel_home_page.dart';
import 'package:flutter_dmzj/modules/user/user_home_page.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:multi_split_view/multi_split_view.dart';
class IndexController extends GetxController {
final index = 0.obs;
final showContent = false.obs;
final GlobalKey indexKey = GlobalKey();
final GlobalKey subRouterKey = GlobalKey();
final MultiSplitViewController multiSplitViewController =
MultiSplitViewController(areas: [
Area(minimalSize: 400, size: 500),
]);
/// 双击退出Flag
bool doubleClickExit = false;
/// 双击退出Timer
Timer? doubleClickTimer;
final pages = [
const ComicHomePage(),
const SizedBox(),
const SizedBox(),
const UserHomePage(),
];
@override
void onInit() {
if (PlatformUtils.isWindows) {
// Windows: 预先初始化所有分区控制器确保NavigationView所有PaneItem.body可用
if (!Get.isRegistered<NewsHomeController>()) {
Get.put(NewsHomeController());
}
if (!Get.isRegistered<NovelHomeController>()) {
Get.put(NovelHomeController());
}
pages[1] = const NewsHomePage();
pages[2] = const NovelHomePage();
}
Future.delayed(Duration.zero, showFirstRun);
super.onInit();
}
@override
void onClose() {}
void setIndex(i) {
if (i == 1 && pages[i] is SizedBox) {
Get.put(NewsHomeController());
pages[i] = const NewsHomePage();
} else if (i == 2 && pages[i] is SizedBox) {
Get.put(NovelHomeController());
pages[i] = const NovelHomePage();
}
if (index.value == i) {
EventBus.instance.emit<int>(EventBus.kBottomNavigationBarClicked, i);
}
index.value = i;
}
void showFirstRun() async {
if (AppSettingsService.instance.firstRun) {
AppSettingsService.instance.setNoFirstRun();
DialogUtils.showStatement();
Utils.checkUpdate();
} else {
Utils.checkUpdate();
}
}
void setDoubleExitFlag() {
if (doubleClickExit) {
doubleClickTimer?.cancel();
Get.back();
return;
}
doubleClickExit = true;
SmartDialog.showToast("再按一次退出应用");
doubleClickTimer = Timer(const Duration(seconds: 2), () {
doubleClickExit = false;
doubleClickTimer!.cancel();
});
}
}

View File

@@ -0,0 +1,223 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/platform_utils.dart';
import 'package:flutter_dmzj/modules/common/empty_page.dart';
import 'package:flutter_dmzj/modules/index/index_controller.dart';
import 'package:flutter_dmzj/modules/index/windows_index_page.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/routes/app_pages.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
class IndexPage extends GetView<IndexController> {
const IndexPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// Windows平台使用Fluent UI的NavigationView
if (PlatformUtils.isWindows) {
return const WindowsIndexPage();
}
final content = _buildContentNavigator();
final indexStack = _buildIndexStack();
return OrientationBuilder(
builder: (context, orientation) {
return orientation == Orientation.landscape
? _buildWide(context, indexStack, content)
: _buildNarrow(context, indexStack, content);
},
);
}
Widget _buildNarrow(BuildContext context, Widget indexStack, Widget content) {
return Stack(
children: [
Obx(
() => Scaffold(
body: indexStack,
bottomNavigationBar: NavigationBar(
selectedIndex: controller.index.value,
onDestinationSelected: controller.setIndex,
destinations: const [
NavigationDestination(
icon: Icon(Remix.bear_smile_line),
selectedIcon: Icon(Remix.bear_smile_fill),
label: "漫画",
),
NavigationDestination(
icon: Icon(Remix.article_line),
selectedIcon: Icon(Remix.article_fill),
label: "资讯",
),
NavigationDestination(
icon: Icon(Remix.book_open_line),
selectedIcon: Icon(Remix.book_open_fill),
label: "轻小说",
),
NavigationDestination(
icon: Icon(Remix.user_smile_line),
selectedIcon: Icon(Remix.user_smile_fill),
label: "我的",
),
],
),
),
),
Obx(
() => IgnorePointer(
ignoring: !controller.showContent.value,
child: content,
),
)
],
);
}
Widget _buildWide(BuildContext context, Widget indexStack, Widget content) {
return Scaffold(
body: Row(
children: [
Obx(
() => Padding(
padding: const EdgeInsets.only(right: 2),
child: NavigationRail(
elevation: 2,
labelType: NavigationRailLabelType.all,
onDestinationSelected: controller.setIndex,
selectedIndex: controller.index.value,
leading: SizedBox(
height: AppStyle.statusBarHeight,
),
selectedLabelTextStyle: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.secondary,
),
unselectedLabelTextStyle: TextStyle(
fontSize: 10,
color: Theme.of(context).textTheme.bodyLarge?.color,
),
destinations: const [
NavigationRailDestination(
icon: Icon(Remix.bear_smile_line),
label: Text("漫画"),
),
NavigationRailDestination(
icon: Icon(Remix.article_line),
label: Text("资讯"),
),
NavigationRailDestination(
icon: Icon(Remix.book_open_line),
label: Text("轻小说"),
),
NavigationRailDestination(
icon: Icon(Remix.user_smile_line),
label: Text("我的"),
),
],
),
),
),
Container(
// constraints: const BoxConstraints(maxWidth: 450),
width: 450,
decoration: BoxDecoration(
border: Border(
right: BorderSide(
color: Colors.grey.withOpacity(.1),
),
),
),
child: indexStack,
),
Expanded(
child: content,
),
],
),
);
}
Widget _buildIndexStack() {
return Obx(
() => IndexedStack(
key: controller.indexKey,
index: controller.index.value,
children: controller.pages,
),
);
}
/// 子路由
Widget _buildContentNavigator() {
/// 拦截子路由的返回
return PopScope(
canPop: false,
onPopInvoked: (didPop) {
if (!didPop) {
if (Navigator.canPop(Get.context!)) {
Get.back();
return;
} else if (AppNavigator.subNavigatorKey!.currentState!.canPop()) {
AppNavigator.subNavigatorKey!.currentState!.pop();
return;
}
if (controller.doubleClickExit) {
controller.doubleClickTimer?.cancel();
SystemNavigator.pop();
return;
}
controller.setDoubleExitFlag();
}
},
// onWillPop: () async {
// if (Navigator.canPop(Get.context!)) {
// return true;
// }
// if (AppNavigator.subNavigatorKey!.currentState!.canPop()) {
// AppNavigator.subNavigatorKey!.currentState!.pop();
// return false;
// }
// return true;
// },
child: ClipRect(
child: Navigator(
key: AppNavigator.subNavigatorKey,
initialRoute: '/',
onUnknownRoute: (settings) => GetPageRoute(
page: () => const EmptyPage(),
),
observers: [
SubNavigatorObserver(),
],
onGenerateRoute: AppPages.generateSubRoute,
),
),
);
}
}
/// 子路由监听
class SubNavigatorObserver extends NavigatorObserver {
@override
void didPush(Route route, Route? previousRoute) {
super.didPush(route, previousRoute);
if (previousRoute != null) {
var routeName = route.settings.name ?? "";
AppNavigator.currentContentRouteName = routeName;
Get.find<IndexController>().showContent.value = routeName != '/';
}
}
@override
void didPop(Route route, Route? previousRoute) {
super.didPop(route, previousRoute);
var routeName = previousRoute?.settings.name ?? "";
AppNavigator.currentContentRouteName = routeName;
Get.find<IndexController>().showContent.value = routeName != '/';
}
}

View File

@@ -0,0 +1,150 @@
import 'package:fluent_ui/fluent_ui.dart' as fluent;
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/platform_utils.dart';
import 'package:flutter_dmzj/modules/common/empty_page.dart';
import 'package:flutter_dmzj/modules/index/index_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/routes/app_pages.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
/// Windows平台专用导航页面 - 使用Fluent UI的NavigationView
class WindowsIndexPage extends GetView<IndexController> {
const WindowsIndexPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return fluent.FluentTheme(
data: PlatformUtils.getFluentTheme(context),
child: Obx(
() => fluent.NavigationView(
paneBodyBuilder: (item, body) {
// Builder ensures ctx is INSIDE the FluentTheme ancestor tree
return Builder(
builder: (ctx) => KeyedSubtree(
key: const ValueKey('windows_main_content'),
child: _buildMasterDetail(ctx),
),
);
},
pane: fluent.NavigationPane(
selected: controller.index.value,
onChanged: controller.setIndex,
displayMode: fluent.PaneDisplayMode.auto,
indicator: const fluent.StickyNavigationIndicator(),
items: [
fluent.PaneItem(
icon: const Icon(Remix.bear_smile_line),
title: const Text('漫画'),
body: const SizedBox.shrink(),
),
fluent.PaneItem(
icon: const Icon(Remix.article_line),
title: const Text('资讯'),
body: const SizedBox.shrink(),
),
fluent.PaneItem(
icon: const Icon(Remix.book_open_line),
title: const Text('轻小说'),
body: const SizedBox.shrink(),
),
],
footerItems: [
fluent.PaneItem(
icon: const Icon(Remix.user_smile_line),
title: const Text('我的'),
body: const SizedBox.shrink(),
),
],
),
),
),
);
}
/// 主内容区section列表(左) + 子路由详情(右)
/// 使用Material主题颜色避免FluentTheme.of()需要特定祖先
Widget _buildMasterDetail(BuildContext context) {
final materialTheme = Theme.of(context);
final isDark = materialTheme.brightness == Brightness.dark;
// 使用Material主题颜色衍生背景色
final scaffoldBg = materialTheme.scaffoldBackgroundColor;
final panelBg = isDark ? const Color(0xff202020) : const Color(0xfff0f0f0);
final dividerColor = materialTheme.dividerColor;
return ColoredBox(
color: scaffoldBg,
child: Row(
children: [
// 左侧各模块首页IndexedStack切换
SizedBox(
width: 450,
child: ColoredBox(
color: panelBg,
child: Obx(
() => IndexedStack(
key: controller.indexKey,
index: controller.index.value,
children: controller.pages,
),
),
),
),
// 分隔线
Container(width: 1, color: dividerColor),
// 右侧:子路由(详情页、阅读器等)
Expanded(
child: _buildContentNavigator(),
),
],
),
);
}
/// 子路由导航器(处理详情页、阅读器等)
Widget _buildContentNavigator() {
return PopScope(
canPop: false,
onPopInvoked: (didPop) {
if (!didPop) {
if (Navigator.canPop(Get.context!)) {
Get.back();
return;
}
if (AppNavigator.subNavigatorKey!.currentState!.canPop()) {
AppNavigator.subNavigatorKey!.currentState!.pop();
}
}
},
child: ClipRect(
child: Navigator(
key: AppNavigator.subNavigatorKey,
initialRoute: '/',
onUnknownRoute: (settings) => GetPageRoute(
page: () => const EmptyPage(),
),
observers: [WindowsSubNavigatorObserver()],
onGenerateRoute: AppPages.generateSubRoute,
),
),
);
}
}
/// Windows子路由监听不需要更新showContent因为采用固定master-detail布局
class WindowsSubNavigatorObserver extends NavigatorObserver {
@override
void didPush(Route route, Route? previousRoute) {
super.didPush(route, previousRoute);
if (previousRoute != null) {
final routeName = route.settings.name ?? '';
AppNavigator.currentContentRouteName = routeName;
}
}
@override
void didPop(Route route, Route? previousRoute) {
super.didPop(route, previousRoute);
final routeName = previousRoute?.settings.name ?? '';
AppNavigator.currentContentRouteName = routeName;
}
}

View File

@@ -0,0 +1,398 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_color.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/dialog_utils.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/requests/news_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';
import 'package:universal_html/html.dart' as html;
import 'package:universal_html/parsing.dart';
import 'package:webview_flutter/webview_flutter.dart';
class NewsDetailController extends BaseController {
final String newsUrl;
final String title;
final int id;
final NewsRequest request = NewsRequest();
AppSettingsService get settings => AppSettingsService.instance;
NewsDetailController(
{required this.newsUrl, this.title = "资讯详情", required this.id}) {
newsTitle.value = title;
if (id == 0) {
newsId = int.tryParse(
RegExp(r"/(\d+).html").firstMatch(newsUrl)?.group(1) ?? "0") ??
0;
} else {
newsId = id;
}
}
WebViewController? webViewController =
(Platform.isAndroid || Platform.isIOS) ? WebViewController() : null;
/// 评论数
var commentAmount = 0.obs;
/// 点赞数
var moodAmount = 0.obs;
/// 是否点过赞
var liked = false.obs;
/// 是否已经收藏
var collected = false.obs;
var newsId = 0;
var newsTitle = "资讯详情".obs;
var htmlContent = "".obs;
var author = "".obs;
var photo = "".obs;
var src = "".obs;
var time = "".obs;
var images = <String>[];
@override
void onInit() {
liked.value = DBService.instance.newsLikeBox.containsKey(newsId);
if (Platform.isAndroid || Platform.isIOS) {
initWebView();
} else {
loadHtml();
}
// loadStat();
// checkCollected();
super.onInit();
}
var currentUrl = "";
void initWebView() {
webViewController!.setJavaScriptMode(JavaScriptMode.unrestricted);
webViewController!.setBackgroundColor(
Get.isDarkMode ? Colors.black : AppColor.backgroundColor);
webViewController!.setNavigationDelegate(
NavigationDelegate(
onPageStarted: (String url) {
pageLoadding.value = true;
},
onPageFinished: (String url) async {
try {
await setFontSize();
//防止亮瞎24K钛合金狗眼
if (Get.isDarkMode) {
await webViewController!.runJavaScript("""
document.body.style.background="#000000";
document.getElementsByClassName("min_box")[0].style.background="#000000";
document.getElementsByClassName("news_box")[0].style.color="#f1f2f6";
document.getElementsByClassName("min_box_tit")[0].style.color="#fff";
""");
}
//加载前5张图片
//当Web没有滚动条时图片不会加载这里手动给他加载出来
await webViewController!.runJavaScript("""
\$('.news_box img:lt(5)').each(function () {
\$(this).lazyload({
effect: "fadeIn"
});
});""");
//读取全部的图片
var imagesResult =
await webViewController?.runJavaScriptReturningResult('''
function getImgLinks(){
var imgLinks = [];
\$('img').each(function() {
var src = \$(this).attr('data-original');
if (src && src.startsWith('https://images')) {
imgLinks.push(src);
}
});
console.log(imgLinks);
return ${Platform.isIOS ? "JSON.stringify(imgLinks)" : "imgLinks"};
}
getImgLinks();
''');
if (imagesResult != null && imagesResult != "null") {
List list = json.decode(imagesResult.toString());
images = list.map((e) => e.toString()).toList();
}
} finally {
pageLoadding.value = false;
}
},
onWebResourceError: (WebResourceError error) {},
onNavigationRequest: (NavigationRequest request) async {
var result = await onTapUrl(request.url);
return result
? NavigationDecision.prevent
: NavigationDecision.navigate;
},
),
);
Log.d(newsUrl);
currentUrl = "https://v3api.zaimanhua.com/v3/article/show/$newsId.html";
webViewController!.loadRequest(Uri.parse(currentUrl));
}
void loadHtml() async {
try {
pageError.value = false;
pageLoadding.value = true;
var result = await Dio().get(
newsUrl,
options: Options(
responseType: ResponseType.plain,
),
);
final htmlDocument = parseHtmlDocument(result.data);
var news = htmlDocument.documentElement!.querySelector('.news_box');
htmlContent.value = news!.innerHtml ?? "";
author.value =
htmlDocument.documentElement?.querySelector('.txt1')?.innerText ?? "";
src.value =
htmlDocument.documentElement?.querySelector('.txt2')?.innerText ?? "";
time.value =
htmlDocument.documentElement?.querySelector('.txt3')?.innerText ?? "";
var imgList = htmlDocument.documentElement?.querySelectorAll('img');
var imagesList = <String>[];
for (html.Element img in imgList ?? []) {
var imgSrc = img.getAttribute("data-original");
if (imgSrc != null) {
imagesList.add(imgSrc);
}
}
images = imagesList;
} catch (e) {
pageError.value = true;
errorMsg.value = e.toString();
} finally {
pageLoadding.value = false;
}
}
void loadStat() async {
try {
var result = await request.stat(newsId);
commentAmount.value = result.commentAmount;
moodAmount.value = result.moodAmount;
newsTitle.value = result.title;
} catch (e) {
SmartDialog.showToast(e.toString());
SmartDialog.showToast("读取新闻数据失败:$e");
}
}
void checkCollected() async {
if (!UserService.instance.logined.value) {
return;
}
try {
collected.value = await request.checkCollect(newsId);
} catch (e) {
Log.logPrint(e);
SmartDialog.showToast("检查用户收藏状态失败:$e");
}
}
void refershContent() {
webViewController!.reload();
}
void collect() async {
if (!await UserService.instance.login()) {
return;
}
try {
SmartDialog.showLoading();
await (collected.value
? request.delCollect(newsId)
: request.collect(newsId));
collected.value = !collected.value;
} catch (e) {
Log.logPrint(e);
SmartDialog.showToast(e.toString());
} finally {
SmartDialog.dismiss(status: SmartStatus.loading);
}
}
void like() async {
if (liked.value) {
SmartDialog.showToast("已经点过赞了");
return;
}
try {
SmartDialog.showLoading();
await request.like(newsId);
liked.value = true;
moodAmount.value += 1;
DBService.instance.newsLikeBox.put(newsId, true);
} catch (e) {
SmartDialog.showToast(e.toString());
} finally {
SmartDialog.dismiss(status: SmartStatus.loading);
}
}
void share() {
Utils.share(newsUrl, content: title);
}
void comment() async {
AppNavigator.toComment(objId: newsId, type: AppConstant.kTypeNews);
}
void photoView() {
DialogUtils.showImageViewer(0, images);
}
void showImageView(String imgSrc) {
if (imgSrc.isEmpty) {
return;
}
if (images.contains(imgSrc)) {
DialogUtils.showImageViewer(
images.indexOf(imgSrc),
images,
);
} else {
DialogUtils.showImageViewer(0, [imgSrc]);
}
}
Future<bool> onTapUrl(url) async {
//iOS处理
if (url == currentUrl) {
return false;
}
var uri = Uri.parse(url);
Log.d(url);
if (uri.scheme == "dmzjimage") {
//打开图片
showImageView(uri.queryParameters['src'].toString());
return true;
} else if (uri.scheme == "dmzjandroid") {
var id = int.tryParse(uri.queryParameters["id"].toString()) ?? 0;
if (uri.path == "/cartoon_description") {
AppNavigator.toComicDetail(id);
} else {
AppNavigator.toNovelDetail(id);
}
return true;
} else if (uri.scheme == "https" || uri.scheme == "http") {
if (uri.path.contains("article/")) {
AppNavigator.toNewsDetail(url: url);
} else {
AppNavigator.toWebView(url);
}
return true;
} else {
SmartDialog.showToast("无法打开链接:$url");
return true;
}
}
void showSettings() {
AppNavigator.showBottomSheet(
SizedBox(
height: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const ListTile(
title: Text("设置"),
trailing: IconButton(
onPressed: AppNavigator.closePage,
icon: Icon(Icons.close),
),
contentPadding: AppStyle.edgeInsetsL12,
),
Divider(
height: 1.0,
color: Colors.grey.withOpacity(.2),
),
Obx(
() => ListTile(
title: const Text("字体大小"),
leading: const Icon(Icons.text_fields_rounded),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton(
onPressed: () {
settings.setNewsFontSize(
settings.newsFontSize.value + 1,
);
setFontSize();
},
child: const Icon(
Icons.add,
color: Colors.grey,
),
),
AppStyle.hGap12,
Text("${settings.newsFontSize.value}"),
AppStyle.hGap12,
OutlinedButton(
onPressed: () {
settings.setNewsFontSize(
settings.newsFontSize.value - 1,
);
setFontSize();
},
child: const Icon(
Icons.remove,
color: Colors.grey,
),
),
],
),
),
),
ListTile(
leading: const Icon(Icons.photo),
title: const Text("进入看图模式"),
onTap: () {
AppNavigator.closePage();
photoView();
},
trailing: const Icon(Icons.chevron_right),
),
],
),
),
);
}
Future setFontSize() async {
try {
if (webViewController == null) {
return;
}
await webViewController!.runJavaScript(
'''document.getElementsByClassName("news_box")[0].style.fontSize="${settings.newsFontSize}px";
document.getElementsByClassName("news_box")[0].style.lineHeight="1.6em";
''');
} catch (e) {
Log.logPrint(e);
}
}
}

View File

@@ -0,0 +1,189 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/modules/news/detail/news_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_widget_from_html_core/flutter_widget_from_html_core.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
import 'package:webview_flutter/webview_flutter.dart';
class NewsDetailPage extends StatelessWidget {
final String newsUrl;
final int newsId;
final String title;
final NewsDetailController controller;
NewsDetailPage({
required this.newsUrl,
this.title = "资讯详情",
required this.newsId,
Key? key,
}) : controller = Get.put(
NewsDetailController(id: newsId, newsUrl: newsUrl, title: title),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
),
super(key: key);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Obx(() => Text(controller.newsTitle.value)),
actions: [
IconButton(
onPressed: controller.share,
icon: const Icon(Icons.share),
),
],
),
body: Stack(
children: [
(Platform.isAndroid || Platform.isIOS)
? Obx(
() => Offstage(
offstage: controller.pageLoadding.value,
child: WebViewWidget(
controller: controller.webViewController!,
),
),
)
: buildHtml(),
Obx(
() => Offstage(
offstage: !controller.pageLoadding.value,
child: const AppLoaddingWidget(),
),
),
Obx(
() => Offstage(
offstage: !controller.pageError.value,
child: AppErrorWidget(
errorMsg: controller.errorMsg.value,
onRefresh: controller.refershContent,
),
),
),
],
),
bottomNavigationBar: BottomAppBar(
child: SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: Obx(
() => TextButton.icon(
onPressed: controller.like,
icon: Icon(
controller.liked.value
? Remix.thumb_up_fill
: Remix.thumb_up_line,
size: 20,
),
label: Text(controller.moodAmount > 0
? "${controller.moodAmount}"
: "点赞"),
),
),
),
Expanded(
child: Obx(
() => TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.comment,
icon: const Icon(
Remix.chat_2_line,
size: 20,
),
label: Text(controller.commentAmount > 0
? "${controller.commentAmount}"
: "评论"),
),
),
),
// Expanded(
// child: Obx(
// () => TextButton.icon(
// style: TextButton.styleFrom(
// textStyle: const TextStyle(fontSize: 14),
// ),
// onPressed: controller.collect,
// icon: Icon(
// controller.collected.value
// ? Remix.star_fill
// : Remix.star_line,
// size: 20,
// ),
// label: Text(controller.collected.value ? "已收藏" : "收藏"),
// ),
// ),
// ),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.showSettings,
icon: const Icon(
Remix.settings_line,
size: 20,
),
label: const Text("设置"),
),
),
],
),
),
),
);
}
Widget buildHtml() {
return Obx(
() => ListView(
padding: AppStyle.edgeInsetsA12,
children: [
Text(
controller.title,
style: Get.textTheme.titleLarge,
),
AppStyle.vGap4,
Text(
"${controller.author.value} ${controller.src.value} ${controller.time.value}",
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
AppStyle.vGap12,
HtmlWidget(
controller.htmlContent.value,
textStyle: TextStyle(
fontSize: controller.settings.newsFontSize.value.toDouble(),
),
customWidgetBuilder: (e) {
if (e.localName == "img") {
var imgSrc = e.attributes["src"];
imgSrc ??= e.attributes["data-original"];
return GestureDetector(
child: NetImage(
imgSrc!,
borderRadius: 4,
),
onTap: () {
controller.showImageView(imgSrc ?? "");
},
);
}
return null;
},
onTapUrl: controller.onTapUrl,
),
],
),
);
}
}

View File

@@ -0,0 +1,67 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/event_bus.dart';
import 'package:flutter_dmzj/models/news/news_tag_model.dart';
import 'package:flutter_dmzj/modules/news/home/news_list_controller.dart';
import 'package:flutter_dmzj/requests/news_request.dart';
import 'package:get/get.dart';
class NewsHomeController extends GetxController
with GetTickerProviderStateMixin {
NewsRequest request = NewsRequest();
late TabController tabController;
var loadding = true;
List<NewsTagModel> categores = [];
var error = false;
var errorMsg = "";
StreamSubscription<dynamic>? streamSubscription;
@override
void onInit() {
streamSubscription = EventBus.instance.listen(
EventBus.kBottomNavigationBarClicked,
(index) {
if (index == 1) {
refreshOrScrollTop();
}
},
);
loadCategores();
super.onInit();
}
@override
void onClose() {
streamSubscription?.cancel();
super.onClose();
}
void loadCategores() async {
try {
loadding = true;
error = false;
update();
var category = await request.category();
category.insert(0, NewsTagModel(id: 0, name: "最新"));
tabController = TabController(length: category.length, vsync: this);
categores = category;
} catch (e) {
errorMsg = e.toString();
error = true;
} finally {
loadding = false;
update();
}
}
void refreshOrScrollTop() {
var tabIndex = tabController.index;
BasePageController controller;
controller = Get.find<NewsListController>(tag: "${categores[tabIndex].id}");
controller.scrollToTopOrRefresh();
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/platform_utils.dart';
import 'package:flutter_dmzj/modules/news/home/news_home_controller.dart';
import 'package:flutter_dmzj/modules/news/home/news_list_view.dart';
import 'package:flutter_dmzj/widgets/status/app_error_widget.dart';
import 'package:flutter_dmzj/widgets/status/app_loadding_widget.dart';
import 'package:flutter_dmzj/widgets/tab_appbar.dart';
import 'package:flutter_dmzj/widgets/windows_tab_page.dart';
import 'package:get/get.dart';
class NewsHomePage extends GetView<NewsHomeController> {
const NewsHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return GetBuilder<NewsHomeController>(
init: controller,
builder: (controller) {
if (controller.loadding) {
return const Scaffold(
body: AppLoaddingWidget(),
);
}
if (!controller.loadding && controller.error) {
return Scaffold(
body: AppErrorWidget(
errorMsg: controller.errorMsg,
onRefresh: controller.loadCategores,
),
);
}
if (PlatformUtils.isWindows) {
return WindowsTabPage(
tabs: controller.categores
.map((e) => WindowsTabItem(
label: e.name,
body: NewsListView(tag: e),
))
.toList(),
);
}
return Scaffold(
appBar: TabAppBar(
tabs: controller.categores.map((e) => Tab(text: e.name)).toList(),
controller: controller.tabController,
),
body: TabBarView(
controller: controller.tabController,
children:
controller.categores.map((e) => NewsListView(tag: e)).toList(),
),
);
},
);
}
}

View File

@@ -0,0 +1,40 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/news/news_banner_model.dart';
import 'package:flutter_dmzj/models/news/news_list_item_model.dart';
import 'package:flutter_dmzj/models/news/news_tag_model.dart';
import 'package:flutter_dmzj/requests/news_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class NewsListController extends BasePageController<NewsListItemModel> {
final NewsRequest request = NewsRequest();
final NewsTagModel tag;
NewsListController(this.tag);
RxList<NewsBannerModel> banners = RxList<NewsBannerModel>();
@override
Future<List<NewsListItemModel>> getData(int page, int pageSize) async {
if (tag.id == 0 && page == 1) {
loadBanner();
}
return await request.getNewsList(tag.id, page);
}
void loadBanner() async {
try {
banners.value = await request.banner();
} catch (e) {
SmartDialog.showToast(e.toString());
}
}
void openBanner(NewsBannerModel item) {
AppNavigator.toNewsDetail(
url: item.objectUrl ?? "",
newsId: item.objectId ?? 0,
title: item.title,
);
}
}

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/news/news_tag_model.dart';
import 'package:flutter_dmzj/modules/news/home/news_list_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:flutter_swiper_view/flutter_swiper_view.dart';
import 'package:get/get.dart';
class NewsListView extends StatelessWidget {
final NewsTagModel tag;
final NewsListController controller;
NewsListView({Key? key, required this.tag})
: controller = Get.put(NewsListController(tag), tag: tag.id.toString()),
super(key: key);
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: PageListView(
pageController: controller,
firstRefresh: true,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
header: tag.id == 0 ? buildBanner() : null,
itemBuilder: (context, i) {
var item = controller.list[i];
return InkWell(
onTap: () {
AppNavigator.toNewsDetail(
newsId: item.articleId.toInt(),
title: item.title,
url: item.pageUrl ?? "",
);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.rowPicUrl ?? "",
width: 100,
height: 62,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: SizedBox(
height: 62,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
Utils.formatTimestamp(item.createTime ?? 0),
style: const TextStyle(
color: Colors.grey, fontSize: 12),
),
// Row(
// children: <Widget>[
// const Icon(
// Icons.thumb_up,
// size: 12.0,
// color: Colors.grey,
// ),
// AppStyle.hGap4,
// Text(
// item.moodAmount.toString(),
// style: const TextStyle(
// color: Colors.grey,
// fontSize: 12,
// ),
// ),
// AppStyle.hGap8,
// const Icon(
// Icons.chat,
// size: 12.0,
// color: Colors.grey,
// ),
// AppStyle.hGap4,
// Text(
// item.commentAmount.toString(),
// style: const TextStyle(
// color: Colors.grey,
// fontSize: 12,
// ),
// )
// ],
// )
],
)
],
),
),
),
],
),
),
);
},
),
);
}
Widget buildBanner() {
return Padding(
padding: AppStyle.edgeInsetsH12.copyWith(bottom: 4),
child: Obx(
() => ClipRRect(
borderRadius: AppStyle.radius4,
child: AspectRatio(
aspectRatio: 75 / 40,
child: controller.banners.isEmpty
? const SizedBox()
: Swiper(
itemWidth: 750,
itemHeight: 400,
autoplay: true,
itemCount: controller.banners.length,
onTap: (i) {
controller.openBanner(controller.banners[i]);
},
itemBuilder: (_, i) => NetImage(
controller.banners[i].picUrl,
width: 750,
height: 400,
),
pagination: SwiperCustomPagination(
builder:
(BuildContext context, SwiperPluginConfig config) {
return Align(
alignment: Alignment.bottomCenter,
child: Container(
padding: const EdgeInsets.only(
left: 8,
right: 12,
top: 4,
bottom: 4,
),
//color: Colors.black12,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black38,
Colors.transparent,
],
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
controller
.banners[config.activeIndex].title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14, color: Colors.white),
),
),
AppStyle.hGap8,
PageIndicator(
controller: config.pageController!,
count: config.itemCount,
size: 10,
layout: PageIndicatorLayout.SCALE,
),
],
),
),
);
},
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/novel/category_filter_model.dart';
import 'package:flutter_dmzj/models/novel/category_novel_model.dart';
import 'package:flutter_dmzj/requests/novel_request.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class NovelCategoryDetailController
extends BasePageController<NovelCategoryNovelModel> {
final int id;
NovelCategoryDetailController(this.id);
final NovelRequest request = NovelRequest();
RxList<NovelCategoryFilterModel> filters = RxList<NovelCategoryFilterModel>();
@override
void onInit() {
loadFilter();
super.onInit();
}
String getTitle() {
var items = filters.where((x) => x.selectId.value != 0 && x.title != "排序");
if (items.isEmpty) {
return "全部小说";
} else {
return items
.map((e) =>
e.items.firstWhere((x) => x.tagId == e.selectId.value).tagName)
.join("-");
}
}
void loadFilter() async {
try {
filters.value = await request.categoryFilter();
for (var item in filters) {
var tag = item.items.firstWhereOrNull((x) => x.tagId == id);
if (tag != null) {
item.selectId.value = tag.tagId;
}
}
filters.insert(
0,
NovelCategoryFilterModel(
title: "排序",
items: [
NovelCategoryFilterItemModel(tagId: 1, tagName: "更新排序"),
NovelCategoryFilterItemModel(tagId: 2, tagName: "热度排序"),
],
)..selectId.value = 1,
);
filters.insert(
1,
NovelCategoryFilterModel(
title: "状态",
items: [
NovelCategoryFilterItemModel(tagId: 0, tagName: "全部"),
NovelCategoryFilterItemModel(tagId: 1, tagName: "连载中"),
NovelCategoryFilterItemModel(tagId: 2, tagName: "已完结"),
],
),
);
} catch (e) {
SmartDialog.showToast(e.toString());
}
}
@override
Future<List<NovelCategoryNovelModel>> getData(int page, int pageSize) async {
if (filters.isEmpty) {
return await request.categoryNovel(cateId: id, page: page - 1);
} else {
var sort = filters.first.selectId.value;
var status = filters[1].selectId.value;
var cateId =
filters.firstWhereOrNull((x) => x.title == "题材")?.selectId.value ?? 0;
return await request.categoryNovel(
cateId: cateId, status: status, sort: sort, page: page - 1);
}
}
}

View File

@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/modules/novel/category_detail/novel_category_detail_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_grid_view.dart';
import 'package:flutter_dmzj/widgets/shadow_card.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
class NovelCategoryDetailPage extends StatelessWidget {
final int id;
final NovelCategoryDetailController controller;
NovelCategoryDetailPage(this.id, {super.key})
: controller = Get.put(
NovelCategoryDetailController(id),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Obx(
() => Text(
controller.getTitle(),
),
),
actions: [
Builder(
builder: (BuildContext context) => IconButton(
icon: const Icon(Remix.filter_line),
onPressed: () {
Scaffold.of(context).openEndDrawer();
},
),
)
],
),
endDrawer: Drawer(
child: Obx(
() => SafeArea(
child: ListView.builder(
padding: AppStyle.edgeInsetsA12.copyWith(top: 12),
itemCount: controller.filters.length,
itemBuilder: (context, i) {
var item = controller.filters[i];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: AppStyle.edgeInsetsV12,
child: Text(
item.title,
style: Get.textTheme.titleMedium,
),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: item.items
.map(
(x) => OutlinedButton(
style: OutlinedButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
foregroundColor: x.tagId == item.selectId.value
? Theme.of(context).colorScheme.primary
: Colors.grey,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(
color: x.tagId == item.selectId.value
? Theme.of(context)
.colorScheme
.secondary
: Colors.transparent,
),
),
),
child: Text(
x.tagName,
style: const TextStyle(
fontSize: 14,
),
),
onPressed: () async {
item.selectId.value = x.tagId;
Navigator.pop(context);
controller.refreshData();
},
),
)
.toList(),
),
],
);
},
),
),
),
),
body: LayoutBuilder(builder: (context, constraints) {
var count = constraints.maxWidth ~/ 160;
if (count < 3) count = 3;
return PageGridView(
pageController: controller,
firstRefresh: true,
crossAxisCount: count,
padding: AppStyle.edgeInsetsA12,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
itemBuilder: (context, i) {
var item = controller.list[i];
return ShadowCard(
onTap: () {
AppNavigator.toNovelDetail(item.id);
},
radius: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 27 / 36,
child: NetImage(
item.cover ?? "",
borderRadius: 4,
),
),
AppStyle.vGap4,
Padding(
padding: AppStyle.edgeInsetsH4,
child: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
height: 1.2,
),
),
),
AppStyle.vGap4,
Padding(
padding: AppStyle.edgeInsetsH4,
child: Text(
item.authors ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.grey,
fontSize: 12.0,
height: 1.2,
),
),
),
AppStyle.vGap4,
],
),
);
},
);
}),
);
}
}

View File

@@ -0,0 +1,243 @@
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/db/novel_history.dart';
import 'package:flutter_dmzj/models/novel/novel_detail_model.dart';
import 'package:flutter_dmzj/requests/novel_request.dart';
import 'package:flutter_dmzj/requests/user_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.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 NovelDetailControler extends BaseController {
final int novelId;
NovelDetailControler(this.novelId);
final NovelRequest request = NovelRequest();
final UserRequest userRequest = UserRequest();
Rx<NovelDetailInfo> detail = Rx<NovelDetailInfo>(NovelDetailInfo.empty());
var expandDescription = false.obs;
/// 是否已订阅
var subscribeStatus = false.obs;
/// 阅读记录
Rx<NovelHistory?> history = Rx<NovelHistory?>(null);
/// 更新小说记录
StreamSubscription<dynamic>? updateNovelSubscription;
@override
void onInit() {
updateNovelSubscription = EventBus.instance.listen(
EventBus.kUpdatedNovelHistory,
(id) {
if (id == novelId) {
getHistory();
}
},
);
// 从本地读取订阅状态
subscribeStatus.value =
UserService.instance.subscribedNovelIds.contains(novelId);
getHistory();
loadDetail();
loadSubscribeStatus();
//updateSubscribeRead();
super.onInit();
}
void refreshDetail() {
getHistory();
loadDetail();
loadSubscribeStatus();
}
/// 更新订阅的阅读状态
void updateSubscribeRead() {
try {
userRequest.subscribeRead(id: novelId, type: AppConstant.kTypeNovel);
} catch (e) {
Log.logPrint(e);
}
}
@override
void onClose() {
updateNovelSubscription?.cancel();
super.onClose();
}
void getHistory() {
var novelHistory = DBService.instance.getNovelHistory(novelId);
if (novelHistory != null) {
history.value = novelHistory;
history.update((val) {});
}
}
/// 加载信息
void loadDetail() async {
try {
pageLoadding.value = true;
pageError.value = false;
var result = await request.novelDetail(novelId: novelId);
detail.value = NovelDetailInfo.fromJson(result.data);
await loadChapter();
} catch (e) {
pageError.value = true;
errorMsg.value = e.toString();
} finally {
pageLoadding.value = false;
}
}
Future loadChapter() async {
try {
var result = await request.novelChapter(novelId: novelId);
detail.value.volume.value =
result.map((e) => NovelDetailVolume.fromJson(e)).toList();
} catch (e) {
SmartDialog.showToast("无法读取小说章节:$e");
}
}
/// 检查订阅状态
void loadSubscribeStatus() async {
try {
var result = await userRequest.checkSubscribeStatus(
objId: novelId,
type: AppConstant.kTypeNovel,
);
subscribeStatus.value = result;
if (subscribeStatus.value) {
UserService.instance.subscribedNovelIds.add(novelId);
} else {
UserService.instance.subscribedNovelIds.remove(novelId);
}
} catch (e) {
Log.logPrint(e);
}
}
/// 查看评论
void comment() {
AppNavigator.toComment(objId: novelId, type: AppConstant.kTypeNovel);
}
/// 分享
void share() {
Utils.share(
"http://q.idmzj.com/$novelId/index.shtml",
content: detail.value.name,
);
}
/// 订阅
void subscribe() async {
var result = await (subscribeStatus.value
? UserService.instance
.cancelSubscribe([novelId], AppConstant.kTypeNovel)
: UserService.instance.addSubscribe([novelId], AppConstant.kTypeNovel));
if (result) {
subscribeStatus.value = !subscribeStatus.value;
}
}
/// 下载
void download() {
AppNavigator.toNovelDownloadSelect(novelId);
}
/// 开始/继续阅读
void read() {
if (detail.value.volume.isEmpty) {
SmartDialog.showToast("没有可阅读的章节");
return;
}
if (detail.value.volume.first.chapters.isEmpty) {
SmartDialog.showToast("没有可阅读的章节");
return;
}
//查找记录
if (history.value != null && history.value!.chapterId != 0) {
NovelDetailChapter? chapter;
for (var volumeItem in detail.value.volume) {
var chapterItem = volumeItem.chapters.firstWhereOrNull(
(x) => x.chapterId == history.value!.chapterId,
);
if (chapterItem != null) {
chapter = chapterItem;
break;
}
}
if (chapter != null) {
List<NovelDetailChapter> chapters = [];
for (var volume in detail.value.volume) {
chapters.addAll(volume.chapters);
}
AppNavigator.toNovelReader(
novelId: novelId,
novelCover: detail.value.cover,
novelTitle: detail.value.name,
chapter: chapter,
chapters: chapters,
);
} else {
SmartDialog.showToast("未找到历史记录对应章节,将从头开始阅读");
readStart();
}
} else {
readStart();
}
}
void readStart() {
//从头开始
List<NovelDetailChapter> chapters = [];
for (var volume in detail.value.volume) {
chapters.addAll(volume.chapters);
}
var chapter = chapters.first;
AppNavigator.toNovelReader(
novelId: novelId,
novelCover: detail.value.cover,
novelTitle: detail.value.name,
chapter: chapter,
chapters: chapters,
);
}
void readChapter(NovelDetailVolume volume, NovelDetailChapter item) {
List<NovelDetailChapter> chapters = [];
for (var volume in detail.value.volume) {
chapters.addAll(volume.chapters);
}
AppNavigator.toNovelReader(
novelId: novelId,
novelCover: detail.value.cover,
novelTitle: detail.value.name,
chapters: chapters,
chapter: item,
);
}
void toAuthorDetail(String e) {
AppNavigator.toNovelSearch(keyword: e);
}
void toCategoryDetail(String e) {
AppNavigator.toNovelSearch(keyword: e);
}
}

View File

@@ -0,0 +1,361 @@
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/novel/detail/novel_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:get/get.dart';
import 'package:remixicon/remixicon.dart';
class NovelDetailPage extends StatelessWidget {
final int id;
final NovelDetailControler controller;
NovelDetailPage(this.id, {super.key})
: controller = Get.put(
NovelDetailControler(id),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Obx(
() => Text(
controller.detail.value.name.isEmpty
? "小说详情"
: controller.detail.value.name,
),
),
actions: [
IconButton(
onPressed: controller.share,
icon: const Icon(Icons.share),
),
],
),
body: Stack(
children: [
Obx(
() => Offstage(
offstage: controller.detail.value.novelId == 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?.volumeName ?? ""} ${controller.history.value?.chapterName ?? ""}",
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("下载"),
),
),
],
),
),
),
);
}
Widget _buildHeader() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
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.name,
style: Get.textTheme.titleMedium,
),
AppStyle.vGap8,
_buildInfoItems(
iconData: Remix.user_smile_line,
children: controller.detail.value.authors
.split("/")
.map(
(e) => GestureDetector(
onTap: () => controller.toAuthorDetail(e),
child: Text(
e,
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).join("/"),
iconData: Remix.hashtag,
),
_buildInfo(
title: "人气 ${controller.detail.value.hotHits}",
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}",
iconData: Icons.schedule,
),
],
),
),
],
),
AppStyle.vGap12,
GestureDetector(
onTap: () {
controller.expandDescription.value =
!controller.expandDescription.value;
},
child: Text(
controller.detail.value.introduction,
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 Obx(
() => Column(
children: controller.detail.value.volume
.map(
(item) => ExpansionTile(
title: Text(
"${item.volumeName}(共${item.chapters.length}章)",
style: Get.textTheme.titleSmall,
),
tilePadding: AppStyle.edgeInsetsH4,
children: [
ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
itemCount: item.chapters.length,
separatorBuilder: (_, i) => const Divider(
height: 1,
),
itemBuilder: (context, i) {
var chapter = item.chapters[i];
return ListTile(
title: Text(
chapter.chapterName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Get.textTheme.bodyMedium!.copyWith(
color: controller.history.value?.chapterId ==
chapter.chapterId
? Get.theme.colorScheme.primary
: null,
),
),
contentPadding: AppStyle.edgeInsetsA4,
visualDensity: const VisualDensity(
vertical: VisualDensity.minimumDensity),
onTap: () {
controller.readChapter(item, chapter);
},
);
},
),
],
),
)
.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,22 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/novel/category_model.dart';
import 'package:flutter_dmzj/requests/novel_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
class NovelCategoryController extends BasePageController<NovelCategoryModel> {
final NovelRequest request = NovelRequest();
@override
Future<List<NovelCategoryModel>> getData(int page, int pageSize) async {
if (page > 1) {
return [];
}
var ls = await request.categores();
return ls;
}
void toDetail(NovelCategoryModel item) {
AppNavigator.toNovelCategoryDetail(item.tagId);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/modules/novel/home/category/novel_category_controller.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_grid_view.dart';
import 'package:flutter_dmzj/widgets/shadow_card.dart';
import 'package:get/get.dart';
class NovelCategoryView extends StatelessWidget {
final NovelCategoryController controller;
NovelCategoryView({Key? key})
: controller = Get.put(NovelCategoryController()),
super(key: key);
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
var count = constraints.maxWidth ~/ 160;
if (count < 3) count = 3;
return KeepAliveWrapper(
child: PageGridView(
pageController: controller,
firstRefresh: true,
loadMore: false,
crossAxisCount: count,
padding: AppStyle.edgeInsetsH12.copyWith(bottom: 12),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
itemBuilder: (context, i) {
var item = controller.list[i];
return ShadowCard(
onTap: () {
controller.toDetail(item);
},
child: Column(
children: [
AspectRatio(
aspectRatio: 1.0,
child: NetImage(
item.cover,
borderRadius: 8,
),
),
Padding(
padding: AppStyle.edgeInsetsA8,
child: Text(
item.title,
textAlign: TextAlign.center,
style: const TextStyle(height: 1),
),
),
],
),
);
},
),
);
});
}
}

View File

@@ -0,0 +1,14 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/novel/latest_model.dart';
import 'package:flutter_dmzj/requests/novel_request.dart';
class NovelLatestController extends BasePageController<NovelLatestModel> {
final NovelRequest request = NovelRequest();
@override
Future<List<NovelLatestModel>> getData(int page, int pageSize) async {
var ls = await request.latest(page: page);
return ls;
}
}

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/novel/latest_model.dart';
import 'package:flutter_dmzj/modules/novel/home/latest/novel_latest_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/user_service.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
class NovelLatestView extends StatelessWidget {
final NovelLatestController controller;
NovelLatestView({Key? key})
: controller = Get.put(NovelLatestController()),
super(key: key);
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: PageListView(
pageController: controller,
firstRefresh: true,
showPageLoadding: false,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
),
);
}
Widget buildItem(NovelLatestModel item) {
return InkWell(
onTap: () {
AppNavigator.toNovelDetail(item.id);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.cover ?? "",
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text.rich(
TextSpan(children: [
const WidgetSpan(
child: Icon(
Icons.account_circle,
color: Colors.grey,
size: 18,
)),
const TextSpan(
text: " ",
),
TextSpan(
text: item.authors,
style:
const TextStyle(color: Colors.grey, fontSize: 14))
]),
),
const SizedBox(height: 2),
Text(item.types ?? "",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
const SizedBox(height: 2),
Text(item.lastName ?? "",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
Center(
child: Obx(
() => UserService.instance.subscribedNovelIds.contains(item.id)
? IconButton(
icon: const Icon(Icons.favorite),
onPressed: () {
UserService.instance.cancelSubscribe(
[item.id],
AppConstant.kTypeNovel,
);
},
)
: IconButton(
icon: const Icon(Icons.favorite_border),
onPressed: () {
UserService.instance.addSubscribe(
[item.id],
AppConstant.kTypeNovel,
);
},
),
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,52 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/event_bus.dart';
import 'package:flutter_dmzj/modules/novel/home/category/novel_category_controller.dart';
import 'package:flutter_dmzj/modules/novel/home/latest/novel_latest_controller.dart';
import 'package:flutter_dmzj/modules/novel/home/rank/novel_rank_controller.dart';
import 'package:flutter_dmzj/modules/novel/home/recommend/novel_recommend_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:get/get.dart';
class NovelHomeController extends GetxController
with GetTickerProviderStateMixin {
late TabController tabController;
StreamSubscription<dynamic>? streamSubscription;
@override
void onInit() {
streamSubscription = EventBus.instance.listen(
EventBus.kBottomNavigationBarClicked,
(index) {
if (index == 2) {
refreshOrScrollTop();
}
},
);
tabController = TabController(length: 3, vsync: this);
super.onInit();
}
void refreshOrScrollTop() {
var tabIndex = tabController.index;
BasePageController? controller;
if (tabIndex == 0) {
controller = Get.find<NovelRecommendController>();
} else if (tabIndex == 1) {
controller = Get.find<NovelLatestController>();
} else if (tabIndex == 2) {
controller = Get.find<NovelCategoryController>();
} else if (tabIndex == 3) {
controller = Get.find<NovelRankController>();
}
controller?.scrollToTopOrRefresh();
}
void search() {
AppNavigator.toNovelSearch();
}
}

View File

@@ -0,0 +1,57 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/platform_utils.dart';
import 'package:flutter_dmzj/modules/novel/home/category/novel_category_view.dart';
import 'package:flutter_dmzj/modules/novel/home/latest/novel_latest_view.dart';
import 'package:flutter_dmzj/modules/novel/home/novel_home_controller.dart';
import 'package:flutter_dmzj/modules/novel/home/recommend/novel_recommend_view.dart';
import 'package:flutter_dmzj/widgets/tab_appbar.dart';
import 'package:flutter_dmzj/widgets/windows_tab_page.dart';
import 'package:get/get.dart';
class NovelHomePage extends GetView<NovelHomeController> {
const NovelHomePage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
if (PlatformUtils.isWindows) {
return WindowsTabPage(
tabs: [
WindowsTabItem(label: '推荐', body: NovelRecommendView()),
WindowsTabItem(label: '更新', body: NovelLatestView()),
WindowsTabItem(label: '分类', body: NovelCategoryView()),
],
headerAction: IconButton(
onPressed: controller.search,
icon: const Icon(Icons.search),
),
);
}
return Scaffold(
appBar: TabAppBar(
tabs: const [
Tab(text: "推荐"),
Tab(text: "更新"),
Tab(text: "分类"),
//Tab(text: "排行"),
],
controller: controller.tabController,
action: IconButton(
onPressed: controller.search,
icon: const Icon(
Icons.search,
),
),
),
body: TabBarView(
controller: controller.tabController,
children: [
NovelRecommendView(),
NovelLatestView(),
NovelCategoryView(),
//NovelRankView(),
],
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/novel/rank_model.dart';
import 'package:flutter_dmzj/requests/novel_request.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class NovelRankController extends BasePageController<NovelRankModel> {
final NovelRequest request = NovelRequest();
RxMap<int, String> tags = {
0: "全部分类",
}.obs;
var tag = 0.obs;
Map<int, String> rankTypes = {
0: "人气排行",
1: "订阅排行",
};
var rankType = 0.obs;
@override
void onInit() {
loadFilter();
super.onInit();
}
void loadFilter() async {
try {
tags.value = await request.rankFilter();
} catch (e) {
SmartDialog.showToast(e.toString());
}
}
@override
Future<List<NovelRankModel>> getData(int page, int pageSize) async {
var ls = await request.rank(
tagId: tag.value,
sort: rankType.value,
page: page - 1,
);
return ls;
}
}

View File

@@ -0,0 +1,191 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/novel/rank_model.dart';
import 'package:flutter_dmzj/modules/novel/home/rank/novel_rank_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/user_service.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
class NovelRankView extends StatelessWidget {
final NovelRankController controller;
NovelRankView({Key? key})
: controller = Get.put(NovelRankController()),
super(key: key);
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: Column(
children: [
Obx(
() => Row(
children: [
buildFilter(
// ignore: invalid_use_of_protected_member
types: controller.tags.value,
value: controller.tag.value,
onSelected: (e) {
controller.tag.value = e;
controller.refreshData();
},
),
buildFilter(
types: controller.rankTypes,
value: controller.rankType.value,
onSelected: (e) {
controller.rankType.value = e;
controller.refreshData();
},
),
],
),
),
AppStyle.vGap12,
Expanded(
child: PageListView(
pageController: controller,
firstRefresh: true,
showPageLoadding: false,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
),
),
],
),
);
}
Widget buildFilter({
required Map<int, String> types,
required int value,
required Function(int) onSelected,
}) {
return Expanded(
child: PopupMenuButton<int>(
onSelected: onSelected,
itemBuilder: (c) => types.keys
.map(
(k) => CheckedPopupMenuItem<int>(
value: k,
checked: k == value,
child: Text(types[k] ?? ""),
),
)
.toList(),
child: SizedBox(
height: 36,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
types[value] ?? "",
),
const Icon(
Icons.arrow_drop_down,
color: Colors.grey,
)
],
),
),
),
);
}
Widget buildItem(NovelRankModel item) {
return InkWell(
onTap: () {
AppNavigator.toNovelDetail(item.id);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.cover,
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text.rich(
TextSpan(children: [
const WidgetSpan(
child: Icon(
Icons.account_circle,
color: Colors.grey,
size: 18,
)),
const TextSpan(
text: " ",
),
TextSpan(
text: item.authors,
style:
const TextStyle(color: Colors.grey, fontSize: 14))
]),
),
const SizedBox(height: 2),
Text(item.types.join("/"),
style: const TextStyle(color: Colors.grey, fontSize: 14)),
const SizedBox(height: 2),
Text(item.lastUpdateChapterName,
style: const TextStyle(color: Colors.grey, fontSize: 14)),
const SizedBox(height: 2),
Text("更新于${Utils.formatTimestamp(item.lastUpdateTime)}",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
Center(
child: Obx(
() => UserService.instance.subscribedNovelIds.contains(item.id)
? IconButton(
icon: const Icon(Icons.favorite),
onPressed: () {
UserService.instance.cancelSubscribe(
[item.id],
AppConstant.kTypeNovel,
);
},
)
: IconButton(
icon: const Icon(Icons.favorite_border),
onPressed: () {
UserService.instance.addSubscribe(
[item.id],
AppConstant.kTypeNovel,
);
},
),
),
)
],
),
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'dart:async';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/novel/recommend_model.dart';
import 'package:flutter_dmzj/modules/novel/home/novel_home_controller.dart';
import 'package:flutter_dmzj/requests/novel_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher_string.dart';
class NovelRecommendController extends BasePageController<NovelRecommendModel> {
final NovelRequest request = NovelRequest();
@override
Future<List<NovelRecommendModel>> getData(int page, int pageSize) async {
var ls = await request.recommend();
return ls;
}
void openDetail(NovelRecommendItemModel item) {
//漫画=1
if (item.type == null || item.type == 2) {
AppNavigator.toNovelDetail(
item.objId ?? item.id ?? 0,
);
} else if (item.type == 1) {
//专题=5
AppNavigator.toComicDetail(
item.objId ?? 0,
);
} else if (item.type == 5) {
//专题=5
AppNavigator.toSpecialDetail(
item.objId ?? 0,
);
} else if (item.type == 6) {
//网页=6
AppNavigator.toWebView(item.url ?? "");
} else if (item.type == 7) {
//新闻=7
AppNavigator.toNewsDetail(
url: item.url ?? "",
newsId: item.objId ?? 0,
title: item.title,
);
} else if (item.type == 8) {
//作者=8
AppNavigator.toComicAuthorDetail(item.objId ?? 0);
} else if (item.type == 13) {
//社区=13
//直接跳转至网页
launchUrlString(
"http://m.forum.idmzj.com/thread/detail?tid=${item.objId}");
} else {
SmartDialog.showToast("未知类型,无法跳转");
}
}
void toLatest() {
var homeController = Get.find<NovelHomeController>();
homeController.tabController.animateTo(1);
}
void toMySubscribe() {}
}

View File

@@ -0,0 +1,229 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/novel/recommend_model.dart';
import 'package:flutter_dmzj/modules/novel/home/recommend/novel_recommend_controller.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:flutter_dmzj/widgets/refresh_until_widget.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:flutter_swiper_view/flutter_swiper_view.dart';
import 'package:get/get.dart';
class NovelRecommendView extends StatelessWidget {
final NovelRecommendController controller;
NovelRecommendView({Key? key})
: controller = Get.put(NovelRecommendController()),
super(key: key);
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: PageListView(
pageController: controller,
padding: AppStyle.edgeInsetsH12,
firstRefresh: true,
loadMore: false,
showPageLoadding: true,
itemBuilder: (context, i) {
var item = controller.list[i];
if (item.categoryId == 57) {
return buildBanner(item);
}
Widget? action;
if (item.categoryId == 58) {
action = buildShowMore(onTap: controller.toLatest);
}
return buildCard(
context,
child: buildTreeColumnGridView(item.data),
title: item.title.toString(),
action: action,
);
},
),
);
}
Widget buildCard(
BuildContext context, {
required Widget child,
required String title,
Widget? action,
}) {
return Padding(
padding: AppStyle.edgeInsetsB8,
child: Container(
decoration: BoxDecoration(
borderRadius: AppStyle.radius8,
),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 16, height: 1.0, fontWeight: FontWeight.bold),
),
),
SizedBox(
height: 48,
child: action,
),
],
),
child,
],
),
),
);
}
Widget buildShowMore({required Function() onTap}) {
return GestureDetector(
onTap: onTap,
child: const Row(
children: [
Text(
"查看更多",
style: TextStyle(fontSize: 14, color: Colors.grey),
),
Icon(Icons.chevron_right, size: 18, color: Colors.grey),
],
),
);
}
Widget buildRefresh({required Future Function() onRefresh}) {
return RefreshUntilWidget(onRefresh: onRefresh, text: "换一批");
}
Widget buildBanner(NovelRecommendModel item) {
return Padding(
padding: AppStyle.edgeInsetsB12,
child: ClipRRect(
borderRadius: AppStyle.radius4,
child: AspectRatio(
aspectRatio: 7.5 / 4,
child: Swiper(
itemWidth: 750,
itemHeight: 400,
autoplay: true,
itemCount: item.data.length,
itemBuilder: (_, i) => NetImage(
item.data[i].cover,
width: 750,
height: 400,
),
onTap: (i) {
controller.openDetail(item.data[i]);
},
pagination: SwiperCustomPagination(
builder: (BuildContext context, SwiperPluginConfig config) {
return Align(
alignment: Alignment.bottomCenter,
child: Container(
padding: const EdgeInsets.only(
left: 8,
right: 12,
top: 4,
bottom: 4,
),
//color: Colors.black12,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black38,
Colors.transparent,
],
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
item.data[config.activeIndex].title,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14, color: Colors.white),
),
),
AppStyle.hGap8,
PageIndicator(
controller: config.pageController!,
count: config.itemCount,
size: 10,
layout: PageIndicatorLayout.SCALE,
),
],
),
),
);
},
),
),
),
),
);
}
Widget buildTreeColumnGridView(List<NovelRecommendItemModel> items) {
return MasonryGridView.count(
padding: EdgeInsets.zero,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
crossAxisCount: 3,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
itemCount: items.length,
itemBuilder: (_, i) {
var item = items[i];
return InkWell(
onTap: () => controller.openDetail(item),
borderRadius: AppStyle.radius4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: AppStyle.radius4,
child: AspectRatio(
aspectRatio: 27 / 36,
child: NetImage(
item.cover,
width: 270,
height: 360,
),
),
),
AppStyle.vGap8,
Text(
item.title,
maxLines: 1,
style: const TextStyle(height: 1.2),
overflow: TextOverflow.ellipsis,
),
Text(
item.subTitle ?? item.status ?? '',
maxLines: 1,
style: const TextStyle(
height: 1.2,
fontSize: 12,
color: Colors.grey,
overflow: TextOverflow.ellipsis,
),
),
AppStyle.vGap8,
],
),
);
},
);
}
}

View File

@@ -0,0 +1,405 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:get/get.dart';
class NovelHorizontalReader extends StatefulWidget {
final String text;
final EdgeInsets? padding;
final TextStyle style;
final PageController? controller;
final bool reverse;
final Function(int index, int max)? onPageChanged;
const NovelHorizontalReader(
this.text, {
required this.style,
this.controller,
this.padding,
this.reverse = false,
this.onPageChanged,
Key? key,
}) : super(key: key);
@override
State<NovelHorizontalReader> createState() => _NovelHorizontalReaderState();
}
class _NovelHorizontalReaderState extends State<NovelHorizontalReader>
with WidgetsBindingObserver {
List<List<String>> textPages = [];
Size _lastSize = const Size(0, 0);
TextStyle textStyle = const TextStyle();
double maxWidth = 500;
double maxHeight = 800;
String text = "";
double fontHieght = 16.0;
EdgeInsets padding = EdgeInsets.zero;
int index = 0;
@override
void initState() {
super.initState();
_lastSize = Get.size;
WidgetsBinding.instance.addObserver(this);
resetText();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeMetrics() {
if (_lastSize != Get.size) {
_lastSize = Get.size;
resetText();
}
}
void resetText() {
text = widget.text;
textStyle = widget.style;
padding = widget.padding ?? EdgeInsets.zero;
maxWidth = Get.width - padding.left - padding.right;
maxHeight = Get.height -
//AppStyle.statusBarHeight -
//AppStyle.bottomBarHeight -
padding.top -
padding.bottom;
if (text.isEmpty) {
setState(() {
textPages = [];
});
return;
}
initText();
}
@override
void didUpdateWidget(covariant NovelHorizontalReader oldWidget) {
super.didUpdateWidget(oldWidget);
if ((widget.text != oldWidget.text) ||
widget.style != oldWidget.style ||
widget.padding != oldWidget.padding) {
if (widget.text != oldWidget.text) {
index = 0;
setState(() {
textPages = [];
});
}
resetText();
}
}
/// 分割文本
Future initText() async {
var startTime = DateTime.now().millisecondsSinceEpoch;
var fontSize = (textStyle.fontSize ?? 16).toDouble();
var lineHeight = textStyle.height ?? 1.5;
// 计算出出各个类型的大小
Size chineseCharSize = calcFontSize("",
fontSize: fontSize.toDouble(), lineHeight: lineHeight);
fontHieght = chineseCharSize.height;
Size englishCharSize = calcFontSize("z",
fontSize: fontSize.toDouble(), lineHeight: lineHeight);
Size symbolCharSize = calcFontSize(",",
fontSize: fontSize.toDouble(), lineHeight: lineHeight);
Size spaceCharSize = calcFontSize(" ",
fontSize: fontSize.toDouble(), lineHeight: lineHeight);
// 计算可渲染的最大行数
int maxLine = (maxHeight / chineseCharSize.height).floor();
// 在新线程中进行分页
var pages = await compute(
splitText,
ComputeParameter(
content: text,
fontSize: fontSize.toDouble(),
width: maxWidth,
maxLine: maxLine,
lineHeight: lineHeight,
chineseWidth: chineseCharSize.width,
englishWidth: englishCharSize.width,
symbolWidth: symbolCharSize.width,
spaceWidth: spaceCharSize.width,
),
);
Log.d("耗时:${DateTime.now().millisecondsSinceEpoch - startTime}ms");
Log.d("页数:${pages.length}");
widget.onPageChanged?.call(index, pages.length);
setState(() {
textPages = pages;
});
}
/// 文本处理、分页
/// 由于TextPainter.layout无法在isolate中使用且计算极其耗时所以手动写一个处理方法
/// 处理一段12万字的文本TextPainter.layout需要耗时16000ms左右此方法则可以到1600ms且能用isolate
/// 该方法还不是很完善,符号换行等还未实现,速度也可以再优化
static List<List<String>> splitText(
ComputeParameter parameter,
) {
var str = parameter.content;
Log.w("字数:${str.length}");
// 定义正则表达式(匹配中文字符、英文单词、符号、全角符号、数字串)
//RegExp reg = RegExp(r"([\u4e00-\u9fa5]|\b\w+\b|\x20| |\S|\p{Han}|\n)");
RegExp reg = RegExp(r"([^\x00-\xff]|\b\w+\b|\p{P}|\x20|\S|\u3000|\n)");
// 使用正则表达式分割字符串
List<String> resultList =
reg.allMatches(str).map((match) => match.group(0) ?? "").toList();
List<CharInfo> chars = [];
final chineseExp = RegExp(r"[^\x00-\xff]");
final wordExp = RegExp(r"\w+");
final symbolExp = RegExp(r"\p{P}");
final newLineExp = RegExp(r"\n");
for (var item in resultList) {
if (chineseExp.hasMatch(item)) {
chars.add(
CharInfo(
text: item,
width: parameter.chineseWidth,
type: CharType.chinese,
),
);
continue;
}
if (wordExp.hasMatch(item)) {
chars.add(
CharInfo(
text: item,
width: parameter.englishWidth * item.length,
type: CharType.word),
);
continue;
}
if (newLineExp.hasMatch(item)) {
chars.add(
CharInfo(text: "", width: 0, type: CharType.newline),
);
continue;
}
if (item == " ") {
chars.add(
CharInfo(
text: item,
width: parameter.spaceWidth,
type: CharType.symbol,
),
);
continue;
}
if (symbolExp.hasMatch(item)) {
chars.add(
CharInfo(
text: item, width: parameter.symbolWidth, type: CharType.symbol),
);
continue;
}
chars.add(
CharInfo(
text: item,
width: parameter.symbolWidth,
type: CharType.symbol,
),
);
}
//开始分页
List<String> rows = [];
List<List<String>> pages = [];
String rowStr = "";
double rowWidth = 0;
for (var item in chars) {
//是否超出了最大行数
if (rows.length >= parameter.maxLine) {
pages.add(rows);
rows = [];
}
//新行
if (item.type == CharType.newline) {
rows.add(rowStr);
rowStr = "";
rowWidth = 0;
//rowStr += item.text;
continue;
}
//是否超出了最大宽度
if ((rowWidth + item.width) > parameter.width) {
rows.add(rowStr);
rowStr = "";
rowWidth = 0;
}
rowStr += item.text;
rowWidth += item.width;
}
rows.add(rowStr);
pages.add(rows);
if (pages.length == 1 &&
pages.first.length == 1 &&
pages.first.first.isEmpty) {
return [];
}
return pages;
}
/// 计算文字大小
Size calcFontSize(
String text, {
required double fontSize,
required double lineHeight,
}) {
TextPainter textPainter = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(
fontSize: fontSize,
height: lineHeight,
locale: PlatformDispatcher.instance.locale,
),
),
textDirection: TextDirection.ltr,
maxLines: 1,
);
textPainter.layout(maxWidth: 200);
return textPainter.size;
}
@override
Widget build(BuildContext context) {
return textPages.isEmpty
? Center(
child: Text(
"加载中...",
style: widget.style,
),
)
: PageView.builder(
controller: widget.controller,
reverse: widget.reverse,
itemCount: textPages.length,
onPageChanged: (e) {
index = e;
widget.onPageChanged?.call(e, textPages.length);
},
itemBuilder: (_, i) {
return Container(
padding: widget.padding ?? EdgeInsets.zero,
child: CustomPaint(
painter: NovelTextPainter(
textPages[i],
style: widget.style,
fontHieght: fontHieght,
),
),
);
},
);
}
}
class NovelTextPainter extends CustomPainter {
final TextStyle style;
final double fontHieght;
final List<String> text;
NovelTextPainter(
this.text, {
required this.style,
required this.fontHieght,
});
@override
void paint(Canvas canvas, Size size) {
var startTime = DateTime.now().millisecondsSinceEpoch;
var i = 0;
for (var item in text) {
TextSpan textSpan = TextSpan(
text: item,
style: style,
);
final textPainter = TextPainter(
text: textSpan,
maxLines: 1,
textAlign: TextAlign.justify,
textDirection: TextDirection.ltr,
);
textPainter.layout(maxWidth: size.width);
final offset = Offset(0, i * fontHieght);
textPainter.paint(canvas, offset);
i++;
}
Log.d("绘制单页耗时:${DateTime.now().millisecondsSinceEpoch - startTime}ms");
}
@override
bool shouldRepaint(covariant NovelTextPainter oldDelegate) {
return oldDelegate.style != style ||
oldDelegate.text != text ||
oldDelegate.fontHieght != fontHieght;
}
}
enum CharType {
//中文及全角符号
chinese,
//单词
word,
//数字
number,
//符号
symbol,
//换行符
newline
}
class CharInfo {
CharType type;
String text;
double width;
CharInfo({
required this.text,
required this.width,
required this.type,
});
@override
String toString() {
return "($type,$width,$text)";
}
}
class ComputeParameter {
String content;
double width;
double fontSize;
double lineHeight;
int maxLine;
double chineseWidth;
double englishWidth;
double symbolWidth;
double spaceWidth;
ComputeParameter({
required this.content,
required this.fontSize,
required this.width,
required this.maxLine,
required this.lineHeight,
required this.chineseWidth,
required this.englishWidth,
required this.symbolWidth,
required this.spaceWidth,
});
}

View File

@@ -0,0 +1,790 @@
import 'dart:async';
import 'dart:io';
import 'package:battery_plus/battery_plus.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dmzj/app/app_color.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/db/download_status.dart';
import 'package:flutter_dmzj/models/db/novel_download_info.dart';
import 'package:flutter_dmzj/services/app_settings_service.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:flutter_dmzj/models/novel/novel_detail_model.dart';
import 'package:flutter_dmzj/requests/novel_request.dart';
import 'package:flutter_dmzj/services/novel_download_service.dart';
import 'package:flutter_dmzj/services/user_service.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
import 'package:html_unescape/html_unescape.dart';
import 'package:remixicon/remixicon.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
// ignore: depend_on_referenced_packages
import 'package:path/path.dart' as p;
class NovelReaderController extends BaseController {
final int novelId;
final String novelTitle;
final String novelCover;
final List<NovelDetailChapter> chapters;
final FocusNode focusNode = FocusNode();
NovelDetailChapter chapter;
NovelReaderController({
required this.novelId,
required this.novelTitle,
required this.novelCover,
required this.chapters,
required this.chapter,
}) {
chapterIndex.value = chapters.indexOf(chapter);
}
/// 当前章节索引
var chapterIndex = 0.obs;
/// 当前页面
var currentIndex = 0.obs;
/// 最大页面
var maxPage = 0.obs;
/// 阅读进度,百分比
var progress = 0.0.obs;
final AppSettingsService settings = AppSettingsService.instance;
final NovelRequest request = NovelRequest();
final PageController pageController = PageController();
final ScrollController scrollController = ScrollController();
/// 连接信息监听
StreamSubscription<ConnectivityResult>? connectivitySubscription;
/// 电量信息监听
StreamSubscription<BatteryState>? batterySubscription;
/// 连接类型
Rx<ConnectivityResult> connectivityType =
Rx<ConnectivityResult>(ConnectivityResult.other);
/// 电量信息
Rx<int> batteryLevel = 0.obs;
/// 显示电量
RxBool showBattery = true.obs;
/// 文本内容
var content = "".obs;
/// 是否是图片
var isPicture = false.obs;
/// 是否为本地缓存
var isLocal = false;
/// 图片列表
RxList<String> pictures = RxList<String>();
var contentLength = 0;
/// 是否显示控制器
var showControls = false.obs;
/// 阅读方向
var direction = 0.obs;
/// 左手模式
bool get leftHandMode => settings.novelReaderLeftHandMode.value;
/// 翻页动画
bool get pageAnimation => settings.novelReaderPageAnimation.value;
@override
void onInit() {
initConnectivity();
initBattery();
direction.value = settings.novelReaderDirection.value;
scrollController.addListener(listenVertical);
setFull();
loadContent();
super.onInit();
}
/// 初始化电池信息
void initBattery() async {
try {
//没有电池的Mac似乎会闪退,暂时屏蔽Mac
//https://github.com/xiaoyaocz/flutter_dmzj/discussions/146
if (Platform.isMacOS) {
showBattery.value = false;
return;
}
var battery = Battery();
batterySubscription =
battery.onBatteryStateChanged.listen((BatteryState state) async {
try {
var level = await battery.batteryLevel;
batteryLevel.value = level;
showBattery.value = true;
} catch (e) {
showBattery.value = false;
}
});
batteryLevel.value = await battery.batteryLevel;
showBattery.value = true;
} catch (e) {
showBattery.value = false;
}
}
/// 初始化连接状态
void initConnectivity() async {
var connectivity = Connectivity();
connectivitySubscription =
connectivity.onConnectivityChanged.listen((ConnectivityResult result) {
//提醒
if (connectivityType.value != result &&
result == ConnectivityResult.mobile) {
SmartDialog.showToast("您已切换至数据网络,请注意流量消耗");
}
connectivityType.value = result;
});
connectivityType.value = await connectivity.checkConnectivity();
}
/// 监听竖向模式时滚动百分比
void listenVertical() {
if (scrollController.position.maxScrollExtent > 0) {
progress.value = scrollController.position.pixels /
scrollController.position.maxScrollExtent;
}
}
@override
void onClose() {
scrollController.removeListener(listenVertical);
connectivitySubscription?.cancel();
batterySubscription?.cancel();
exitFull();
uploadHistory();
super.onClose();
}
/// 加载内容
Future loadContent() async {
try {
pageLoadding.value = true;
pageError.value = false;
content.value = "";
currentIndex.value = 0;
isLocal = false;
chapter = chapters[chapterIndex.value];
//查询本地是否存在
var localInfo = NovelDownloadService.instance.box
.get("${novelId}_${chapter.volumeId}_${chapter.chapterId}");
if (localInfo != null && localInfo.status == DownloadStatus.complete) {
return await loadFromLocal(localInfo);
}
var text = await request.novelContent(
volumeId: chapter.volumeId,
chapterId: chapter.chapterId,
);
contentLength = text.length;
var subStr = text.substring(0, text.length < 200 ? text.length : 200);
//检查是否是插画
if (subStr.contains(RegExp('<img.*?>'))) {
List<String> imgs = [];
for (var item
in RegExp(r'<img.*?src=[' '""](.*?)[' '""].*?>').allMatches(text)) {
var src = item.group(1);
if (src != null && src.isNotEmpty) {
imgs.add(src);
}
}
isPicture.value = true;
pictures.value = imgs;
content.value = text;
maxPage.value = pictures.length;
SmartDialog.showToast("双击插画可放大、保存哦~");
} else {
isPicture.value = false;
text = HtmlUnescape().convert(text);
text = text
.replaceAll('\r\n', '\n')
.replaceAll("<br/>", "\n")
.replaceAll('<br />', "\n")
.replaceAll('\n\n\n', "\n")
.replaceAll('\n\n', "\n")
.replaceAll('\n', "\n  ")
.replaceAll(RegExp(r"  \s+"), "  ");
content.value = text;
}
if (scrollController.hasClients) {
scrollController.jumpTo(0);
progress.value = 0.0;
}
preloadContent();
//TODO 阅读记录跳转
//上传记录
uploadHistory();
} catch (e) {
pageError.value = true;
errorMsg.value = e.toString();
} finally {
pageLoadding.value = false;
}
//SmartDialog.dismiss(status: SmartStatus.loading);
}
Future loadFromLocal(NovelDownloadInfo local) async {
try {
isLocal = true;
var file = File(p.join(NovelDownloadService.instance.savePath,
local.taskId, local.fileName));
var text = await file.readAsString();
//检查是否是插画
if (local.isImage) {
List<String> imgs = local.imageFiles
.map((e) =>
p.join(NovelDownloadService.instance.savePath, local.taskId, e))
.toList();
isPicture.value = true;
pictures.value = imgs;
content.value = text;
maxPage.value = pictures.length;
SmartDialog.showToast("双击插画可放大、保存哦~");
} else {
isPicture.value = false;
text = HtmlUnescape().convert(text);
text = text
.replaceAll('\r\n', '\n')
.replaceAll("<br/>", "\n")
.replaceAll('<br />', "\n")
.replaceAll('\n\n\n', "\n")
.replaceAll('\n\n', "\n")
.replaceAll('\n', "\n  ")
.replaceAll(RegExp(r"  \s+"), "  ");
content.value = text;
}
if (scrollController.hasClients) {
scrollController.jumpTo(0);
progress.value = 0.0;
}
preloadContent();
//TODO 阅读记录跳转
//上传记录
uploadHistory();
} catch (e) {
pageError.value = true;
errorMsg.value = e.toString();
} finally {
pageLoadding.value = false;
}
}
/// 预加载下一话
void preloadContent() async {
try {
if (chapterIndex.value == chapters.length - 1) {
return;
}
var nextChapter = chapters[chapterIndex.value + 1];
await request.novelContent(
volumeId: nextChapter.volumeId,
chapterId: nextChapter.chapterId,
);
} catch (e) {
Log.logPrint(e);
}
}
/// 上传历史记录
void uploadHistory() {
var chapter = chapters[chapterIndex.value];
UserService.instance.updateNovelHistory(
novelId: novelId,
chapterId: chapter.chapterId,
//TODO 已读位置计算
index: 0,
total: contentLength,
novelCover: novelCover,
novelName: novelTitle,
chapterName: chapter.chapterName,
volumeId: chapter.volumeId,
volumeName: chapter.volumeName,
);
}
/// 下一章
void nextChapter() {
if (chapterIndex.value == chapters.length - 1) {
SmartDialog.showToast("后面没有了");
return;
}
chapterIndex.value += 1;
loadContent();
}
/// 上一章
void forwardChapter() {
if (chapterIndex.value == 0) {
SmartDialog.showToast("前面没有了");
return;
}
chapterIndex.value -= 1;
loadContent();
}
/// 下一页
void nextPage() {
if (direction.value == ReaderDirection.kUpToDown) {
return;
}
var value = currentIndex.value;
var max = maxPage.value;
if (value >= max - 1) {
nextChapter();
} else {
jumpToPage(value + 1, anime: true);
}
}
/// 上一页
void forwardPage() {
if (direction.value == ReaderDirection.kUpToDown) {
return;
}
var value = currentIndex.value;
if (value == 0) {
forwardChapter();
} else {
jumpToPage(value - 1, anime: true);
}
}
/// 跳转页数
void jumpToPage(int page, {bool anime = false}) {
//竖向
if (direction.value == ReaderDirection.kUpToDown) {
final viewportHeight = scrollController.position.viewportDimension;
scrollController.jumpTo(viewportHeight * page);
} else {
anime && pageAnimation
? pageController.animateToPage(page,
duration: const Duration(milliseconds: 200), curve: Curves.linear)
: pageController.jumpToPage(page);
}
}
/// 显示设置
void showSettings() {
setShowControls();
showModalBottomSheet(
context: Get.context!,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
constraints: const BoxConstraints(
maxWidth: 500,
),
builder: (context) => Column(
children: [
ListTile(
title: const Text("设置"),
trailing: IconButton(
onPressed: Get.back,
icon: const Icon(Icons.close),
),
contentPadding: AppStyle.edgeInsetsL12,
),
Expanded(
child: Obx(
() => ListView(
padding: AppStyle.edgeInsetsA12,
children: [
buildBGItem(
context,
child: ListTile(
title: const Text("阅读方向"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
buildSelectedButton(
onTap: () {
setDirection(ReaderDirection.kLeftToRight);
},
selected: settings.novelReaderDirection.value ==
ReaderDirection.kLeftToRight,
child: const Icon(Remix.arrow_right_line),
),
AppStyle.hGap8,
buildSelectedButton(
onTap: () {
setDirection(ReaderDirection.kRightToLeft);
},
selected: settings.novelReaderDirection.value ==
ReaderDirection.kRightToLeft,
child: const Icon(Remix.arrow_left_line),
),
AppStyle.hGap8,
buildSelectedButton(
onTap: () {
setDirection(ReaderDirection.kUpToDown);
},
selected: settings.novelReaderDirection.value ==
ReaderDirection.kUpToDown,
child: const Icon(Remix.arrow_down_line),
)
],
),
),
),
AppStyle.vGap12,
buildBGItem(
context,
child: ListTile(
title: const Text("阅读主题"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: AppColor.novelThemes.keys
.map(
(e) => GestureDetector(
onTap: () {
settings.setNovelReaderTheme(e);
},
child: Container(
margin: AppStyle.edgeInsetsL8,
height: 36,
width: 36,
decoration: BoxDecoration(
color: AppColor.novelThemes[e]!.first,
borderRadius: AppStyle.radius24,
border: Border.all(
color: Colors.grey.withOpacity(.2),
),
),
child: Visibility(
visible: AppColor.novelThemes.keys
.toList()
.indexOf(e) ==
settings.novelReaderTheme.value,
child: Icon(
Icons.check,
color: AppColor.novelThemes[e]!.last,
),
),
),
),
)
.toList(),
),
),
),
AppStyle.vGap12,
buildBGItem(
context,
child: SwitchListTile(
value: settings.novelReaderLeftHandMode.value,
onChanged: (e) {
settings.setNovelReaderLeftHandMode(e);
},
title: const Text("操作反转"),
subtitle: const Text("点击左侧下一页,右侧上一页"),
),
),
AppStyle.vGap12,
buildBGItem(
context,
child: SwitchListTile(
value: settings.novelReaderShowStatus.value,
onChanged: (e) {
settings.setNovelReaderShowStatus(e);
},
title: const Text("显示状态信息"),
),
),
AppStyle.vGap12,
buildBGItem(
context,
child: SwitchListTile(
value: settings.novelReaderPageAnimation.value,
onChanged: (e) {
settings.setNovelReaderPageAnimation(e);
},
title: const Text("翻页动画"),
),
),
AppStyle.vGap12,
buildBGItem(
context,
child: ListTile(
title: const Text("字体大小"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton(
onPressed: () {
settings.setNovelReaderFontSize(
settings.novelReaderFontSize.value + 1,
);
},
child: const Icon(
Icons.add,
// color: Colors.grey,
),
),
AppStyle.hGap12,
Text("${settings.novelReaderFontSize.value}"),
AppStyle.hGap12,
OutlinedButton(
onPressed: () {
settings.setNovelReaderFontSize(
settings.novelReaderFontSize.value - 1,
);
},
child: const Icon(
Icons.remove,
// color: Colors.grey,
),
),
],
),
),
),
AppStyle.vGap12,
buildBGItem(
context,
child: ListTile(
title: const Text("行距"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton(
onPressed: () {
settings.setNovelReaderLineSpacing(
settings.novelReaderLineSpacing.value + 0.1,
);
},
child: const Icon(
Icons.add,
// color: Colors.grey,
),
),
AppStyle.hGap12,
Text((settings.novelReaderLineSpacing.value)
.toStringAsFixed(1)),
AppStyle.hGap12,
OutlinedButton(
onPressed: () {
settings.setNovelReaderLineSpacing(
settings.novelReaderLineSpacing.value - 0.1,
);
},
child: const Icon(
Icons.remove,
// color: Colors.grey,
),
),
],
),
),
),
],
),
),
),
],
),
);
}
/// 设置阅读方向
void setDirection(int value) {
settings.setNovelReaderDirection(value);
direction.value = value;
}
Widget buildBGItem(BuildContext context, {required Widget child}) {
return Container(
decoration: BoxDecoration(
borderRadius: AppStyle.radius8,
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: child,
);
}
Widget buildSelectedButton(
{required Widget child, bool selected = false, Function()? onTap}) {
return Builder(builder: (context) {
return OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor:
selected ? Theme.of(context).colorScheme.primary : Colors.grey,
side: BorderSide(
color:
selected ? Theme.of(context).colorScheme.primary : Colors.grey,
),
),
onPressed: onTap,
child: child,
);
});
}
/// 显示目录
void showMenu() {
setShowControls();
showModalBottomSheet(
context: Get.context!,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
constraints: const BoxConstraints(
maxWidth: 500,
),
builder: (context) => Column(
children: [
ListTile(
title: Text("目录(${chapters.length})"),
trailing: IconButton(
onPressed: Get.back,
icon: const Icon(Icons.close),
),
contentPadding: AppStyle.edgeInsetsL12,
),
Divider(
height: 1.0,
color: Theme.of(context).dividerColor.withOpacity(.2),
),
Expanded(
child: ScrollablePositionedList.separated(
initialScrollIndex: chapterIndex.value,
itemCount: chapters.length,
separatorBuilder: (_, i) => Divider(
indent: 12,
endIndent: 12,
height: 1.0,
color: Theme.of(context).dividerColor.withOpacity(.2),
),
itemBuilder: (_, i) {
var item = chapters[i];
return ListTile(
selected: i == chapterIndex.value,
selectedTileColor:
Theme.of(context).colorScheme.secondaryContainer,
selectedColor:
Theme.of(context).colorScheme.onSecondaryContainer,
title: Text(item.chapterName),
subtitle: Text(item.volumeName),
onTap: () {
chapterIndex.value = i;
loadContent();
Get.back();
},
);
},
),
),
],
),
routeSettings: const RouteSettings(name: "/modalBottomSheet"),
);
}
/// 设置显示/隐藏控制按钮
void setShowControls() {
if (settings.novelReaderFullScreen.value) {
if (showControls.value) {
setFull();
} else {
setFullEdge();
}
}
Future.delayed(const Duration(milliseconds: 100), () {
showControls.value = !showControls.value;
});
}
/// 进入全屏
void setFull() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.manual,
overlays: [],
);
}
/// 进入全屏edgeToEdge模式
void setFullEdge() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
overlays: SystemUiOverlay.values,
);
SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.light,
systemNavigationBarColor: Colors.transparent,
systemNavigationBarIconBrightness: Brightness.light,
));
}
/// 退出全屏
void exitFull() {
SystemChrome.setEnabledSystemUIMode(
SystemUiMode.edgeToEdge,
overlays: SystemUiOverlay.values,
);
}
void keyDown(LogicalKeyboardKey key) {
if (key == LogicalKeyboardKey.arrowLeft ||
key == LogicalKeyboardKey.pageUp) {
if (leftHandMode) {
nextPage();
} else {
forwardPage();
}
} else if (key == LogicalKeyboardKey.arrowRight ||
key == LogicalKeyboardKey.pageDown) {
if (leftHandMode) {
forwardPage();
} else {
nextPage();
}
}
}
}

View File

@@ -0,0 +1,670 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_dmzj/app/app_color.dart';
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/dialog_utils.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:flutter_dmzj/modules/novel/reader/novel_horizontal_reader.dart';
import 'package:flutter_dmzj/modules/novel/reader/novel_reader_controller.dart';
import 'package:flutter_dmzj/widgets/custom_header.dart';
import 'package:flutter_dmzj/widgets/local_image.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:get/get.dart';
import 'package:remixicon/remixicon.dart';
class NovelReaderPage extends GetView<NovelReaderController> {
const NovelReaderPage({Key? key}) : super(key: key);
Color get color =>
AppColor.novelThemes[controller.settings.novelReaderTheme.value]!.last;
@override
Widget build(BuildContext context) {
return KeyboardListener(
onKeyEvent: (e) {
if (e.runtimeType == KeyUpEvent) {
controller.keyDown(e.logicalKey);
Log.d(e.toString());
}
},
focusNode: controller.focusNode,
autofocus: true,
child: Theme(
data: Theme.of(context),
child: Obx(
() => Scaffold(
resizeToAvoidBottomInset: false,
backgroundColor: AppColor
.novelThemes[controller.settings.novelReaderTheme.value]!.first,
body: Stack(
children: [
Obx(
() => Offstage(
offstage: controller.content.value.isEmpty,
child: GestureDetector(
onTap: () {
controller.setShowControls();
},
child: controller.isPicture.value
? buildPicture(context)
: (controller.direction.value ==
ReaderDirection.kUpToDown
? buildVertical(context)
: buildHorizontal(context)),
),
),
),
Positioned.fill(
child: Row(
children: [
Expanded(
flex: 1,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
controller.leftHandMode
? controller.nextPage()
: controller.forwardPage();
},
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.transparent,
),
),
),
Expanded(
flex: 8,
child: Container(),
),
Expanded(
flex: 1,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
controller.leftHandMode
? controller.forwardPage()
: controller.nextPage();
},
child: Container(
width: double.infinity,
height: double.infinity,
color: Colors.transparent,
),
),
),
],
),
),
Obx(
() => Offstage(
offstage: !controller.pageLoadding.value,
child: const AppLoaddingWidget(),
),
),
Obx(
() => Offstage(
offstage: !controller.pageError.value,
child: AppErrorWidget(
errorMsg: controller.errorMsg.value,
onRefresh: () => controller.loadContent(),
),
),
),
buildBottomStatus(),
//顶部
Obx(
() => AnimatedPositioned(
top: controller.showControls.value
? 0
: -(64 + AppStyle.statusBarHeight),
left: 0,
right: 0,
duration: const Duration(milliseconds: 100),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(16),
bottomRight: Radius.circular(16),
),
),
height: 64 + AppStyle.statusBarHeight,
padding: EdgeInsets.only(top: AppStyle.statusBarHeight),
child: Row(
children: [
IconButton(
onPressed: Get.back,
icon: const Icon(Icons.arrow_back),
),
AppStyle.hGap12,
Expanded(
child: Text(
controller.chapters[controller.chapterIndex.value]
.chapterName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleLarge,
),
),
],
),
),
),
),
//底部
Obx(
() => AnimatedPositioned(
bottom: controller.showControls.value
? 0
: -(136 + AppStyle.bottomBarHeight),
left: 0,
right: 0,
duration: const Duration(milliseconds: 100),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
),
),
height: 136 + AppStyle.bottomBarHeight,
padding:
EdgeInsets.only(bottom: AppStyle.bottomBarHeight),
alignment: Alignment.center,
child: Container(
constraints: const BoxConstraints(
maxWidth: 600,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
buildSilderBar(),
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
IconButton.filledTonal(
onPressed: controller.forwardChapter,
icon: const Icon(Remix.skip_back_line),
tooltip: "上一话",
),
IconButton.filledTonal(
onPressed: controller.showMenu,
icon: const Icon(Remix.file_list_line),
tooltip: "目录",
),
IconButton.filledTonal(
onPressed: controller.showSettings,
icon: const Icon(Remix.settings_line),
tooltip: "设置",
),
IconButton.filledTonal(
onPressed: controller.nextChapter,
icon: const Icon(Remix.skip_forward_line),
tooltip: "下一话",
),
],
),
],
),
),
),
),
),
],
),
),
),
),
);
}
Widget buildHorizontal(BuildContext context) {
return EasyRefresh(
header: MaterialHeader2(
triggerOffset: 80,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: AppStyle.radius24,
),
padding: AppStyle.edgeInsetsA12,
child: Icon(
Icons.arrow_circle_left,
color: Theme.of(context).colorScheme.primary,
),
),
),
footer: MaterialFooter2(
triggerOffset: 80,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: AppStyle.radius24,
),
padding: AppStyle.edgeInsetsA12,
child: Icon(
Icons.arrow_circle_right,
color: Theme.of(context).colorScheme.primary,
),
),
),
refreshOnStart: false,
onRefresh: () async {
controller.forwardChapter();
},
onLoad: () async {
controller.nextChapter();
},
child: NovelHorizontalReader(
controller.content.value,
controller: controller.pageController,
reverse: controller.direction.value == ReaderDirection.kRightToLeft,
style: TextStyle(
fontSize: controller.settings.novelReaderFontSize.value.toDouble(),
height: controller.settings.novelReaderLineSpacing.value,
color: AppColor
.novelThemes[controller.settings.novelReaderTheme.value]!.last,
),
padding: AppStyle.edgeInsetsA12.copyWith(
top: AppStyle.statusBarHeight + 12,
bottom: (controller.settings.novelReaderShowStatus.value ? 24 : 12),
),
onPageChanged: (i, m) {
controller.currentIndex.value = i;
controller.maxPage.value = m;
},
),
);
}
Widget buildVertical(BuildContext context) {
return SizedBox(
height: double.infinity,
child: Padding(
padding: EdgeInsets.only(
top: AppStyle.statusBarHeight,
),
child: Padding(
padding: AppStyle.edgeInsetsA12.copyWith(
bottom:
(controller.settings.novelReaderShowStatus.value ? 32 : 12),
),
child: EasyRefresh(
header: MaterialHeader2(
triggerOffset: 80,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: AppStyle.radius24,
),
padding: AppStyle.edgeInsetsA12,
child: Icon(
Icons.arrow_circle_up,
color: Theme.of(context).colorScheme.primary,
),
),
),
footer: MaterialFooter2(
triggerOffset: 80,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: AppStyle.radius24,
),
padding: AppStyle.edgeInsetsA12,
child: Icon(
Icons.arrow_circle_down,
color: Theme.of(context).colorScheme.primary,
),
),
),
refreshOnStart: false,
onRefresh: () async {
controller.forwardChapter();
},
onLoad: () async {
controller.nextChapter();
},
child: SingleChildScrollView(
controller: controller.scrollController,
child: Text(
controller.content.value,
textAlign: TextAlign.justify,
style: TextStyle(
fontSize:
controller.settings.novelReaderFontSize.value.toDouble(),
height: controller.settings.novelReaderLineSpacing.value,
color: AppColor
.novelThemes[controller.settings.novelReaderTheme.value]!
.last,
),
),
),
),
),
),
);
}
Widget buildPicture(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
top: AppStyle.statusBarHeight,
),
child: EasyRefresh(
header: MaterialHeader2(
triggerOffset: 80,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: AppStyle.radius24,
),
padding: AppStyle.edgeInsetsA12,
child: Icon(
controller.direction.value != ReaderDirection.kUpToDown
? Icons.arrow_circle_left
: Icons.arrow_circle_up,
color: Theme.of(context).colorScheme.primary,
),
),
),
footer: MaterialFooter2(
triggerOffset: 80,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: AppStyle.radius24,
),
padding: AppStyle.edgeInsetsA12,
child: Icon(
controller.direction.value != ReaderDirection.kUpToDown
? Icons.arrow_circle_right
: Icons.arrow_circle_down,
color: Theme.of(context).colorScheme.primary,
),
),
),
refreshOnStart: false,
onRefresh: () async {
controller.forwardChapter();
},
onLoad: () async {
controller.nextChapter();
},
child: controller.direction.value != ReaderDirection.kUpToDown
? PageView.builder(
controller: controller.pageController,
itemCount: controller.pictures.length,
reverse:
controller.direction.value == ReaderDirection.kRightToLeft,
onPageChanged: (e) {
controller.currentIndex.value = e;
controller.maxPage.value = controller.pictures.length;
},
itemBuilder: (_, i) {
return Padding(
padding: EdgeInsets.only(
bottom: (controller.settings.novelReaderShowStatus.value
? 24
: 12),
),
child: GestureDetector(
onDoubleTap: () {
DialogUtils.showImageViewer(
i, controller.pictures.toList());
},
child: controller.isLocal
? LocalImage(
controller.pictures[i],
fit: BoxFit.contain,
)
: NetImage(
controller.pictures[i],
fit: BoxFit.contain,
progress: true,
),
),
);
})
: ListView.separated(
controller: controller.scrollController,
itemCount: controller.pictures.length,
padding: EdgeInsets.zero,
separatorBuilder: (_, i) => AppStyle.vGap4,
itemBuilder: (_, i) {
return GestureDetector(
onDoubleTap: () {
DialogUtils.showImageViewer(
i, controller.pictures.toList());
},
child: controller.isLocal
? LocalImage(
controller.pictures[i],
fit: BoxFit.fitWidth,
)
: NetImage(
controller.pictures[i],
fit: BoxFit.fitWidth,
progress: true,
),
);
}),
),
);
}
Widget buildSilderBar() {
if (controller.direction.value == ReaderDirection.kUpToDown) {
return Obx(
() {
var value = controller.progress.value;
var max = 1.0;
if (value > max) {
return const SizedBox(
height: 48,
);
}
return SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: Slider(
value: value,
max: max,
onChanged: (e) {
controller.scrollController.jumpTo(
controller.scrollController.position.maxScrollExtent *
e,
);
},
),
),
],
),
);
},
);
}
return Obx(
() {
var value = controller.currentIndex.value + 1.0;
var max = controller.maxPage.value;
if (value > max) {
return const SizedBox(
height: 48,
);
}
return SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: Slider(
value: value,
max: max.toDouble(),
onChanged: (e) {
controller.jumpToPage((e - 1).toInt());
},
),
),
],
),
);
},
);
}
Widget buildBottomStatus() {
return Positioned(
right: 8,
left: 8,
bottom: 4,
child: Obx(
() => Offstage(
offstage: !controller.settings.novelReaderShowStatus.value,
child: Container(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
padding: AppStyle.edgeInsetsA12.copyWith(top: 4, bottom: 4),
child: Obx(
() => Row(
children: [
buildConnectivity(),
buildBattery(),
const Expanded(child: SizedBox()),
controller.direction.value != ReaderDirection.kUpToDown
? Text(
"${controller.currentIndex.value + 1} / ${controller.maxPage.value}",
style: const TextStyle(
fontSize: 12,
height: 1.0,
color: Colors.white,
fontWeight: FontWeight.bold,
),
)
: Text(
"${(controller.progress.value * 100).toStringAsFixed(0)}%",
style: const TextStyle(
fontSize: 12,
height: 1.0,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
),
),
);
}
Widget buildConnectivity() {
var connectivityType = controller.connectivityType.value;
IconData icon = Remix.wifi_line;
var name = "WiFi";
switch (connectivityType) {
case ConnectivityResult.bluetooth:
icon = Remix.wifi_line;
name = "蓝牙";
break;
case ConnectivityResult.ethernet:
icon = Remix.computer_line;
name = "有线";
break;
case ConnectivityResult.mobile:
icon = Remix.base_station_line;
name = "流量";
break;
case ConnectivityResult.wifi:
icon = Remix.wifi_line;
name = "WiFi";
break;
case ConnectivityResult.vpn:
icon = Remix.shield_keyhole_line;
name = "VPN";
break;
case ConnectivityResult.none:
icon = Remix.wifi_off_line;
name = "无网络";
break;
case ConnectivityResult.other:
icon = Remix.question_line;
name = "未知";
break;
default:
}
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(icon, size: 12, color: Colors.white),
AppStyle.hGap4,
Text(
name,
style: const TextStyle(
fontSize: 12,
height: 1.0,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
AppStyle.hGap8,
],
);
}
Widget buildBattery() {
var battery = controller.batteryLevel.value;
// IconData icon = Icons.battery_0_bar;
// if (battery >= 90) {
// icon = Icons.battery_full_rounded;
// } else if (battery < 90 && battery >= 80) {
// icon = Icons.battery_6_bar;
// } else if (battery < 80 && battery >= 70) {
// icon = Icons.battery_5_bar;
// } else if (battery < 70 && battery >= 50) {
// icon = Icons.battery_4_bar;
// } else if (battery < 50 && battery >= 30) {
// icon = Icons.battery_3_bar;
// } else if (battery < 30 && battery >= 20) {
// icon = Icons.battery_2_bar;
// } else if (battery < 20 && battery >= 10) {
// icon = Icons.battery_1_bar;
// } else {
// icon = Icons.battery_0_bar;
// }
return Visibility(
visible: controller.showBattery.value,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
//Icon(icon, size: 12, color: color.withOpacity(.6)),
Text(
"电量 $battery%",
style: const TextStyle(
fontSize: 12,
height: 1.0,
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
AppStyle.hGap8,
],
),
);
}
}

View File

@@ -0,0 +1,55 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:flutter_dmzj/models/novel/search_model.dart';
import 'package:flutter_dmzj/requests/novel_request.dart';
import 'package:get/get.dart';
class NovelSearchController extends BasePageController<NovelSearchModel> {
final String keyword;
NovelSearchController(this.keyword) {
searchController = TextEditingController(text: keyword);
}
late TextEditingController searchController;
final NovelRequest request = NovelRequest();
String _keyword = "";
RxMap<int, String> hotWords = <int, String>{}.obs;
var showHotWord = true.obs;
@override
void onInit() {
// loadHotWord();
if (keyword.isNotEmpty) {
submit();
}
super.onInit();
}
void submit() {
if (searchController.text.isEmpty) {
list.clear();
showHotWord.value = true;
return;
}
showHotWord.value = false;
_keyword = searchController.text;
refreshData();
}
@override
Future<List<NovelSearchModel>> getData(int page, int pageSize) async {
if (searchController.text.isEmpty) {
return [];
}
return await request.search(keyword: _keyword, page: page);
}
void loadHotWord() async {
try {
hotWords.value = await request.searchHotWord();
} catch (e) {
Log.logPrint(e);
}
}
}

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/novel/search_model.dart';
import 'package:flutter_dmzj/modules/novel/search/novel_search_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
class NovelSearchPage extends StatelessWidget {
final String keyword;
final NovelSearchController controller;
NovelSearchPage({this.keyword = "", super.key})
: controller = Get.put(NovelSearchController(keyword));
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
titleSpacing: 8,
title: SizedBox(
height: 40,
child: TextField(
controller: controller.searchController,
autofocus: true,
decoration: InputDecoration(
hintText: "搜索轻小说",
contentPadding: AppStyle.edgeInsetsH12,
border: const OutlineInputBorder(),
prefixIcon: SizedBox(
width: 48,
child: IconButton(
onPressed: () {
AppNavigator.closePage();
},
icon: const Icon(Icons.arrow_back),
),
),
suffixIcon: SizedBox(
width: 48,
child: IconButton(
onPressed: controller.submit,
icon: const Icon(Icons.search),
),
),
),
onSubmitted: (e) {
controller.submit();
},
),
),
),
body: Stack(
children: [
PageListView(
pageController: controller,
firstRefresh: false,
showPageLoadding: true,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
),
Positioned.fill(
child: Obx(
() => Offstage(
offstage: !controller.showHotWord.value,
child: SingleChildScrollView(
child: Column(
children: [
const ListTile(
title: Text("热门搜索"),
),
Padding(
padding: AppStyle.edgeInsetsH12.copyWith(bottom: 12),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: controller.hotWords.keys
.map(
(e) => OutlinedButton(
style: OutlinedButton.styleFrom(
tapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
onPressed: () {
AppNavigator.toNovelDetail(e);
},
child: Text(controller.hotWords[e] ?? ""),
),
)
.toList(),
),
)
],
),
),
),
),
),
],
),
);
}
Widget buildItem(NovelSearchModel item) {
return InkWell(
onTap: () {
AppNavigator.toNovelDetail(item.id);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.cover ?? "",
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text.rich(
TextSpan(children: [
const WidgetSpan(
child: Icon(
Icons.account_circle,
color: Colors.grey,
size: 18,
)),
const TextSpan(
text: " ",
),
TextSpan(
text: item.authors,
style:
const TextStyle(color: Colors.grey, fontSize: 14))
]),
),
AppStyle.vGap4,
Text(item.types ?? "",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
AppStyle.vGap4,
Text(item.lastName ?? "",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,126 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/novel/novel_detail_model.dart';
import 'package:flutter_dmzj/requests/novel_request.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/services/novel_download_service.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class NovelSelectChapterController extends BaseController {
final int novelId;
NovelSelectChapterController(this.novelId);
final NovelRequest request = NovelRequest();
RxList<NovelDetailVolume> volumes = RxList<NovelDetailVolume>();
String novelTitle = "";
String novelCover = "";
RxMap<int, RxSet<int>> selectIds = RxMap<int, RxSet<int>>();
@override
void onInit() {
loadDetail();
super.onInit();
}
/// 加载信息
void loadDetail() async {
try {
pageLoadding.value = true;
pageError.value = false;
var result = await request.novelDetail(novelId: novelId);
novelTitle = result.data.name;
novelCover = result.data.cover;
var chpaterResult = await request.novelChapter(novelId: novelId);
var ls = chpaterResult.map((e) => NovelDetailVolume.fromJson(e)).toList();
selectIds.value = {};
for (var item in ls) {
selectIds.addAll({
item.volumeId: RxSet<int>(),
});
}
volumes.value = ls;
} catch (e) {
pageError.value = true;
errorMsg.value = e.toString();
} finally {
pageLoadding.value = false;
}
}
void selectItem(NovelDetailChapter item) {
var chapterIds = selectIds[item.volumeId]!;
if (chapterIds.contains(item.chapterId)) {
chapterIds.remove(item.chapterId);
} else {
chapterIds.add(item.chapterId);
}
}
void selectAll() {
for (var volume in volumes) {
for (var chapter in volume.chapters) {
var chapterIds = selectIds[volume.volumeId]!;
var id = "${novelId}_${volume.volumeId}_${chapter.chapterId}";
if (!NovelDownloadService.instance.downloadIds.contains(id)) {
chapterIds.add(chapter.chapterId);
}
}
}
}
void cleanAll() {
for (var volume in selectIds.values) {
volume.clear();
}
}
void toDownloadManage() {
AppNavigator.toNovelDownloadManage(1);
}
void startDownload() {
var chapterIds = <int>[];
for (var item in selectIds.values) {
chapterIds.addAll(item);
}
if (chapterIds.isEmpty) {
SmartDialog.showToast("请选择需要下载的章节");
return;
}
for (var id in chapterIds) {
//搜索章节
NovelDetailVolume? volume;
NovelDetailChapter? chapter;
for (var item in volumes) {
var chapterItem =
item.chapters.firstWhereOrNull((y) => y.chapterId == id);
if (chapterItem != null) {
volume = item;
chapter = chapterItem;
break;
}
}
if (volume == null || chapter == null) {
continue;
}
NovelDownloadService.instance.addTask(
novelId: novelId,
chapterId: chapter.chapterId,
chapterSort: chapter.chapterOrder,
volumeName: volume.volumeName,
novelTitle: novelTitle,
novelCover: novelCover,
chapterName: chapter.chapterName,
isVip: false,
volumeId: volume.volumeId,
volumeOrder: volume.volumeOrder,
);
}
cleanAll();
SmartDialog.showToast("已添加到下载列表下载过程中请保持APP在前台运行");
}
}

View File

@@ -0,0 +1,175 @@
import 'package:easy_refresh/easy_refresh.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/models/novel/novel_detail_model.dart';
import 'package:flutter_dmzj/modules/novel/select_chapter/novel_select_chapter_controller.dart';
import 'package:flutter_dmzj/services/novel_download_service.dart';
import 'package:flutter_dmzj/widgets/status/app_error_widget.dart';
import 'package:flutter_dmzj/widgets/status/app_loadding_widget.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
class NovelSelectChapterPage extends StatelessWidget {
final int novelId;
final NovelSelectChapterController controller;
NovelSelectChapterPage(this.novelId, {super.key})
: controller = Get.put(
NovelSelectChapterController(novelId),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("选择下载章节"),
actions: [
TextButton(
onPressed: controller.toDownloadManage,
child: const Text("下载管理"),
),
],
),
body: Stack(
children: [
EasyRefresh(
header: const MaterialHeader(),
onRefresh: controller.loadDetail,
child: _buildVolumes(),
),
Obx(
() => Offstage(
offstage: !controller.pageLoadding.value,
child: const AppLoaddingWidget(),
),
),
Obx(
() => Offstage(
offstage: !controller.pageError.value,
child: AppErrorWidget(
errorMsg: controller.errorMsg.value,
onRefresh: () => controller.loadDetail(),
),
),
),
],
),
bottomNavigationBar: BottomAppBar(
child: SizedBox(
height: 48,
child: Row(
children: [
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.selectAll,
icon: const Icon(
Remix.checkbox_line,
size: 20,
),
label: const Text("全选"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.cleanAll,
icon: const Icon(
Remix.checkbox_blank_line,
size: 20,
),
label: const Text("取消选中"),
),
),
Expanded(
child: TextButton.icon(
style: TextButton.styleFrom(
textStyle: const TextStyle(fontSize: 14),
),
onPressed: controller.startDownload,
icon: const Icon(
Remix.download_line,
size: 20,
),
label: const Text("下载选中"),
),
),
],
),
),
),
);
}
Widget _buildVolumes() {
return Obx(
() => ListView.builder(
padding: EdgeInsets.zero,
itemCount: controller.volumes.length,
itemBuilder: (_, i) {
var item = controller.volumes[i];
return _buildChapters(item);
},
),
);
}
Widget _buildChapters(NovelDetailVolume item) {
return Obx(
() {
var volume = controller.selectIds[item.volumeId]!;
return ExpansionTile(
title: Text("${item.volumeName}(共${item.chapters.length}话)"),
leading: SizedBox(
width: 40,
child: Checkbox(
value: volume.length == item.chapters.length,
onChanged: (e) {
if (e!) {
volume.addAll(
item.chapters
.where((x) => !NovelDownloadService.instance.downloadIds
.contains(
"${novelId}_${x.volumeId}_${x.chapterId}"))
.map((e) => e.chapterId),
);
} else {
volume.clear();
}
},
),
),
children: item.chapters
.map(
(chapter) => CheckboxListTile(
value: volume.contains(chapter.chapterId),
controlAffinity: ListTileControlAffinity.leading,
title: Text(
chapter.chapterName,
style: Get.textTheme.titleSmall,
),
enabled: !NovelDownloadService.instance.downloadIds.contains(
"${novelId}_${chapter.volumeId}_${chapter.chapterId}"),
subtitle: NovelDownloadService.instance.downloadIds.contains(
"${novelId}_${chapter.volumeId}_${chapter.chapterId}")
? const Text("已下载")
: null,
onChanged: (e) {
if (e!) {
volume.add(chapter.chapterId);
} else {
volume.remove(chapter.chapterId);
}
},
),
)
.toList(),
);
},
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/comment/user_comment_item.dart';
import 'package:flutter_dmzj/requests/comment_request.dart';
class UserCommentController extends BasePageController<UserCommentItem> {
final int type;
final int userId;
UserCommentController({required this.type, required this.userId});
final CommentRequest request = CommentRequest();
@override
Future<List<UserCommentItem>> getData(int page, int pageSize) async {
return await request.getUserComment(
type: type,
uid: userId,
page: page - 1,
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/modules/user/comment/user_comment_view.dart';
import 'package:get/get.dart';
class UserCommentPage extends StatelessWidget {
final int userId;
const UserCommentPage(this.userId, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: Container(
alignment: Alignment.center,
padding: const EdgeInsets.only(right: 56),
child: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
labelPadding: AppStyle.edgeInsetsH24,
indicatorColor: Theme.of(context).colorScheme.primary,
labelColor: Theme.of(context).colorScheme.primary,
unselectedLabelColor:
Get.isDarkMode ? Colors.white70 : Colors.black87,
tabs: const [
Tab(text: "漫画"),
Tab(text: "小说"),
Tab(text: "新闻"),
],
),
),
),
body: TabBarView(
children: [
UserCommentView(type: 0, userId: userId),
UserCommentView(type: 1, userId: userId),
UserCommentView(type: 2, userId: userId),
],
),
),
);
}
}

View File

@@ -0,0 +1,162 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/comment/user_comment_item.dart';
import 'package:flutter_dmzj/modules/user/comment/user_comment_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
class UserCommentView extends StatelessWidget {
final int type;
final int userId;
final UserCommentController controller;
UserCommentView({
required this.type,
required this.userId,
Key? key,
}) : controller = Get.put(
UserCommentController(
type: type,
userId: userId,
),
tag: "${userId}_$type",
),
super(key: key);
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: PageListView(
pageController: controller,
firstRefresh: true,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (context, i) {
var item = controller.list[i];
//TODO 跳转评论详情
return Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () {
toDetail(item);
},
child: NetImage(
item.objCover,
width: 60,
borderRadius: 4,
),
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(
item.objName,
),
AppStyle.vGap8,
Container(
padding: AppStyle.edgeInsetsA8,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(.1),
borderRadius: AppStyle.radius4,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.start,
children: [
Text(item.content),
Visibility(
visible: item.mastercomment != null,
child: Container(
decoration: BoxDecoration(
color: Colors.grey.withOpacity(.1),
borderRadius: AppStyle.radius4,
),
padding: AppStyle.edgeInsetsA4,
margin: AppStyle.edgeInsetsV4,
child: Text(
"${item.mastercomment?.nickname}${item.mastercomment?.content}",
style: const TextStyle(
fontSize: 14,
),
),
),
),
AppStyle.vGap4,
Row(
children: [
const Icon(
Remix.thumb_up_line,
color: Colors.grey,
size: 14,
),
AppStyle.hGap4,
Text(
"${item.likeAmount}",
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
AppStyle.hGap12,
const Icon(
Remix.message_2_line,
color: Colors.grey,
size: 14,
),
AppStyle.hGap4,
Text(
"${item.likeAmount}",
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
const Expanded(child: SizedBox()),
Text(
Utils.formatTimestamp(item.createTime),
style: const TextStyle(
fontSize: 12,
color: Colors.grey,
),
),
],
),
],
),
),
],
),
),
],
),
);
},
),
);
}
void toDetail(UserCommentItem item) {
//漫画
if (type == 0) {
AppNavigator.toComicDetail(item.objId);
} else if (type == 1) {
AppNavigator.toNovelDetail(item.objId);
} else if (type == 2) {
AppNavigator.toNewsDetail(url: item.pageUrl ?? "");
}
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/user/comic_history_model.dart';
import 'package:flutter_dmzj/requests/user_request.dart';
class ComicHistoryController extends BasePageController<UserComicHistoryModel> {
final UserRequest request = UserRequest();
@override
Future<List<UserComicHistoryModel>> getData(int page, int pageSize) async {
if (page > 1) {
return [];
}
return await request.comicHistory();
}
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/user/comic_history_model.dart';
import 'package:flutter_dmzj/modules/user/history/comic/comic_history_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
class ComicHistoryView extends StatelessWidget {
final ComicHistoryController controller;
ComicHistoryView({super.key})
: controller = Get.put(ComicHistoryController());
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: PageListView(
pageController: controller,
firstRefresh: true,
loadMore: false,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
),
);
}
Widget buildItem(UserComicHistoryModel item) {
return InkWell(
onTap: () {
AppNavigator.toComicDetail(item.comicId);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.cover,
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.comicName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
AppStyle.vGap4,
Text("看到${item.chapterName} ${item.record}",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
AppStyle.vGap4,
Text("观看于${Utils.formatTimestamp(item.viewingTime ?? 0)}",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/user/novel_history_model.dart';
import 'package:flutter_dmzj/requests/user_request.dart';
class NovelHistoryController extends BasePageController<UserNovelHistoryModel> {
final UserRequest request = UserRequest();
@override
Future<List<UserNovelHistoryModel>> getData(int page, int pageSize) async {
if (page > 1) {
return [];
}
return await request.novelHistory();
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/user/novel_history_model.dart';
import 'package:flutter_dmzj/modules/user/history/novel/novel_history_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
class NovelHistoryView extends StatelessWidget {
final NovelHistoryController controller;
NovelHistoryView({super.key})
: controller = Get.put(NovelHistoryController());
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: PageListView(
pageController: controller,
firstRefresh: true,
loadMore: false,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
),
);
}
Widget buildItem(UserNovelHistoryModel item) {
return InkWell(
onTap: () {
AppNavigator.toNovelDetail(item.lnovelId);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.cover,
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.novelName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
AppStyle.vGap4,
Text(
"看到${item.volumeName} ${item.chapterName} ${item.record}",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
AppStyle.vGap4,
Text("观看于${Utils.formatTimestamp(item.viewingTime ?? 0)}",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class UserHistoryController extends GetxController
with GetSingleTickerProviderStateMixin {
final int type;
UserHistoryController(this.type);
late TabController tabController;
@override
void onInit() {
tabController = TabController(length: 2, vsync: this, initialIndex: type);
super.onInit();
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/modules/user/history/comic/comic_history_view.dart';
import 'package:flutter_dmzj/modules/user/history/novel/novel_history_view.dart';
import 'package:flutter_dmzj/modules/user/history/user_history_controller.dart';
import 'package:get/get.dart';
class UserHistoryPage extends StatelessWidget {
final UserHistoryController controller;
final int type;
UserHistoryPage({this.type = 0, super.key})
: controller = Get.put(
UserHistoryController(type),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Container(
alignment: Alignment.center,
padding: const EdgeInsets.only(right: 56),
child: TabBar(
controller: controller.tabController,
isScrollable: true,
tabAlignment: TabAlignment.start,
labelPadding: AppStyle.edgeInsetsH24,
indicatorColor: Theme.of(context).colorScheme.primary,
labelColor: Theme.of(context).colorScheme.primary,
unselectedLabelColor:
Get.isDarkMode ? Colors.white70 : Colors.black87,
tabs: const [
Tab(text: "漫画记录"),
Tab(text: "小说记录"),
],
),
),
),
body: TabBarView(
controller: controller.tabController,
children: [
ComicHistoryView(),
NovelHistoryView(),
],
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/db/local_favorite.dart';
import 'package:flutter_dmzj/services/db_service.dart';
import 'package:get/get.dart';
class LocalFavoriteController extends BasePageController<LocalFavorite> {
var editMode = false.obs;
@override
Future<List<LocalFavorite>> getData(int page, int pageSize) async {
if (page > 1) {
return [];
}
return DBService.instance.localFavoriteBox.values
.where((x) => x.type == AppConstant.kTypeComic)
.toList();
}
void cancelEdit() {
for (var item in list) {
item.isChecked.value = false;
}
editMode.value = false;
}
void cancelFavorite() async {
var items = list.where((x) => x.isChecked.value).toList();
if (items.isEmpty) {
cancelEdit();
return;
}
cancelEdit();
for (var item in items) {
DBService.instance.removeComicFavorite(comicId: item.objId);
}
refreshData();
}
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/db/local_favorite.dart';
import 'package:flutter_dmzj/modules/user/local_favorite/local_favorite_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_grid_view.dart';
import 'package:flutter_dmzj/widgets/shadow_card.dart';
import 'package:get/get.dart';
class LocalFavoritePage extends StatelessWidget {
final LocalFavoriteController controller;
LocalFavoritePage({super.key})
: controller = Get.put(LocalFavoriteController());
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("本机收藏"),
),
body: LayoutBuilder(builder: (context, constraints) {
var count = constraints.maxWidth ~/ 160;
if (count < 3) count = 3;
return PageGridView(
pageController: controller,
firstRefresh: true,
crossAxisCount: count,
padding: AppStyle.edgeInsetsA12,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
);
}),
bottomNavigationBar: Obx(
() => Offstage(
offstage: !controller.editMode.value,
child: SizedBox(
height: 48,
child: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: controller.cancelFavorite,
icon: const Icon(Icons.favorite_border),
label: const Text("取消收藏"),
),
AppStyle.hGap8,
TextButton.icon(
onPressed: controller.cancelEdit,
icon: const Icon(Icons.cancel_outlined),
label: const Text("取消"),
),
],
),
),
),
),
),
);
}
Widget buildItem(LocalFavorite item) {
return ShadowCard(
onTap: () {
if (controller.editMode.value) {
item.isChecked.value = !item.isChecked.value;
return;
}
AppNavigator.toComicDetail(item.objId);
},
onLongPress: () {
if (controller.editMode.value) {
return;
}
item.isChecked.value = true;
controller.editMode.value = true;
},
radius: 4,
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AspectRatio(
aspectRatio: 27 / 36,
child: NetImage(
item.cover,
borderRadius: 4,
),
),
Padding(
padding: AppStyle.edgeInsetsA8,
child: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
height: 1.2,
),
),
),
],
),
Obx(
() => Positioned(
right: 0,
top: 0,
child: Offstage(
offstage: !controller.editMode.value,
child: Checkbox(
value: item.isChecked.value,
onChanged: (e) {
item.isChecked.value = e!;
},
),
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/db/comic_history.dart';
import 'package:flutter_dmzj/requests/user_request.dart';
import 'package:flutter_dmzj/services/db_service.dart';
class LocalComicHistoryController extends BasePageController<ComicHistory> {
final UserRequest request = UserRequest();
@override
Future<List<ComicHistory>> getData(int page, int pageSize) async {
if (page > 1) {
return [];
}
return DBService.instance.getComicHistoryList();
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/db/comic_history.dart';
import 'package:flutter_dmzj/modules/user/local_history/comic/comic_history_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
class LocalComicHistoryView extends StatelessWidget {
final LocalComicHistoryController controller;
LocalComicHistoryView({super.key})
: controller = Get.put(LocalComicHistoryController());
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: PageListView(
pageController: controller,
firstRefresh: true,
loadMore: false,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
),
);
}
Widget buildItem(ComicHistory item) {
return InkWell(
onTap: () {
AppNavigator.toComicDetail(item.comicId);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.comicCover,
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.comicName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
AppStyle.vGap4,
Text("看到${item.chapterName} ${item.page}",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
AppStyle.vGap4,
Text(
"观看于${Utils.formatTimestampMS(item.updateTime.millisecondsSinceEpoch)}",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class LocalHistoryController extends GetxController
with GetSingleTickerProviderStateMixin {
final int type;
LocalHistoryController(this.type);
late TabController tabController;
@override
void onInit() {
tabController = TabController(length: 2, vsync: this, initialIndex: type);
super.onInit();
}
}

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/modules/user/local_history/comic/comic_history_view.dart';
import 'package:flutter_dmzj/modules/user/local_history/local_history_controller.dart';
import 'package:flutter_dmzj/modules/user/local_history/novel/novel_history_view.dart';
import 'package:get/get.dart';
class LocalHistoryPage extends StatelessWidget {
final LocalHistoryController controller;
final int type;
LocalHistoryPage({this.type = 0, super.key})
: controller = Get.put(
LocalHistoryController(type),
tag: DateTime.now().millisecondsSinceEpoch.toString(),
);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Container(
alignment: Alignment.center,
padding: const EdgeInsets.only(right: 56),
child: TabBar(
controller: controller.tabController,
isScrollable: true,
tabAlignment: TabAlignment.start,
labelPadding: AppStyle.edgeInsetsH24,
indicatorColor: Theme.of(context).colorScheme.primary,
indicatorSize: TabBarIndicatorSize.label,
labelColor: Theme.of(context).colorScheme.primary,
unselectedLabelColor:
Get.isDarkMode ? Colors.white70 : Colors.black87,
tabs: const [
Tab(text: "漫画记录"),
Tab(text: "小说记录"),
],
),
),
),
body: TabBarView(
controller: controller.tabController,
children: [
LocalComicHistoryView(),
LocalNovelHistoryView(),
],
),
);
}
}

View File

@@ -0,0 +1,17 @@
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/db/novel_history.dart';
import 'package:flutter_dmzj/requests/user_request.dart';
import 'package:flutter_dmzj/services/db_service.dart';
class LocalNovelHistoryController extends BasePageController<NovelHistory> {
final UserRequest request = UserRequest();
@override
Future<List<NovelHistory>> getData(int page, int pageSize) async {
if (page > 1) {
return [];
}
return DBService.instance.getNovelHistoryList();
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/app/utils.dart';
import 'package:flutter_dmzj/models/db/novel_history.dart';
import 'package:flutter_dmzj/modules/user/local_history/novel/novel_history_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_list_view.dart';
import 'package:get/get.dart';
class LocalNovelHistoryView extends StatelessWidget {
final LocalNovelHistoryController controller;
LocalNovelHistoryView({super.key})
: controller = Get.put(LocalNovelHistoryController());
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: PageListView(
pageController: controller,
firstRefresh: true,
loadMore: false,
separatorBuilder: (context, i) => Divider(
endIndent: 12,
indent: 12,
color: Colors.grey.withOpacity(.2),
height: 1,
),
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
),
);
}
Widget buildItem(NovelHistory item) {
return InkWell(
onTap: () {
AppNavigator.toNovelDetail(item.novelId);
},
child: Container(
padding: AppStyle.edgeInsetsA12,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
NetImage(
item.novelCover,
width: 80,
height: 110,
borderRadius: 4,
),
AppStyle.hGap12,
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
item.novelName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
AppStyle.vGap4,
Text("看到${item.volumeName} ${item.chapterName}",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
AppStyle.vGap4,
Text(
"观看于${Utils.formatTimestampMS(item.updateTime.millisecondsSinceEpoch)}",
style: const TextStyle(color: Colors.grey, fontSize: 14)),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/log.dart';
import 'package:flutter_dmzj/requests/user_request.dart';
import 'package:flutter_dmzj/services/user_service.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class UserLoginController extends GetxController {
final TextEditingController userNameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
final UserRequest userRequest = UserRequest();
var loadding = false.obs;
void login() async {
if (userNameController.text.isEmpty) {
SmartDialog.showToast("请输入用户名");
return;
}
if (passwordController.text.isEmpty) {
SmartDialog.showToast("请输入密码");
return;
}
try {
loadding.value = true;
var data = await userRequest.login(
nickname: userNameController.text,
password: passwordController.text,
);
UserService.instance.setAuthInfo(data);
loadding.value = false;
Get.back(result: true);
} catch (e) {
SmartDialog.showToast(e.toString());
Log.logPrint(e);
} finally {
loadding.value = false;
}
}
}

View File

@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/modules/user/login/user_login_controller.dart';
import 'package:get/get.dart';
class UserLoginDialog extends StatelessWidget {
final UserLoginController controller = Get.put(UserLoginController());
UserLoginDialog({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: AppStyle.radius12,
),
child: Container(
constraints: const BoxConstraints(
maxWidth: 400,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
contentPadding: AppStyle.edgeInsetsL12,
title: const Text("登录"),
trailing: IconButton(
onPressed: Get.back,
icon: const Icon(Icons.close),
),
),
AppStyle.vGap12,
Padding(
padding: AppStyle.edgeInsetsH24,
child: TextField(
controller: controller.userNameController,
autofocus: true,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
hintText: "请输入用户名/手机号",
labelText: "用户名/手机号",
floatingLabelBehavior: FloatingLabelBehavior.always,
contentPadding: AppStyle.edgeInsetsH8,
border: OutlineInputBorder(),
),
),
),
AppStyle.vGap24,
Padding(
padding: AppStyle.edgeInsetsH24,
child: TextField(
controller: controller.passwordController,
obscureText: true,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
hintText: "请输入密码",
labelText: "密码",
floatingLabelBehavior: FloatingLabelBehavior.always,
contentPadding: AppStyle.edgeInsetsH8,
border: OutlineInputBorder(),
),
onSubmitted: (e) {
controller.login();
},
),
),
AppStyle.vGap12,
Container(
width: double.infinity,
padding: AppStyle.edgeInsetsA12.copyWith(left: 24, right: 24),
child: SizedBox(
height: 40,
child: Obx(
() => ElevatedButton(
style: ElevatedButton.styleFrom(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: AppStyle.radius24,
),
),
onPressed:
controller.loadding.value ? null : controller.login,
child: controller.loadding.value
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
),
)
: const Text("登录"),
),
),
),
),
AppStyle.vGap12,
],
),
),
);
}
}

View File

@@ -0,0 +1,97 @@
import 'package:extended_image/extended_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/services/app_settings_service.dart';
import 'package:flutter_dmzj/services/local_storage_service.dart';
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart';
import 'package:get/get.dart';
class SettingsController extends GetxController {
final settings = AppSettingsService.instance;
var imageCacheSize = "正在计算缓存...".obs;
var novelCacheSize = "正在计算缓存...".obs;
@override
void onInit() {
super.onInit();
getImageCachedSize();
getNovelCachedSize();
}
void getImageCachedSize() async {
try {
imageCacheSize.value = "正在计算缓存...";
var bytes = await getCachedSizeBytes();
imageCacheSize.value = "${(bytes / 1024 / 1024).toStringAsFixed(1)}MB";
} catch (e) {
imageCacheSize.value = "缓存计算失败";
}
}
void getNovelCachedSize() async {
try {
novelCacheSize.value = "正在计算缓存...";
var bytes = await LocalStorageService.instance.getNovelCacheSize();
novelCacheSize.value = "${(bytes / 1024 / 1024).toStringAsFixed(1)}MB";
} catch (e) {
novelCacheSize.value = "缓存计算失败";
}
}
void cleanImageCache() async {
var result = await clearDiskCachedImages();
if (!result) {
SmartDialog.showToast("清除失败");
}
getImageCachedSize();
}
void cleanNovelCache() async {
var result = await LocalStorageService.instance.cleanNovelCacheSize();
if (!result) {
SmartDialog.showToast("清除失败");
}
getNovelCachedSize();
}
void setDownloadComicTask() {
Get.dialog(
SimpleDialog(
title: const Text("漫画最大任务数"),
children: [0, 1, 2, 3, 4, 5]
.map(
(e) => RadioListTile<int>(
title: Text(e == 0 ? "无限制" : "$e个"),
value: e,
groupValue: settings.downloadComicTaskCount.value,
onChanged: (e) {
Get.back();
settings.setDownloadComicTaskCount(e ?? 0);
},
),
)
.toList(),
),
);
}
void setDownloadNovelTask() {
Get.dialog(
SimpleDialog(
title: const Text("小说最大任务数"),
children: [0, 1, 2, 3, 4, 5]
.map(
(e) => RadioListTile<int>(
title: Text(e == 0 ? "无限制" : "$e个"),
value: e,
groupValue: settings.downloadNovelTaskCount.value,
onChanged: (e) {
Get.back();
settings.setDownloadNovelTaskCount(e ?? 0);
},
),
)
.toList(),
),
);
}
}

View File

@@ -0,0 +1,529 @@
import 'dart:io';
import 'package:fluent_ui/fluent_ui.dart' as fluent;
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/platform_utils.dart';
import 'package:flutter_dmzj/modules/user/settings/settings_controller.dart';
import 'package:get/get.dart';
import 'package:remixicon/remixicon.dart';
class SettingsPage extends StatelessWidget {
final int index;
SettingsPage({required this.index, super.key});
final controller = Get.put<SettingsController>(SettingsController());
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 4,
initialIndex: index,
child: Scaffold(
appBar: AppBar(
title: Container(
alignment: Alignment.center,
padding: const EdgeInsets.only(right: 56),
child: TabBar(
isScrollable: true,
tabAlignment: TabAlignment.start,
indicatorSize: TabBarIndicatorSize.label,
indicatorColor: Theme.of(context).colorScheme.primary,
labelColor: Theme.of(context).colorScheme.primary,
unselectedLabelColor:
Get.isDarkMode ? Colors.white70 : Colors.black87,
tabs: const [
Tab(text: "常规"),
Tab(text: "漫画"),
Tab(text: "小说"),
Tab(text: "下载"),
],
),
),
),
body: TabBarView(
children: [
buildGeneralSettings(),
buildComicSettings(),
buildNovelSettings(),
buildDownloadSettings(),
],
),
),
);
}
Widget buildGeneralSettings() {
return Obx(
() => ListView(
padding: AppStyle.edgeInsetsA12,
children: [
buildToggle(
value: controller.settings.useDynamicColor.value,
onChanged: (e) {
controller.settings.setUseDynamicColor(e);
},
title: "使用MD动态取色",
subtitle: "关闭后使用固定主题色 #4196f9",
),
ListTile(
title: const Text("清除图片缓存"),
subtitle: Text(controller.imageCacheSize.value),
trailing: OutlinedButton(
onPressed: () {
controller.cleanImageCache();
},
child: const Text("清除"),
),
),
ListTile(
title: const Text("清除小说缓存"),
subtitle: Text(controller.novelCacheSize.value),
trailing: OutlinedButton(
onPressed: () {},
child: const Text("清除"),
),
),
// SwitchListTile(
// value: controller.settings.comicSearchUseWebApi.value,
// onChanged: (e) {
// controller.settings.setComicSearchUseWebApi(e);
// },
// title: const Text("使用Web接口搜索漫画"),
// subtitle: const Text("开启后可以搜索到更多漫画"),
// ),
buildToggle(
value: controller.settings.useSystemFontSize.value,
onChanged: (e) {
controller.settings.setUseSystemFontSize(e);
},
title: "字体大小跟随系统",
subtitle: "开启可能会有布局错乱",
),
buildToggle(
value: controller.settings.collectHideComic.value,
onChanged: (e) {
controller.settings.setCollectHideComic(e);
},
title: "自动收藏神隐漫画",
subtitle: "浏览神隐漫画时自动添加到本机收藏",
),
ListTile(
title: const Text("代理地址"),
subtitle: TextField(
controller: TextEditingController(text: controller.settings.proxyAddress.value),
decoration: const InputDecoration(
hintText: "仅支持http协议,重启生效 eg:127.0.0.1:7890",
),
onSubmitted: (e){
controller.settings.setProxyAddress(e);
},
),
)
],
),
);
}
Widget buildComicSettings() {
return Obx(
() => ListView(
padding: AppStyle.edgeInsetsA12,
children: [
buildToggle(
value: controller.settings.comicReaderHD.value,
onChanged: (e) {
controller.settings.setComicReaderHD(e);
},
title: "优先加载高清图",
subtitle: "部分单行本可能未分页",
),
ListTile(
title: const Text("阅读方向"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
buildSelectedButton(
onTap: () {
controller.settings.setComicReaderDirection(0);
},
selected: controller.settings.comicReaderDirection.value == 0,
child: const Icon(Remix.arrow_right_line),
),
AppStyle.hGap8,
buildSelectedButton(
onTap: () {
controller.settings.setComicReaderDirection(2);
},
selected: controller.settings.comicReaderDirection.value == 2,
child: const Icon(Remix.arrow_left_line),
),
AppStyle.hGap8,
buildSelectedButton(
onTap: () {
controller.settings.setComicReaderDirection(1);
},
selected: controller.settings.comicReaderDirection.value == 1,
child: const Icon(Remix.arrow_down_line),
)
],
),
),
buildToggle(
value: controller.settings.comicReaderLeftHandMode.value,
onChanged: (e) {
controller.settings.setComicReaderLeftHandMode(e);
},
title: "操作反转",
subtitle: "点击左侧下一页,右侧上一页",
),
buildToggle(
value: controller.settings.comicReaderFullScreen.value,
onChanged: (e) {
controller.settings.setComicReaderFullScreen(e);
},
title: "全屏阅读",
),
buildToggle(
value: controller.settings.comicReaderShowStatus.value,
onChanged: (e) {
controller.settings.setComicReaderShowStatus(e);
},
title: "显示状态信息",
),
buildToggle(
value: controller.settings.comicReaderShowViewPoint.value,
onChanged: (e) {
controller.settings.setComicReaderShowViewPoint(e);
},
title: "显示吐槽",
),
buildToggle(
value: controller.settings.comicReaderOldViewPoint.value,
onChanged: (e) {
controller.settings.setComicReaderOldViewPoint(e);
},
title: "旧版吐槽",
),
buildToggle(
value: controller.settings.comicReaderPageAnimation.value,
onChanged: (e) {
controller.settings.setComicReaderPageAnimation(e);
},
title: "翻页动画",
),
],
),
);
}
Widget buildNovelSettings() {
return Obx(
() => ListView(
padding: AppStyle.edgeInsetsA12,
children: [
ListTile(
title: const Text("阅读方向"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
buildSelectedButton(
onTap: () {
controller.settings.setNovelReaderDirection(0);
},
selected: controller.settings.novelReaderDirection.value == 0,
child: const Icon(Remix.arrow_right_line),
),
AppStyle.hGap8,
buildSelectedButton(
onTap: () {
controller.settings.setNovelReaderDirection(2);
},
selected: controller.settings.novelReaderDirection.value == 2,
child: const Icon(Remix.arrow_left_line),
),
AppStyle.hGap8,
buildSelectedButton(
onTap: () {
controller.settings.setNovelReaderDirection(1);
},
selected: controller.settings.novelReaderDirection.value == 1,
child: const Icon(Remix.arrow_down_line),
)
],
),
),
buildToggle(
value: controller.settings.novelReaderLeftHandMode.value,
onChanged: (e) {
controller.settings.setNovelReaderLeftHandMode(e);
},
title: "操作反转",
subtitle: "点击左侧下一页,右侧上一页",
),
// SwitchListTile(
// value: settings.novelReaderFullScreen.value,
// onChanged: (e) {
// settings.setNovelReaderFullScreen(e);
// },
// title: const Text("全屏阅读"),
// ),
buildToggle(
value: controller.settings.novelReaderShowStatus.value,
onChanged: (e) {
controller.settings.setNovelReaderShowStatus(e);
},
title: "显示状态信息",
),
buildToggle(
value: controller.settings.novelReaderPageAnimation.value,
onChanged: (e) {
controller.settings.setNovelReaderPageAnimation(e);
},
title: "翻页动画",
),
ListTile(
title: const Text("字体大小"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton(
onPressed: () {
controller.settings.setNovelReaderFontSize(
controller.settings.novelReaderFontSize.value + 1,
);
},
child: const Icon(
Icons.add,
),
),
AppStyle.hGap12,
Text("${controller.settings.novelReaderFontSize.value}"),
AppStyle.hGap12,
OutlinedButton(
onPressed: () {
controller.settings.setNovelReaderFontSize(
controller.settings.novelReaderFontSize.value - 1,
);
},
child: const Icon(
Icons.remove,
),
),
],
),
),
ListTile(
title: const Text("行距"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
OutlinedButton(
onPressed: () {
controller.settings.setNovelReaderLineSpacing(
controller.settings.novelReaderLineSpacing.value + 0.1,
);
},
child: const Icon(
Icons.add,
),
),
AppStyle.hGap12,
Text((controller.settings.novelReaderLineSpacing.value)
.toStringAsFixed(1)),
AppStyle.hGap12,
OutlinedButton(
onPressed: () {
controller.settings.setNovelReaderLineSpacing(
controller.settings.novelReaderLineSpacing.value - 0.1,
);
},
child: const Icon(
Icons.remove,
),
),
],
),
),
ListTile(
title: const Text("阅读主题"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: AppColor.novelThemes.keys
.map(
(e) => GestureDetector(
onTap: () {
controller.settings.setNovelReaderTheme(e);
},
child: Container(
margin: AppStyle.edgeInsetsL8,
height: 36,
width: 36,
decoration: BoxDecoration(
color: AppColor.novelThemes[e]!.first,
borderRadius: AppStyle.radius24,
),
child: Visibility(
visible:
AppColor.novelThemes.keys.toList().indexOf(e) ==
controller.settings.novelReaderTheme.value,
child: Icon(
Icons.check,
color: AppColor.novelThemes[e]!.last,
),
),
),
),
)
.toList(),
),
),
Container(
margin: AppStyle.edgeInsetsV12,
padding: AppStyle.edgeInsetsA8,
decoration: BoxDecoration(
borderRadius: AppStyle.radius4,
color: AppColor
.novelThemes[controller.settings.novelReaderTheme]!.first,
),
child: Text(
"""这是一段测试文字,可以预览上面的设置效果。
  晋太元中,武陵人捕鱼为业。缘溪行,忘路之远近。忽逢桃花林,夹岸数百步,中无杂树,芳草鲜美,落英缤纷。渔人甚异之,复前行,欲穷其林。
  林尽水源,便得一山,山有小口,仿佛若有光。便舍船,从口入。初极狭,才通人。复行数十步,豁然开朗。土地平旷,屋舍俨然,有良田、美池、桑竹之属。阡陌交通,鸡犬相闻。其中往来种作,男女衣着,悉如外人。黄发垂髫,并怡然自乐……""",
//不需要跟随系统
textScaler: const TextScaler.linear(1.0),
style: TextStyle(
fontSize:
controller.settings.novelReaderFontSize.value.toDouble(),
height: controller.settings.novelReaderLineSpacing.value,
color: AppColor
.novelThemes[controller.settings.novelReaderTheme]!.last,
),
),
),
],
),
);
}
Widget buildDownloadSettings() {
return Obx(
() => ListView(
padding: AppStyle.edgeInsetsA12,
children: [
buildToggle(
value: controller.settings.downloadAllowCellular.value,
onChanged: (e) {
controller.settings.setDownloadAllowCellular(e);
},
title: "允许使用流量下载",
),
ListTile(
title: const Text("漫画最大任务数"),
onTap: () {
controller.setDownloadComicTask();
},
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
controller.settings.downloadComicTaskCount.value == 0
? "无限制"
: controller.settings.downloadComicTaskCount.toString(),
),
AppStyle.hGap4,
const Icon(
Icons.chevron_right,
color: Colors.grey,
),
],
),
),
ListTile(
title: const Text("小说最大任务数"),
onTap: () {
controller.setDownloadNovelTask();
},
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
controller.settings.downloadNovelTaskCount.value == 0
? "无限制"
: controller.settings.downloadNovelTaskCount.toString(),
),
AppStyle.hGap4,
const Icon(
Icons.chevron_right,
color: Colors.grey,
),
],
),
),
],
),
);
}
Widget buildSelectedButton(
{required Widget child, bool selected = false, Function()? onTap}) {
final primary = Get.theme.colorScheme.primary;
return OutlinedButton(
style: OutlinedButton.styleFrom(
foregroundColor: selected ? primary : Colors.grey,
side: BorderSide(
color: selected ? primary : Colors.grey,
),
),
onPressed: onTap,
child: child,
);
}
/// 平台自适应开关控件
/// Windows使用Fluent ToggleSwitch其他平台使用Material SwitchListTile
Widget buildToggle({
required String title,
required bool value,
required ValueChanged<bool> onChanged,
String? subtitle,
}) {
if (PlatformUtils.isWindows) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: Get.textTheme.bodyMedium),
if (subtitle != null)
Text(subtitle,
style: Get.textTheme.bodySmall
?.copyWith(color: Colors.grey)),
],
),
),
fluent.FluentTheme(
data: PlatformUtils.getFluentTheme(Get.context!),
child: fluent.ToggleSwitch(
checked: value,
onChanged: onChanged,
),
),
],
),
);
}
return SwitchListTile(
value: value,
onChanged: onChanged,
title: Text(title),
subtitle: subtitle != null ? Text(subtitle) : null,
);
}
}

View File

@@ -0,0 +1,78 @@
import 'package:flutter_dmzj/app/app_constant.dart';
import 'package:flutter_dmzj/app/controller/base_controller.dart';
import 'package:flutter_dmzj/models/user/subscribe_comic_model.dart';
import 'package:flutter_dmzj/requests/user_request.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 ComicSubscribeController
extends BasePageController<UserSubscribeComicItemModel> {
ComicSubscribeController() {
for (var item in List.generate(
26, (index) => String.fromCharCode(index + 65).toLowerCase())) {
letters.addAll({item: "${item.toUpperCase()}开头"});
}
}
final UserRequest request = UserRequest();
var letter = "".obs;
Map letters = {
"": "全部",
"number": "数字开头",
};
Map<int, String> types = {
1: "全部订阅",
2: "未读",
3: "已读",
4: "完结",
};
var type = 1.obs;
var editMode = false.obs;
@override
Future<List<UserSubscribeComicItemModel>> getData(
int page, int pageSize) async {
var ls = await request.comicSubscribes(
subType: type.value,
letter: letter.value,
page: page,
);
UserService.instance.subscribedComicIds.addAll(ls.map((e) => e.id));
return ls;
}
void cancelEdit() {
for (var item in list) {
item.isChecked.value = false;
}
editMode.value = false;
}
void cancelSub() async {
var ids = list.where((x) => x.isChecked.value).map((e) => e.id).toList();
if (ids.isEmpty) {
cancelEdit();
return;
}
cancelEdit();
await UserService.instance.cancelSubscribe(ids, AppConstant.kTypeComic);
easyRefreshController.callRefresh();
}
void addFavorite() async {
for (var item in list.where((x) => x.isChecked.value)) {
DBService.instance.putComicFavorite(
title: item.title,
cover: item.cover,
comicId: item.id,
);
}
cancelEdit();
SmartDialog.showToast("已添加至本机收藏");
}
}

View File

@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:flutter_dmzj/app/app_style.dart';
import 'package:flutter_dmzj/models/user/subscribe_comic_model.dart';
import 'package:flutter_dmzj/modules/user/subscribe/comic/comic_subscribe_controller.dart';
import 'package:flutter_dmzj/routes/app_navigator.dart';
import 'package:flutter_dmzj/widgets/keep_alive_wrapper.dart';
import 'package:flutter_dmzj/widgets/net_image.dart';
import 'package:flutter_dmzj/widgets/page_grid_view.dart';
import 'package:flutter_dmzj/widgets/shadow_card.dart';
import 'package:get/get.dart';
class ComicSubscribeView extends StatelessWidget {
final ComicSubscribeController controller;
ComicSubscribeView({super.key})
: controller = Get.put(ComicSubscribeController());
@override
Widget build(BuildContext context) {
return KeepAliveWrapper(
child: Column(
children: [
Obx(
() => Row(
children: [
buildFilter(
// ignore: invalid_use_of_protected_member
types: controller.letters,
value: controller.letter.value,
onSelected: (e) {
controller.letter.value = e;
controller.refreshData();
},
),
buildFilter(
types: controller.types,
value: controller.type.value,
onSelected: (e) {
controller.type.value = e;
controller.refreshData();
},
),
],
),
),
Divider(
color: Colors.grey.withOpacity(.2),
height: 1.0,
),
Expanded(
child: LayoutBuilder(builder: (context, constraints) {
var count = constraints.maxWidth ~/ 160;
if (count < 3) count = 3;
return PageGridView(
pageController: controller,
firstRefresh: true,
crossAxisCount: count,
padding: AppStyle.edgeInsetsA12,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
itemBuilder: (context, i) {
var item = controller.list[i];
return buildItem(item);
},
);
}),
),
Obx(
() => Offstage(
offstage: !controller.editMode.value,
child: SizedBox(
height: 48,
child: BottomAppBar(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton.icon(
onPressed: controller.addFavorite,
icon: const Icon(Icons.star_border),
label: const Text("添加收藏"),
),
AppStyle.hGap8,
TextButton.icon(
onPressed: controller.cancelSub,
icon: const Icon(Icons.favorite_border),
label: const Text("取消订阅"),
),
AppStyle.hGap8,
TextButton.icon(
onPressed: controller.cancelEdit,
icon: const Icon(Icons.cancel_outlined),
label: const Text("取消"),
),
],
),
),
),
),
),
],
),
);
}
Widget buildItem(UserSubscribeComicItemModel item) {
return ShadowCard(
onTap: () {
if (controller.editMode.value) {
item.isChecked.value = !item.isChecked.value;
return;
}
item.hasNew.value = false;
AppNavigator.toComicDetail(item.id);
},
onLongPress: () {
if (controller.editMode.value) {
return;
}
item.isChecked.value = true;
controller.editMode.value = true;
},
radius: 4,
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
AspectRatio(
aspectRatio: 27 / 36,
child: NetImage(
item.cover,
borderRadius: 4,
),
),
Positioned(
left: 0,
bottom: 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,
),
),
),
),
Positioned(
right: 0,
top: 0,
child: Obx(
() => Visibility(
visible: item.hasNew.value,
child: Container(
decoration: const BoxDecoration(
color: Colors.deepOrange,
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(4),
topRight: Radius.circular(4),
),
),
padding:
AppStyle.edgeInsetsH8.copyWith(top: 2, bottom: 2),
child: const Text(
"",
style: TextStyle(
fontSize: 12,
color: Colors.white,
),
),
),
),
),
),
],
),
AppStyle.vGap4,
Padding(
padding: AppStyle.edgeInsetsH4,
child: Text(
item.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
height: 1.2,
),
),
),
AppStyle.vGap4,
Padding(
padding: AppStyle.edgeInsetsH4,
child: Text(
"更新 ${item.lastUpdateChapterName}",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.grey,
fontSize: 12.0,
height: 1.2,
),
),
),
AppStyle.vGap4,
],
),
Obx(
() => Positioned(
right: 0,
top: 0,
child: Offstage(
offstage: !controller.editMode.value,
child: Checkbox(
value: item.isChecked.value,
onChanged: (e) {
item.isChecked.value = e!;
},
),
),
),
),
],
),
);
}
Widget buildFilter({
required Map types,
required dynamic value,
required Function(dynamic) onSelected,
}) {
return Expanded(
child: PopupMenuButton(
onSelected: onSelected,
itemBuilder: (c) => types.keys
.map(
(k) => CheckedPopupMenuItem(
value: k,
checked: k == value,
child: Text(types[k] ?? ""),
),
)
.toList(),
child: SizedBox(
height: 40,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
types[value] ?? "",
),
const Icon(
Icons.arrow_drop_down,
color: Colors.grey,
)
],
),
),
),
);
}
}

Some files were not shown because too many files have changed in this diff Show More