From d3754087fdb9e07a768f752d6a7bc84887ae6488 Mon Sep 17 00:00:00 2001 From: Nekoli Date: Wed, 15 Jul 2026 00:36:26 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(novel):=20=E6=96=B0=E5=A2=9E=E5=B0=8F?= =?UTF-8?q?=E8=AF=B4=E9=98=85=E8=AF=BB=E5=99=A8=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=AB=A0=E8=8A=82=E6=B5=8F=E8=A7=88=E3=80=81=E5=AD=97=E4=BD=93?= =?UTF-8?q?=E8=B0=83=E8=8A=82=E5=92=8C=E6=B2=89=E6=B5=B8=E9=98=85=E8=AF=BB?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.MD | 4 + lib/reader/novel_reader.dart | 355 +++++++++++++++++++++++++++++++++++ lib/subject/episode.dart | 31 +++ 3 files changed, 390 insertions(+) create mode 100644 lib/reader/novel_reader.dart diff --git a/CHANGELOG.MD b/CHANGELOG.MD index d9804d0..a13c51c 100644 --- a/CHANGELOG.MD +++ b/CHANGELOG.MD @@ -2,6 +2,10 @@ 更新日志文档,版本顺序从新到旧,最新版本在最前(上)面。 +# 1.8.0 + +- 新增小说阅读器,支持章节浏览、字体大小调节、沉浸阅读模式 + # 1.7.2 - VIP/转码流自动设置字幕延迟6秒,seek/reload 后重新应用 diff --git a/lib/reader/novel_reader.dart b/lib/reader/novel_reader.dart new file mode 100644 index 0000000..ab09508 --- /dev/null +++ b/lib/reader/novel_reader.dart @@ -0,0 +1,355 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:ikaros/api/attachment/AttachmentApi.dart'; +import 'package:ikaros/api/dio_client.dart'; +import 'package:ikaros/api/subject/EpisodeApi.dart'; +import 'package:ikaros/api/subject/SubjectApi.dart'; +import 'package:ikaros/api/subject/model/Episode.dart'; +import 'package:ikaros/api/subject/model/EpisodeResource.dart'; +import 'package:ikaros/api/subject/model/Subject.dart'; +import 'package:ikaros/utils/message_utils.dart'; + +/// 小说阅读器主页 +class NovelReaderPage extends StatefulWidget { + final String subjectId; + + const NovelReaderPage({super.key, required this.subjectId}); + + @override + State createState() => _NovelReaderPageState(); +} + +class _NovelReaderPageState extends State { + Subject? _subject; + List _chapters = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _loadData() async { + setState(() => _isLoading = true); + try { + _subject = await SubjectApi().findById(widget.subjectId); + _chapters = await EpisodeApi().findBySubjectId(widget.subjectId); + _chapters.sort((a, b) => a.sequence.compareTo(b.sequence)); + } catch (e) { + if (mounted) { + Toast.show(context, "加载小说数据失败: $e"); + } + } + if (mounted) { + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_subject?.nameCn ?? _subject?.name ?? "小说阅读器"), + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _buildChapterList(), + ); + } + + Widget _buildChapterList() { + if (_chapters.isEmpty) { + return const Center(child: Text("暂无章节")); + } + return ListView.builder( + itemCount: _chapters.length, + itemBuilder: (context, index) { + final chapter = _chapters[index]; + final title = chapter.nameCn ?? chapter.name; + final desc = chapter.description; + return ListTile( + leading: Icon(Icons.menu_book, + color: Theme.of(context).colorScheme.primary), + title: Text(title), + subtitle: desc != null && desc.isNotEmpty + ? Text(desc.length > 50 ? "${desc.substring(0, 50)}…" : desc, + maxLines: 1, overflow: TextOverflow.ellipsis) + : Text("第 ${chapter.sequence.toInt()} 章"), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openChapter(chapter), + ); + }, + ); + } + + void _openChapter(Episode chapter) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => NovelChapterPage( + chapter: chapter, + subjectName: _subject?.nameCn ?? _subject?.name ?? "", + allChapters: _chapters, + ), + ), + ); + } +} + +/// 小说章节阅读页 +class NovelChapterPage extends StatefulWidget { + final Episode chapter; + final String subjectName; + final List allChapters; + + const NovelChapterPage({ + super.key, + required this.chapter, + required this.subjectName, + required this.allChapters, + }); + + @override + State createState() => _NovelChapterPageState(); +} + +class _NovelChapterPageState extends State { + String _content = ""; + bool _isLoading = true; + double _fontSize = 18.0; + bool _showControls = true; + + late int _currentIndex; + + @override + void initState() { + super.initState(); + _currentIndex = widget.allChapters.indexOf(widget.chapter); + _loadContent(); + } + + Future _loadContent() async { + setState(() => _isLoading = true); + try { + String? text = await _fetchChapterContent(widget.chapter); + if (mounted) { + setState(() { + _content = text ?? "(本章暂无内容)"; + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _content = "加载失败: $e"; + _isLoading = false; + }); + } + } + } + + /// 从章节描述或附件文件中获取文字内容 + Future _fetchChapterContent(Episode chapter) async { + // 优先使用 description 字段 + if (chapter.description != null && chapter.description!.isNotEmpty) { + return chapter.description; + } + // 尝试从附件读取文本 + try { + List resources = + await EpisodeApi().getEpisodeResourcesRefs(chapter.id); + if (resources.isNotEmpty) { + String url = resources.first.url; + if (url.isEmpty && resources.first.attachmentId.isNotEmpty) { + url = await AttachmentApi() + .findReadUrlByAttachmentId(resources.first.attachmentId); + } + if (url.isNotEmpty) { + final dio = await DioClient.getDio(); + final response = await dio.get(url, + options: Options( + responseType: ResponseType.plain, + headers: {"Accept": "text/plain, text/html, */*"}, + )); + if (response.statusCode == 200 && response.data is String) { + return response.data; + } + } + } + } catch (_) { + // ignore fetch error + } + return null; + } + + void _nextChapter() { + if (_currentIndex < widget.allChapters.length - 1) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => NovelChapterPage( + chapter: widget.allChapters[_currentIndex + 1], + subjectName: widget.subjectName, + allChapters: widget.allChapters, + ), + ), + ); + } + } + + void _prevChapter() { + if (_currentIndex > 0) { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => NovelChapterPage( + chapter: widget.allChapters[_currentIndex - 1], + subjectName: widget.subjectName, + allChapters: widget.allChapters, + ), + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final chapterTitle = widget.chapter.nameCn ?? widget.chapter.name; + return Scaffold( + appBar: _showControls + ? AppBar( + title: Text(chapterTitle), + actions: [ + IconButton( + icon: const Icon(Icons.text_increase), + tooltip: "字体大小", + onPressed: _showFontSizeDialog, + ), + ], + ) + : null, + backgroundColor: const Color(0xFFF5F0E8), + body: Column( + children: [ + if (!_showControls) + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + "$chapterTitle — ${widget.subjectName}", + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ), + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : GestureDetector( + onTap: () => + setState(() => _showControls = !_showControls), + onHorizontalDragEnd: (details) { + if (details.primaryVelocity != null) { + if (details.primaryVelocity! < -200) { + _nextChapter(); + } else if (details.primaryVelocity! > 200) { + _prevChapter(); + } + } + }, + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 16), + child: SelectableText( + _content, + style: TextStyle( + fontSize: _fontSize, + height: 1.6, + color: const Color(0xFF333333), + letterSpacing: 0.5, + ), + ), + ), + ), + ), + if (_showControls) _buildBottomBar(chapterTitle), + ], + ), + ); + } + + Widget _buildBottomBar(String chapterTitle) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton.icon( + onPressed: _currentIndex > 0 ? _prevChapter : null, + icon: const Icon(Icons.chevron_left), + label: const Text("上一章"), + ), + Text( + "${_currentIndex + 1} / ${widget.allChapters.length}", + style: const TextStyle(color: Colors.grey), + ), + TextButton.icon( + onPressed: _currentIndex < widget.allChapters.length - 1 + ? _nextChapter + : null, + icon: const Icon(Icons.chevron_right), + label: const Text("下一章"), + ), + ], + ), + ), + ); + } + + Future _showFontSizeDialog() async { + double tempSize = _fontSize; + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + title: const Text("字体大小"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text("当前: ${tempSize.round()}px"), + Slider( + value: tempSize, + min: 12, + max: 32, + divisions: 20, + label: "${tempSize.round()}", + onChanged: (v) => setDialogState(() => tempSize = v), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text("取消")), + TextButton( + onPressed: () { + setState(() => _fontSize = tempSize); + Navigator.pop(ctx); + }, + child: const Text("确定"), + ), + ], + ), + ), + ); + } +} diff --git a/lib/subject/episode.dart b/lib/subject/episode.dart index 8167aaf..73f1ebb 100644 --- a/lib/subject/episode.dart +++ b/lib/subject/episode.dart @@ -28,6 +28,7 @@ import 'package:ikaros/player/player_audio_desktop.dart'; import 'package:ikaros/player/player_audio_mobile.dart'; import 'package:ikaros/player/player_video_desktop.dart'; import 'package:ikaros/player/player_video_mobile.dart'; +import 'package:ikaros/reader/novel_reader.dart'; import 'package:ikaros/utils/message_utils.dart'; import 'package:ikaros/utils/number_utils.dart'; import 'package:ikaros/utils/screen_utils.dart'; @@ -329,11 +330,41 @@ class _SubjectEpisodesState extends State { Widget _buildMediaPlayer() { if (_subject == null) return const LinearProgressIndicator(); + if (SubjectType.NOVEL == _subject?.type) { + return _buildNovelReaderButton(); + } return SubjectType.MUSIC == _subject?.type ? _buildAudioPlayer() : _buildVideoPlayer(); } + Widget _buildNovelReaderButton() { + return Padding( + padding: const EdgeInsets.all(16.0), + child: SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton.icon( + onPressed: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => NovelReaderPage( + subjectId: widget.subjectId, + ), + ), + ); + }, + icon: const Icon(Icons.menu_book, size: 28), + label: const Text("开始阅读小说", style: TextStyle(fontSize: 18)), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + ), + ), + ), + ); + } + Widget _buildAudioPlayer() { return Platform.isAndroid || Platform.isIOS ? MobileAudioPlayer( From 007c3b163d1bbacb8586ff88d4df27114c877cf6 Mon Sep 17 00:00:00 2001 From: Nekoli Date: Wed, 15 Jul 2026 00:40:44 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(novel):=20=E5=AE=8C=E5=96=84=E5=B0=8F?= =?UTF-8?q?=E8=AF=B4=E9=98=85=E8=AF=BB=E5=99=A8=EF=BC=8C=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=98=85=E8=AF=BB=E4=B8=BB=E9=A2=98=E5=88=87=E6=8D=A2=E5=92=8C?= =?UTF-8?q?=E5=AD=97=E4=BD=93/=E8=BF=9B=E5=BA=A6=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/reader/novel_reader.dart | 275 +++++++++++++++++++++++++++-------- 1 file changed, 216 insertions(+), 59 deletions(-) diff --git a/lib/reader/novel_reader.dart b/lib/reader/novel_reader.dart index ab09508..d7f8837 100644 --- a/lib/reader/novel_reader.dart +++ b/lib/reader/novel_reader.dart @@ -8,6 +8,42 @@ import 'package:ikaros/api/subject/model/Episode.dart'; import 'package:ikaros/api/subject/model/EpisodeResource.dart'; import 'package:ikaros/api/subject/model/Subject.dart'; import 'package:ikaros/utils/message_utils.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 阅读主题 +class _ReadingTheme { + final Color bgColor; + final Color textColor; + final String name; + final Color? statusBarColor; + + const _ReadingTheme({ + required this.bgColor, + required this.textColor, + required this.name, + this.statusBarColor, + }); + + static const _ReadingTheme light = _ReadingTheme( + bgColor: Color(0xFFF5F0E8), + textColor: Color(0xFF333333), + name: "羊皮纸", + ); + + static const _ReadingTheme sepia = _ReadingTheme( + bgColor: Color(0xFFC8B896), + textColor: Color(0xFF3A2E1C), + name: "护眼黄", + ); + + static const _ReadingTheme dark = _ReadingTheme( + bgColor: Color(0xFF1A1A2E), + textColor: Color(0xFFE0E0E0), + name: "夜间黑", + ); + + static const List<_ReadingTheme> values = [light, sepia, dark]; +} /// 小说阅读器主页 class NovelReaderPage extends StatefulWidget { @@ -69,8 +105,8 @@ class _NovelReaderPageState extends State { final title = chapter.nameCn ?? chapter.name; final desc = chapter.description; return ListTile( - leading: Icon(Icons.menu_book, - color: Theme.of(context).colorScheme.primary), + leading: + Icon(Icons.menu_book, color: Theme.of(context).colorScheme.primary), title: Text(title), subtitle: desc != null && desc.isNotEmpty ? Text(desc.length > 50 ? "${desc.substring(0, 50)}…" : desc, @@ -90,6 +126,7 @@ class _NovelReaderPageState extends State { builder: (_) => NovelChapterPage( chapter: chapter, subjectName: _subject?.nameCn ?? _subject?.name ?? "", + subjectId: widget.subjectId, allChapters: _chapters, ), ), @@ -101,12 +138,14 @@ class _NovelReaderPageState extends State { class NovelChapterPage extends StatefulWidget { final Episode chapter; final String subjectName; + final String subjectId; final List allChapters; const NovelChapterPage({ super.key, required this.chapter, required this.subjectName, + required this.subjectId, required this.allChapters, }); @@ -119,6 +158,11 @@ class _NovelChapterPageState extends State { bool _isLoading = true; double _fontSize = 18.0; bool _showControls = true; + _ReadingTheme _theme = _ReadingTheme.light; + + // 阅读进度 + double _scrollPosition = 0.0; + final ScrollController _scrollController = ScrollController(); late int _currentIndex; @@ -126,7 +170,41 @@ class _NovelChapterPageState extends State { void initState() { super.initState(); _currentIndex = widget.allChapters.indexOf(widget.chapter); + _loadThemeAndFont(); _loadContent(); + _scrollController.addListener(_saveScrollPosition); + } + + @override + void dispose() { + _scrollController.removeListener(_saveScrollPosition); + _scrollController.dispose(); + super.dispose(); + } + + Future _loadThemeAndFont() async { + final prefs = await SharedPreferences.getInstance(); + final themeIndex = prefs.getInt("${widget.subjectId}_theme") ?? 0; + final fontSize = prefs.getDouble("${widget.subjectId}_fontsize") ?? 18.0; + if (mounted) { + setState(() { + _theme = _ReadingTheme.values[themeIndex.clamp(0, 2)]; + _fontSize = fontSize; + }); + } + } + + void _saveScrollPosition() { + if (_scrollController.hasClients) { + _scrollPosition = _scrollController.position.pixels; + } + } + + Future _saveSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt("${widget.subjectId}_theme", + _ReadingTheme.values.indexOf(_theme)); + await prefs.setDouble("${widget.subjectId}_fontsize", _fontSize); } Future _loadContent() async { @@ -149,7 +227,6 @@ class _NovelChapterPageState extends State { } } - /// 从章节描述或附件文件中获取文字内容 Future _fetchChapterContent(Episode chapter) async { // 优先使用 description 字段 if (chapter.description != null && chapter.description!.isNotEmpty) { @@ -191,6 +268,7 @@ class _NovelChapterPageState extends State { builder: (_) => NovelChapterPage( chapter: widget.allChapters[_currentIndex + 1], subjectName: widget.subjectName, + subjectId: widget.subjectId, allChapters: widget.allChapters, ), ), @@ -206,6 +284,7 @@ class _NovelChapterPageState extends State { builder: (_) => NovelChapterPage( chapter: widget.allChapters[_currentIndex - 1], subjectName: widget.subjectName, + subjectId: widget.subjectId, allChapters: widget.allChapters, ), ), @@ -216,62 +295,87 @@ class _NovelChapterPageState extends State { @override Widget build(BuildContext context) { final chapterTitle = widget.chapter.nameCn ?? widget.chapter.name; - return Scaffold( - appBar: _showControls - ? AppBar( - title: Text(chapterTitle), - actions: [ - IconButton( - icon: const Icon(Icons.text_increase), - tooltip: "字体大小", - onPressed: _showFontSizeDialog, + return Theme( + data: ThemeData( + scaffoldBackgroundColor: _theme.bgColor, + appBarTheme: AppBarTheme( + backgroundColor: _theme.bgColor, + foregroundColor: _theme.textColor, + elevation: _showControls ? 1 : 0, + ), + ), + child: Scaffold( + appBar: _showControls + ? AppBar( + title: Text(chapterTitle), + actions: [ + // 主题切换 + IconButton( + icon: const Icon(Icons.palette), + tooltip: "阅读主题", + onPressed: _showThemePicker, + ), + // 字体大小 + IconButton( + icon: const Icon(Icons.text_fields), + tooltip: "字体大小", + onPressed: _showFontSizeDialog, + ), + ], + ) + : null, + body: Column( + children: [ + if (!_showControls) + Container( + color: _theme.bgColor, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Text( + "$chapterTitle — ${widget.subjectName}", + style: TextStyle(color: _theme.textColor.withOpacity(0.5), fontSize: 12), ), - ], - ) - : null, - backgroundColor: const Color(0xFFF5F0E8), - body: Column( - children: [ - if (!_showControls) - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), - child: Text( - "$chapterTitle — ${widget.subjectName}", - style: const TextStyle(color: Colors.grey, fontSize: 12), ), - ), - Expanded( - child: _isLoading - ? const Center(child: CircularProgressIndicator()) - : GestureDetector( - onTap: () => - setState(() => _showControls = !_showControls), - onHorizontalDragEnd: (details) { - if (details.primaryVelocity != null) { - if (details.primaryVelocity! < -200) { - _nextChapter(); - } else if (details.primaryVelocity! > 200) { - _prevChapter(); + Expanded( + child: _isLoading + ? Center( + child: CircularProgressIndicator( + color: _theme.textColor, + ), + ) + : GestureDetector( + onTap: () => + setState(() => _showControls = !_showControls), + onHorizontalDragEnd: (details) { + if (details.primaryVelocity != null) { + if (details.primaryVelocity! < -200) { + _nextChapter(); + } else if (details.primaryVelocity! > 200) { + _prevChapter(); + } } - } - }, - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric( - horizontal: 20, vertical: 16), - child: SelectableText( - _content, - style: TextStyle( - fontSize: _fontSize, - height: 1.6, - color: const Color(0xFF333333), - letterSpacing: 0.5, + }, + child: Container( + color: _theme.bgColor, + child: SingleChildScrollView( + controller: _scrollController, + padding: const EdgeInsets.symmetric( + horizontal: 20, vertical: 16), + child: SelectableText( + _content, + style: TextStyle( + fontSize: _fontSize, + height: 1.6, + color: _theme.textColor, + letterSpacing: 0.5, + ), + ), ), ), ), - ), - ), - if (_showControls) _buildBottomBar(chapterTitle), - ], + ), + if (_showControls) _buildBottomBar(chapterTitle), + ], + ), ), ); } @@ -280,7 +384,7 @@ class _NovelChapterPageState extends State { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: Colors.white, + color: _theme.bgColor, boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.05), @@ -300,7 +404,7 @@ class _NovelChapterPageState extends State { ), Text( "${_currentIndex + 1} / ${widget.allChapters.length}", - style: const TextStyle(color: Colors.grey), + style: TextStyle(color: _theme.textColor.withOpacity(0.5)), ), TextButton.icon( onPressed: _currentIndex < widget.allChapters.length - 1 @@ -321,28 +425,81 @@ class _NovelChapterPageState extends State { context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) => AlertDialog( - title: const Text("字体大小"), + backgroundColor: _theme.bgColor, + title: Text("字体大小", style: TextStyle(color: _theme.textColor)), content: Column( mainAxisSize: MainAxisSize.min, children: [ - Text("当前: ${tempSize.round()}px"), + Text("当前: ${tempSize.round()}px", + style: TextStyle(color: _theme.textColor)), + const SizedBox(height: 8), Slider( value: tempSize, min: 12, max: 32, divisions: 20, label: "${tempSize.round()}", + activeColor: Colors.blue, onChanged: (v) => setDialogState(() => tempSize = v), ), ], ), actions: [ TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text("取消")), + onPressed: () => Navigator.pop(ctx), + child: const Text("取消"), + ), TextButton( onPressed: () { setState(() => _fontSize = tempSize); + _saveSettings(); + Navigator.pop(ctx); + }, + child: const Text("确定"), + ), + ], + ), + ), + ); + } + + Future _showThemePicker() async { + int selectedIndex = _ReadingTheme.values.indexOf(_theme); + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDialogState) => AlertDialog( + backgroundColor: _ReadingTheme.values[selectedIndex].bgColor, + title: const Text("阅读主题"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: List.generate(_ReadingTheme.values.length, (i) { + final t = _ReadingTheme.values[i]; + final isSelected = i == selectedIndex; + return RadioListTile( + value: i, + groupValue: selectedIndex, + title: Text(t.name, + style: TextStyle(color: t.textColor)), + tileColor: t.bgColor, + activeColor: t.textColor, + onChanged: (v) { + setDialogState(() => selectedIndex = v!); + }, + ); + }), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text("取消"), + ), + TextButton( + onPressed: () { + setState(() { + _theme = _ReadingTheme.values[selectedIndex]; + }); + _saveSettings(); Navigator.pop(ctx); }, child: const Text("确定"), From 7cd9944891eeb0e7fbe8c4e6f96e6dbee765a50f Mon Sep 17 00:00:00 2001 From: Nekoli Date: Wed, 15 Jul 2026 00:44:07 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test(novel):=20=E6=B7=BB=E5=8A=A0=E5=B0=8F?= =?UTF-8?q?=E8=AF=B4=E9=98=85=E8=AF=BB=E5=99=A8=E5=9F=BA=E7=A1=80=E5=8D=95?= =?UTF-8?q?=E5=85=83=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/reader/novel_reader_test.dart | 111 +++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 test/reader/novel_reader_test.dart diff --git a/test/reader/novel_reader_test.dart b/test/reader/novel_reader_test.dart new file mode 100644 index 0000000..ae95b99 --- /dev/null +++ b/test/reader/novel_reader_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:ikaros/reader/novel_reader.dart'; + +/// 小说阅读器单元测试 +void main() { + group('NovelReaderPage', () { + testWidgets('默认状态应显示标题和加载中指示器', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: NovelReaderPage(subjectId: "test-subject-id"), + ), + ); + + // 默认标题 + expect(find.text("小说阅读器"), findsOneWidget); + // 加载中 + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('空状态应显示暂无章节', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center(child: Text("暂无章节")), + ), + ), + ); + + expect(find.text("暂无章节"), findsOneWidget); + }); + }); + + group('NovelChapterPage', () { + testWidgets('空白内容应显示提示', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center(child: Text("(本章暂无内容)")), + ), + ), + ); + + expect(find.text("(本章暂无内容)"), findsOneWidget); + }); + + testWidgets('加载失败应显示错误文本', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Center(child: Text("加载失败: Connection refused")), + ), + ), + ); + + expect(find.textContaining("加载失败"), findsOneWidget); + }); + }); + + group('阅读主题', () { + test('三种主题应有不同配色', () { + final themes = [ + {"name": "羊皮纸", "bg": 0xFFF5F0E8}, + {"name": "护眼黄", "bg": 0xFFC8B896}, + {"name": "夜间黑", "bg": 0xFF1A1A2E}, + ]; + + expect(themes.length, 3); + expect(themes[0]["name"], "羊皮纸"); + expect(themes[2]["name"], "夜间黑"); + }); + + test('字体范围应在12~32px', () { + const minFont = 12.0; + const maxFont = 32.0; + const defaultFont = 18.0; + + expect(defaultFont, inInclusiveRange(minFont, maxFont)); + expect(defaultFont, 18.0); + }); + }); + + group('章节导航', () { + test('章节索引应在有效范围内', () { + final chapters = ["序章", "第一章", "第二章", "第三章"]; + int currentIndex = 1; + + expect(currentIndex, greaterThan(0)); + expect(currentIndex, lessThan(chapters.length - 1)); + + final hasPrev = currentIndex > 0; + final hasNext = currentIndex < chapters.length - 1; + expect(hasPrev, true); + expect(hasNext, true); + }); + + test('第一章不应有上一章', () { + final currentIndex = 0; + final hasPrev = currentIndex > 0; + expect(hasPrev, false); + }); + + test('最后一章不应有下一章', () { + final total = 5; + final lastIndex = total - 1; + final hasNext = lastIndex < total - 1; + expect(hasNext, false); + }); + }); +} From b2c9c69c76bae2547c4e477c48195446c16b68c7 Mon Sep 17 00:00:00 2001 From: Nekoli Date: Wed, 15 Jul 2026 00:52:51 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(novel):=20=E6=96=B0=E5=A2=9E=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E6=BB=9A=E5=8A=A8=E3=80=81=E6=BB=9A=E5=8A=A8=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE=E6=8C=81=E4=B9=85=E5=8C=96=E3=80=81=E8=A1=8C=E9=97=B4?= =?UTF-8?q?=E8=B7=9D=E5=92=8C=E5=AF=B9=E9=BD=90=E8=AE=BE=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/reader/novel_reader.dart | 388 +++++++++++++++++++++++++---------- 1 file changed, 279 insertions(+), 109 deletions(-) diff --git a/lib/reader/novel_reader.dart b/lib/reader/novel_reader.dart index d7f8837..7951cf1 100644 --- a/lib/reader/novel_reader.dart +++ b/lib/reader/novel_reader.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:ikaros/api/attachment/AttachmentApi.dart'; @@ -15,13 +17,11 @@ class _ReadingTheme { final Color bgColor; final Color textColor; final String name; - final Color? statusBarColor; const _ReadingTheme({ required this.bgColor, required this.textColor, required this.name, - this.statusBarColor, }); static const _ReadingTheme light = _ReadingTheme( @@ -45,6 +45,14 @@ class _ReadingTheme { static const List<_ReadingTheme> values = [light, sepia, dark]; } +/// 阅读设置键 +class _PrefKeys { + static String theme(String subjectId) => "${subjectId}_novel_theme"; + static String fontSize(String subjectId) => "${subjectId}_novel_fontsize"; + static String lineHeight(String subjectId) => "${subjectId}_novel_lineheight"; + static String textAlign(String subjectId) => "${subjectId}_novel_textalign"; +} + /// 小说阅读器主页 class NovelReaderPage extends StatefulWidget { final String subjectId; @@ -59,6 +67,7 @@ class _NovelReaderPageState extends State { Subject? _subject; List _chapters = []; bool _isLoading = true; + int? _lastChapterIndex; @override void initState() { @@ -72,14 +81,30 @@ class _NovelReaderPageState extends State { _subject = await SubjectApi().findById(widget.subjectId); _chapters = await EpisodeApi().findBySubjectId(widget.subjectId); _chapters.sort((a, b) => a.sequence.compareTo(b.sequence)); + + // 恢复上次阅读章节 + final prefs = await SharedPreferences.getInstance(); + _lastChapterIndex = + prefs.getInt("${widget.subjectId}_novel_last_chapter"); } catch (e) { - if (mounted) { - Toast.show(context, "加载小说数据失败: $e"); - } - } - if (mounted) { - setState(() => _isLoading = false); + if (mounted) Toast.show(context, "加载小说数据失败: $e"); } + if (mounted) setState(() => _isLoading = false); + } + + void _openChapter(Episode chapter, {int index = 0}) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => NovelChapterPage( + chapter: chapter, + chapterIndex: index, + subjectName: _subject?.nameCn ?? _subject?.name ?? "", + subjectId: widget.subjectId, + allChapters: _chapters, + ), + ), + ).then((_) => _loadData()); } @override @@ -104,39 +129,50 @@ class _NovelReaderPageState extends State { final chapter = _chapters[index]; final title = chapter.nameCn ?? chapter.name; final desc = chapter.description; + final isLastRead = _lastChapterIndex == index; return ListTile( - leading: - Icon(Icons.menu_book, color: Theme.of(context).colorScheme.primary), - title: Text(title), + selected: isLastRead, + selectedTileColor: Theme.of(context) + .colorScheme + .primaryContainer + .withOpacity(0.3), + leading: Icon( + isLastRead ? Icons.menu_book : Icons.book_outlined, + color: isLastRead + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.primary.withOpacity(0.6), + ), + title: Text(title, + style: TextStyle( + fontWeight: isLastRead ? FontWeight.bold : null)), subtitle: desc != null && desc.isNotEmpty ? Text(desc.length > 50 ? "${desc.substring(0, 50)}…" : desc, maxLines: 1, overflow: TextOverflow.ellipsis) - : Text("第 ${chapter.sequence.toInt()} 章"), + : Row( + children: [ + Text("第 ${chapter.sequence.toInt()} 章"), + if (isLastRead) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text("继续阅读", + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.primary)), + ), + ], + ), trailing: const Icon(Icons.chevron_right), - onTap: () => _openChapter(chapter), + onTap: () => _openChapter(chapter, index: index), ); }, ); } - - void _openChapter(Episode chapter) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => NovelChapterPage( - chapter: chapter, - subjectName: _subject?.nameCn ?? _subject?.name ?? "", - subjectId: widget.subjectId, - allChapters: _chapters, - ), - ), - ); - } } /// 小说章节阅读页 class NovelChapterPage extends StatefulWidget { final Episode chapter; + final int chapterIndex; final String subjectName; final String subjectId; final List allChapters; @@ -144,6 +180,7 @@ class NovelChapterPage extends StatefulWidget { const NovelChapterPage({ super.key, required this.chapter, + required this.chapterIndex, required this.subjectName, required this.subjectId, required this.allChapters, @@ -153,69 +190,126 @@ class NovelChapterPage extends StatefulWidget { State createState() => _NovelChapterPageState(); } -class _NovelChapterPageState extends State { +class _NovelChapterPageState extends State + with WidgetsBindingObserver { String _content = ""; bool _isLoading = true; + + // 阅读设置 double _fontSize = 18.0; + double _lineHeight = 1.6; bool _showControls = true; _ReadingTheme _theme = _ReadingTheme.light; + TextAlign _textAlign = TextAlign.left; + + // 自动滚动 + bool _autoScroll = false; + Timer? _autoScrollTimer; + double _autoScrollSpeed = 1.0; // 速度倍率 - // 阅读进度 - double _scrollPosition = 0.0; + // 滚动位置 final ScrollController _scrollController = ScrollController(); + double _lastScrollFraction = 0.0; + bool _scrollRestored = false; late int _currentIndex; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _currentIndex = widget.allChapters.indexOf(widget.chapter); - _loadThemeAndFont(); + _loadSettings(); _loadContent(); - _scrollController.addListener(_saveScrollPosition); + _scrollController.addListener(_onScroll); } @override void dispose() { - _scrollController.removeListener(_saveScrollPosition); + WidgetsBinding.instance.removeObserver(this); + _scrollController.removeListener(_onScroll); _scrollController.dispose(); + _autoScrollTimer?.cancel(); super.dispose(); } - Future _loadThemeAndFont() async { + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.paused) { + _saveProgress(); + } + } + + Future _loadSettings() async { final prefs = await SharedPreferences.getInstance(); - final themeIndex = prefs.getInt("${widget.subjectId}_theme") ?? 0; - final fontSize = prefs.getDouble("${widget.subjectId}_fontsize") ?? 18.0; + final sid = widget.subjectId; if (mounted) { setState(() { - _theme = _ReadingTheme.values[themeIndex.clamp(0, 2)]; - _fontSize = fontSize; + _theme = _ReadingTheme.values[ + (prefs.getInt(_PrefKeys.theme(sid)) ?? 0).clamp(0, 2)]; + _fontSize = prefs.getDouble(_PrefKeys.fontSize(sid)) ?? 18.0; + _lineHeight = prefs.getDouble(_PrefKeys.lineHeight(sid)) ?? 1.6; + _textAlign = TextAlign + .values[(prefs.getInt(_PrefKeys.textAlign(sid)) ?? 0)]; }); } } - void _saveScrollPosition() { + Future _saveSettings() async { + final prefs = await SharedPreferences.getInstance(); + final sid = widget.subjectId; + await prefs.setInt( + _PrefKeys.theme(sid), _ReadingTheme.values.indexOf(_theme)); + await prefs.setDouble(_PrefKeys.fontSize(sid), _fontSize); + await prefs.setDouble(_PrefKeys.lineHeight(sid), _lineHeight); + await prefs.setInt(_PrefKeys.textAlign(sid), _textAlign.index); + } + + Future _saveProgress() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt("${widget.subjectId}_novel_last_chapter", _currentIndex); if (_scrollController.hasClients) { - _scrollPosition = _scrollController.position.pixels; + final fraction = _scrollController.position.pixels / + _scrollController.position.maxScrollExtent; + await prefs.setDouble( + "${widget.subjectId}_${widget.chapter.id}_scroll", fraction); } } - Future _saveSettings() async { + void _onScroll() { + if (!_scrollRestored && _scrollController.hasClients) { + _restoreScrollPosition(); + } + } + + Future _restoreScrollPosition() async { + if (_scrollRestored || !_scrollController.hasClients) return; final prefs = await SharedPreferences.getInstance(); - await prefs.setInt("${widget.subjectId}_theme", - _ReadingTheme.values.indexOf(_theme)); - await prefs.setDouble("${widget.subjectId}_fontsize", _fontSize); + final fraction = prefs.getDouble( + "${widget.subjectId}_${widget.chapter.id}_scroll"); + if (fraction != null && + fraction > 0 && + _scrollController.position.maxScrollExtent > 0) { + final target = fraction * _scrollController.position.maxScrollExtent; + _scrollController.jumpTo(target.clamp( + 0.0, _scrollController.position.maxScrollExtent)); + } + _scrollRestored = true; } Future _loadContent() async { setState(() => _isLoading = true); try { - String? text = await _fetchChapterContent(widget.chapter); + final text = await _fetchChapterContent(widget.chapter); if (mounted) { setState(() { _content = text ?? "(本章暂无内容)"; _isLoading = false; }); + // 内容加载完成后恢复滚动位置 + WidgetsBinding.instance.addPostFrameCallback((_) { + _restoreScrollPosition(); + }); } } catch (e) { if (mounted) { @@ -228,13 +322,11 @@ class _NovelChapterPageState extends State { } Future _fetchChapterContent(Episode chapter) async { - // 优先使用 description 字段 if (chapter.description != null && chapter.description!.isNotEmpty) { return chapter.description; } - // 尝试从附件读取文本 try { - List resources = + final resources = await EpisodeApi().getEpisodeResourcesRefs(chapter.id); if (resources.isNotEmpty) { String url = resources.first.url; @@ -254,19 +346,39 @@ class _NovelChapterPageState extends State { } } } - } catch (_) { - // ignore fetch error - } + } catch (_) {} return null; } + void _toggleAutoScroll() { + if (_autoScroll) { + _autoScrollTimer?.cancel(); + } else { + _autoScrollTimer = Timer.periodic(const Duration(milliseconds: 50), (_) { + if (!_scrollController.hasClients) return; + final delta = 0.5 * _autoScrollSpeed; + final next = _scrollController.position.pixels + delta; + if (next >= _scrollController.position.maxScrollExtent) { + // 到底后自动切下一章 + _autoScrollTimer?.cancel(); + _nextChapter(); + return; + } + _scrollController.jumpTo(next); + }); + } + setState(() => _autoScroll = !_autoScroll); + } + void _nextChapter() { if (_currentIndex < widget.allChapters.length - 1) { + _saveProgress(); Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => NovelChapterPage( chapter: widget.allChapters[_currentIndex + 1], + chapterIndex: _currentIndex + 1, subjectName: widget.subjectName, subjectId: widget.subjectId, allChapters: widget.allChapters, @@ -278,11 +390,13 @@ class _NovelChapterPageState extends State { void _prevChapter() { if (_currentIndex > 0) { + _saveProgress(); Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => NovelChapterPage( chapter: widget.allChapters[_currentIndex - 1], + chapterIndex: _currentIndex - 1, subjectName: widget.subjectName, subjectId: widget.subjectId, allChapters: widget.allChapters, @@ -295,31 +409,31 @@ class _NovelChapterPageState extends State { @override Widget build(BuildContext context) { final chapterTitle = widget.chapter.nameCn ?? widget.chapter.name; - return Theme( - data: ThemeData( - scaffoldBackgroundColor: _theme.bgColor, - appBarTheme: AppBarTheme( - backgroundColor: _theme.bgColor, - foregroundColor: _theme.textColor, - elevation: _showControls ? 1 : 0, - ), + final themeData = ThemeData( + scaffoldBackgroundColor: _theme.bgColor, + appBarTheme: AppBarTheme( + backgroundColor: _theme.bgColor, + foregroundColor: _theme.textColor, + elevation: _showControls ? 1 : 0, ), + ); + + return Theme( + data: themeData, child: Scaffold( appBar: _showControls ? AppBar( title: Text(chapterTitle), actions: [ - // 主题切换 IconButton( icon: const Icon(Icons.palette), tooltip: "阅读主题", onPressed: _showThemePicker, ), - // 字体大小 IconButton( icon: const Icon(Icons.text_fields), - tooltip: "字体大小", - onPressed: _showFontSizeDialog, + tooltip: "阅读设置", + onPressed: _showReadingSettings, ), ], ) @@ -332,7 +446,8 @@ class _NovelChapterPageState extends State { padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), child: Text( "$chapterTitle — ${widget.subjectName}", - style: TextStyle(color: _theme.textColor.withOpacity(0.5), fontSize: 12), + style: TextStyle( + color: _theme.textColor.withOpacity(0.5), fontSize: 12), ), ), Expanded( @@ -343,8 +458,18 @@ class _NovelChapterPageState extends State { ), ) : GestureDetector( - onTap: () => - setState(() => _showControls = !_showControls), + onTap: () => setState(() => _showControls = !_showControls), + onVerticalDragEnd: (details) { + // 纵向滑动自动滚动变速 + if (details.primaryVelocity != null) { + setState(() { + _autoScrollSpeed = (details.primaryVelocity! / 500) + .abs() + .clamp(0.5, 5.0); + }); + if (!_autoScroll) _toggleAutoScroll(); + } + }, onHorizontalDragEnd: (details) { if (details.primaryVelocity != null) { if (details.primaryVelocity! < -200) { @@ -364,10 +489,11 @@ class _NovelChapterPageState extends State { _content, style: TextStyle( fontSize: _fontSize, - height: 1.6, + height: _lineHeight, color: _theme.textColor, letterSpacing: 0.5, ), + textAlign: _textAlign, ), ), ), @@ -402,6 +528,14 @@ class _NovelChapterPageState extends State { icon: const Icon(Icons.chevron_left), label: const Text("上一章"), ), + // 自动滚动按钮 + IconButton( + icon: Icon( + _autoScroll ? Icons.speed : Icons.speed_outlined, + color: _autoScroll ? Colors.blue : null), + tooltip: _autoScroll ? "停止自动滚动" : "自动滚动", + onPressed: _toggleAutoScroll, + ), Text( "${_currentIndex + 1} / ${widget.allChapters.length}", style: TextStyle(color: _theme.textColor.withOpacity(0.5)), @@ -419,30 +553,29 @@ class _NovelChapterPageState extends State { ); } - Future _showFontSizeDialog() async { - double tempSize = _fontSize; + void _showThemePicker() async { + int selectedIndex = _ReadingTheme.values.indexOf(_theme); await showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) => AlertDialog( - backgroundColor: _theme.bgColor, - title: Text("字体大小", style: TextStyle(color: _theme.textColor)), + backgroundColor: _ReadingTheme.values[selectedIndex].bgColor, + title: const Text("阅读主题"), content: Column( mainAxisSize: MainAxisSize.min, - children: [ - Text("当前: ${tempSize.round()}px", - style: TextStyle(color: _theme.textColor)), - const SizedBox(height: 8), - Slider( - value: tempSize, - min: 12, - max: 32, - divisions: 20, - label: "${tempSize.round()}", - activeColor: Colors.blue, - onChanged: (v) => setDialogState(() => tempSize = v), - ), - ], + children: List.generate(_ReadingTheme.values.length, (i) { + final t = _ReadingTheme.values[i]; + return RadioListTile( + value: i, + groupValue: selectedIndex, + title: Text(t.name, style: TextStyle(color: t.textColor)), + tileColor: t.bgColor, + activeColor: t.textColor, + onChanged: (v) { + setDialogState(() => selectedIndex = v!); + }, + ); + }), ), actions: [ TextButton( @@ -451,7 +584,7 @@ class _NovelChapterPageState extends State { ), TextButton( onPressed: () { - setState(() => _fontSize = tempSize); + setState(() => _theme = _ReadingTheme.values[selectedIndex]); _saveSettings(); Navigator.pop(ctx); }, @@ -463,31 +596,66 @@ class _NovelChapterPageState extends State { ); } - Future _showThemePicker() async { - int selectedIndex = _ReadingTheme.values.indexOf(_theme); - await showDialog( + void _showReadingSettings() { + double tempFont = _fontSize; + double tempLine = _lineHeight; + int tempAlign = _textAlign.index; + + showDialog( context: context, builder: (ctx) => StatefulBuilder( builder: (ctx, setDialogState) => AlertDialog( - backgroundColor: _ReadingTheme.values[selectedIndex].bgColor, - title: const Text("阅读主题"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: List.generate(_ReadingTheme.values.length, (i) { - final t = _ReadingTheme.values[i]; - final isSelected = i == selectedIndex; - return RadioListTile( - value: i, - groupValue: selectedIndex, - title: Text(t.name, - style: TextStyle(color: t.textColor)), - tileColor: t.bgColor, - activeColor: t.textColor, - onChanged: (v) { - setDialogState(() => selectedIndex = v!); - }, - ); - }), + backgroundColor: _theme.bgColor, + title: Text("阅读设置", + style: TextStyle(color: _theme.textColor)), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 字体大小 + Text("字体大小: ${tempFont.round()}px", + style: TextStyle(color: _theme.textColor)), + Slider( + value: tempFont, + min: 12, + max: 32, + divisions: 20, + label: "${tempFont.round()}", + activeColor: Colors.blue, + onChanged: (v) => + setDialogState(() => tempFont = v), + ), + const Divider(), + // 行间距 + Text("行间距: ${tempLine.toStringAsFixed(1)}", + style: TextStyle(color: _theme.textColor)), + Slider( + value: tempLine, + min: 1.0, + max: 3.0, + divisions: 20, + label: tempLine.toStringAsFixed(1), + activeColor: Colors.blue, + onChanged: (v) => + setDialogState(() => tempLine = v), + ), + const Divider(), + // 对齐方式 + Text("对齐方式", + style: TextStyle(color: _theme.textColor)), + SegmentedButton( + segments: const [ + ButtonSegment(value: 0, label: Text("左对齐")), + ButtonSegment(value: 1, label: Text("居中")), + ButtonSegment(value: 2, label: Text("两端对齐")), + ], + selected: {tempAlign}, + onSelectionChanged: (v) => + setDialogState(() => tempAlign = v.first), + ), + ], + ), ), actions: [ TextButton( @@ -497,7 +665,9 @@ class _NovelChapterPageState extends State { TextButton( onPressed: () { setState(() { - _theme = _ReadingTheme.values[selectedIndex]; + _fontSize = tempFont; + _lineHeight = tempLine; + _textAlign = TextAlign.values[tempAlign]; }); _saveSettings(); Navigator.pop(ctx);