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