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