From 3b20ada101577ff56ac6d66db54b2d5d157bd16a Mon Sep 17 00:00:00 2001 From: Terry Cheng Date: Fri, 5 Jun 2026 14:50:52 +0930 Subject: [PATCH 1/4] =?UTF-8?q?feat(novel):=20=E9=98=85=E8=AF=BB=E5=99=A8?= =?UTF-8?q?=E5=86=85=E4=B8=8A=E4=B8=80=E7=AB=A0/=E4=B8=8B=E4=B8=80?= =?UTF-8?q?=E7=AB=A0/=E7=AB=A0=E8=8A=82=E5=88=97=E8=A1=A8=E8=B7=B3?= =?UTF-8?q?=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在系列小说阅读页底部加入章节导航条(上一章 / 当前·总数 / 下一章), 中间按钮可打开侧边章节列表直接跳到指定篇目,无需返回详情页手选。 切章后原地重载并回到顶部,仅对系列(series)小说显示。 Co-Authored-By: Claude Opus 4.8 --- assets/tr.json | 12 ++ lib/pages/novel_reading_page.dart | 256 +++++++++++++++++++++++++++++- 2 files changed, 264 insertions(+), 4 deletions(-) diff --git a/assets/tr.json b/assets/tr.json index 61efd84..ffa903e 100644 --- a/assets/tr.json +++ b/assets/tr.json @@ -133,6 +133,10 @@ "Novel": "小说", "Novels": "小说", "Reading Settings": "阅读设置", + "Previous": "上一章", + "Next": "下一章", + "Chapters": "章节", + "Load chapters": "加载章节", "Font Size": "字体大小", "Line Height": "行高", "Paragraph Spacing": "段间距", @@ -321,6 +325,10 @@ "Novel": "小說", "Novels": "小說", "Reading Settings": "閱讀設置", + "Previous": "上一章", + "Next": "下一章", + "Chapters": "章節", + "Load chapters": "載入章節", "Font Size": "字體大小", "Line Height": "行高", "Paragraph Spacing": "段間距", @@ -509,6 +517,10 @@ "Novel": "소설", "Novels": "소설", "Reading Settings": "읽기 설정", + "Previous": "이전", + "Next": "다음", + "Chapters": "챕터", + "Load chapters": "챕터 불러오기", "Font Size": "글꼴 크기", "Line Height": "줄 간격", "Paragraph Spacing": "문단 간격", diff --git a/lib/pages/novel_reading_page.dart b/lib/pages/novel_reading_page.dart index 7b09d03..b1d0333 100644 --- a/lib/pages/novel_reading_page.dart +++ b/lib/pages/novel_reading_page.dart @@ -31,8 +31,19 @@ class _NovelReadingPageState extends LoadingState { String? translatedContent; + /// The novel currently shown. Changes when navigating between the + /// chapters (episodes) of a series. + late Novel novel; + + /// All episodes of the series this novel belongs to, in reading order. + /// Null until loaded, or if the novel does not belong to a series. + List? seriesNovels; + + bool isLoadingSeries = false; + @override void initState() { + novel = widget.novel; action = TitleBarAction(MdIcons.tune, "Settings".tl, () { if (!isShowingSettings) { _NovelReadingSettings.show( @@ -68,6 +79,9 @@ class _NovelReadingPageState extends LoadingState { StateController.find().addAction(action!); }); super.initState(); + if (novel.seriesId != null) { + loadSeries(); + } } @override @@ -78,15 +92,80 @@ class _NovelReadingPageState extends LoadingState { super.dispose(); } + /// Loads the full ordered list of episodes for the current series. + void loadSeries() async { + final seriesId = novel.seriesId; + if (seriesId == null || isLoadingSeries) return; + setState(() { + isLoadingSeries = true; + }); + final all = []; + String? nextUrl; + // Series are paginated; load every page. The cap guards against an + // unexpected pagination loop. + for (var i = 0; i < 50; i++) { + var res = await Network().getNovelSeries(seriesId.toString(), nextUrl); + if (res.error) break; + all.addAll(res.data); + nextUrl = res.subData; + if (nextUrl == null || nextUrl.isEmpty) break; + } + if (!mounted) return; + setState(() { + isLoadingSeries = false; + if (all.isNotEmpty) { + seriesNovels = all; + } + }); + } + + /// Switches the reader to [target] and reloads its content. + void goToNovel(Novel target) { + if (target.id == novel.id || isLoading) return; + setState(() { + novel = target; + translatedContent = null; + isLoading = true; + error = null; + data = null; + }); + loadData().then((value) { + if (!mounted) return; + setState(() { + isLoading = false; + if (value.success) { + data = value.data; + } else { + error = value.errorMessage!; + } + }); + }); + } + + void showChapterList() { + final list = seriesNovels; + if (list == null) return; + Navigator.of(context).push( + SideBarRoute(_NovelChapterList( + novels: list, + currentId: novel.id, + onSelected: goToNovel, + )), + ); + } + @override Widget buildContent(BuildContext context, String data) { var content = buildList(context).toList(); + content.add(buildChapterNav(context)); return ScaffoldPage( padding: EdgeInsets.zero, content: SelectionArea( child: DefaultTextStyle.merge( style: const TextStyle(fontSize: 16.0, height: 1.6), child: ListView.builder( + // A fresh key per chapter resets the scroll back to the top. + key: ValueKey(novel.id), padding: const EdgeInsets.all(16.0), itemBuilder: (context, index) { return content[index]; @@ -97,16 +176,107 @@ class _NovelReadingPageState extends LoadingState { ); } + /// The previous / chapter-list / next bar shown at the end of a chapter. + Widget buildChapterNav(BuildContext context) { + if (novel.seriesId == null) { + return const SizedBox.shrink(); + } + if (seriesNovels == null) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Center( + child: isLoadingSeries + ? const SizedBox.square( + dimension: 24, + child: ProgressRing(strokeWidth: 2), + ) + : Button( + onPressed: loadSeries, + child: Text("Load chapters".tl), + ), + ), + ); + } + final list = seriesNovels!; + final index = list.indexWhere((n) => n.id == novel.id); + final hasPrev = index > 0; + final hasNext = index >= 0 && index < list.length - 1; + return Padding( + padding: const EdgeInsets.only(top: 24, bottom: 8), + child: Column( + children: [ + const Divider( + style: DividerThemeData(horizontalMargin: EdgeInsets.all(0)), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: Button( + onPressed: hasPrev ? () => goToNovel(list[index - 1]) : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(MdIcons.chevron_left, size: 18), + const SizedBox(width: 4), + Text("Previous".tl), + ], + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: showChapterList, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(MdIcons.format_list_bulleted, size: 18), + const SizedBox(width: 4), + Flexible( + child: Text( + index >= 0 + ? "${index + 1} / ${list.length}" + : "Chapters".tl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Button( + onPressed: hasNext ? () => goToNovel(list[index + 1]) : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("Next".tl), + const SizedBox(width: 4), + const Icon(MdIcons.chevron_right, size: 18), + ], + ), + ), + ), + ], + ), + ], + ), + ); + } + @override Future> loadData() { - return Network().getNovelContent(widget.novel.id.toString()); + return Network().getNovelContent(novel.id.toString()); } Iterable buildList(BuildContext context) sync* { double fontSizeAdd = appdata.settings["readingFontSize"] - 16.0; double fontHeight = appdata.settings["readingLineHeight"]; - yield Text(widget.novel.title, + yield Text(novel.title, style: TextStyle( fontSize: 24.0 + fontSizeAdd, fontWeight: FontWeight.bold)); yield const SizedBox(height: 12.0); @@ -122,14 +292,14 @@ class _NovelReadingPageState extends LoadingState { var imageId = content.nums; yield GestureDetector( onTap: () { - ImagePage.show(["novel:${widget.novel.id.toString()}/$imageId"]); + ImagePage.show(["novel:${novel.id.toString()}/$imageId"]); }, child: SizedBox( height: 300, width: double.infinity, child: AnimatedImage( image: - CachedNovelImageProvider(widget.novel.id.toString(), imageId), + CachedNovelImageProvider(novel.id.toString(), imageId), filterQuality: FilterQuality.medium, fit: BoxFit.contain, height: 300, @@ -155,6 +325,84 @@ class _NovelReadingPageState extends LoadingState { } } +/// A side panel listing every chapter (episode) of a series, used to jump +/// directly to a specific chapter from the reader. +class _NovelChapterList extends StatelessWidget { + const _NovelChapterList({ + required this.novels, + required this.currentId, + required this.onSelected, + }); + + final List novels; + + final int currentId; + + final void Function(Novel novel) onSelected; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + TitleBar(title: "Chapters".tl), + Expanded( + child: ListView.builder( + itemCount: novels.length, + itemBuilder: (context, index) { + final n = novels[index]; + final isCurrent = n.id == currentId; + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + Navigator.of(context).pop(); + if (!isCurrent) { + onSelected(n); + } + }, + child: Container( + color: isCurrent + ? ColorScheme.of(context).primaryContainer.toOpacity(0.4) + : null, + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + SizedBox( + width: 32, + child: Text( + "${index + 1}", + style: TextStyle( + color: ColorScheme.of(context).primary, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + n.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + if (isCurrent) + Icon( + MdIcons.check, + size: 18, + color: ColorScheme.of(context).primary, + ), + ], + ), + ), + ); + }, + ), + ), + ], + ); + } +} + class TranslationController { final String content; From ec7208624340bf8dd7ac097fb8cc9bd8787688c2 Mon Sep 17 00:00:00 2001 From: Terry Cheng Date: Fri, 5 Jun 2026 14:54:47 +0930 Subject: [PATCH 2/4] ci: select latest available Xcode instead of pinned 26.4.0 The macos runner no longer ships Xcode_26.4.0.app, so the pinned xcode-select path failed before the build could start. Pick the highest installed Xcode 26 (falling back to the newest Xcode) dynamically. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/main.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 0d92198..d176fbd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -87,7 +87,15 @@ jobs: with: channel: "stable" flutter-version-file: pubspec.yaml - - run: sudo xcode-select --switch /Applications/Xcode_26.4.0.app + - name: Select latest available Xcode + run: | + XCODE=$(ls -d /Applications/Xcode_26*.app 2>/dev/null | sort -V | tail -1) + if [ -z "$XCODE" ]; then + XCODE=$(ls -d /Applications/Xcode_*.app 2>/dev/null | sort -V | tail -1) + fi + echo "Selecting Xcode at: $XCODE" + sudo xcode-select --switch "$XCODE" + xcodebuild -version - run: flutter pub get - run: flutter build ios --release --no-codesign - run: | From 7157ab4cd651fa60d5863e0f25914e2021129c3f Mon Sep 17 00:00:00 2001 From: Terry Cheng Date: Fri, 5 Jun 2026 15:11:17 +0930 Subject: [PATCH 3/4] fix(novel): vertically center chapter-nav button labels The reader body uses DefaultTextStyle height:1.6 for paragraph spacing, which leaked into the chapter-nav buttons and pushed the "n / total" label below the icon. Pin the button labels to height:1.0. Co-Authored-By: Claude Opus 4.8 --- lib/pages/novel_reading_page.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/pages/novel_reading_page.dart b/lib/pages/novel_reading_page.dart index b1d0333..8b426ab 100644 --- a/lib/pages/novel_reading_page.dart +++ b/lib/pages/novel_reading_page.dart @@ -219,7 +219,7 @@ class _NovelReadingPageState extends LoadingState { children: [ const Icon(MdIcons.chevron_left, size: 18), const SizedBox(width: 4), - Text("Previous".tl), + Text("Previous".tl, style: const TextStyle(height: 1.0)), ], ), ), @@ -238,6 +238,7 @@ class _NovelReadingPageState extends LoadingState { index >= 0 ? "${index + 1} / ${list.length}" : "Chapters".tl, + style: const TextStyle(height: 1.0), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -253,7 +254,7 @@ class _NovelReadingPageState extends LoadingState { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text("Next".tl), + Text("Next".tl, style: const TextStyle(height: 1.0)), const SizedBox(width: 4), const Icon(MdIcons.chevron_right, size: 18), ], From 8864133715a56f67e04604048a264a89ca8d94f0 Mon Sep 17 00:00:00 2001 From: Terry Cheng Date: Fri, 5 Jun 2026 15:24:24 +0930 Subject: [PATCH 4/4] fix(novel): even leading distribution so nav labels truly center height:1.0 alone still let the default proportional leading push the glyph down (CJK/digit fonts have ascent >> descent). Use TextLeadingDistribution.even so the label is vertically centered with the icon. Co-Authored-By: Claude Opus 4.8 --- lib/pages/novel_reading_page.dart | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/lib/pages/novel_reading_page.dart b/lib/pages/novel_reading_page.dart index 8b426ab..abe55c1 100644 --- a/lib/pages/novel_reading_page.dart +++ b/lib/pages/novel_reading_page.dart @@ -219,7 +219,11 @@ class _NovelReadingPageState extends LoadingState { children: [ const Icon(MdIcons.chevron_left, size: 18), const SizedBox(width: 4), - Text("Previous".tl, style: const TextStyle(height: 1.0)), + Text("Previous".tl, + style: const TextStyle( + height: 1.0, + leadingDistribution: + TextLeadingDistribution.even)), ], ), ), @@ -238,7 +242,10 @@ class _NovelReadingPageState extends LoadingState { index >= 0 ? "${index + 1} / ${list.length}" : "Chapters".tl, - style: const TextStyle(height: 1.0), + style: const TextStyle( + height: 1.0, + leadingDistribution: + TextLeadingDistribution.even), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -254,7 +261,11 @@ class _NovelReadingPageState extends LoadingState { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text("Next".tl, style: const TextStyle(height: 1.0)), + Text("Next".tl, + style: const TextStyle( + height: 1.0, + leadingDistribution: + TextLeadingDistribution.even)), const SizedBox(width: 4), const Icon(MdIcons.chevron_right, size: 18), ], @@ -299,8 +310,7 @@ class _NovelReadingPageState extends LoadingState { height: 300, width: double.infinity, child: AnimatedImage( - image: - CachedNovelImageProvider(novel.id.toString(), imageId), + image: CachedNovelImageProvider(novel.id.toString(), imageId), filterQuality: FilterQuality.medium, fit: BoxFit.contain, height: 300,