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: | 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..abe55c1 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,119 @@ 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, + style: const TextStyle( + height: 1.0, + leadingDistribution: + TextLeadingDistribution.even)), + ], + ), + ), + ), + 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, + style: const TextStyle( + height: 1.0, + leadingDistribution: + TextLeadingDistribution.even), + 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, + style: const TextStyle( + height: 1.0, + leadingDistribution: + TextLeadingDistribution.even)), + 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 +304,13 @@ 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), + image: CachedNovelImageProvider(novel.id.toString(), imageId), filterQuality: FilterQuality.medium, fit: BoxFit.contain, height: 300, @@ -155,6 +336,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;