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