Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
12 changes: 12 additions & 0 deletions assets/tr.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@
"Novel": "小说",
"Novels": "小说",
"Reading Settings": "阅读设置",
"Previous": "上一章",
"Next": "下一章",
"Chapters": "章节",
"Load chapters": "加载章节",
"Font Size": "字体大小",
"Line Height": "行高",
"Paragraph Spacing": "段间距",
Expand Down Expand Up @@ -321,6 +325,10 @@
"Novel": "小說",
"Novels": "小說",
"Reading Settings": "閱讀設置",
"Previous": "上一章",
"Next": "下一章",
"Chapters": "章節",
"Load chapters": "載入章節",
"Font Size": "字體大小",
"Line Height": "行高",
"Paragraph Spacing": "段間距",
Expand Down Expand Up @@ -509,6 +517,10 @@
"Novel": "소설",
"Novels": "소설",
"Reading Settings": "읽기 설정",
"Previous": "이전",
"Next": "다음",
"Chapters": "챕터",
"Load chapters": "챕터 불러오기",
"Font Size": "글꼴 크기",
"Line Height": "줄 간격",
"Paragraph Spacing": "문단 간격",
Expand Down
269 changes: 264 additions & 5 deletions lib/pages/novel_reading_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,19 @@ class _NovelReadingPageState extends LoadingState<NovelReadingPage, String> {

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<Novel>? seriesNovels;

bool isLoadingSeries = false;

@override
void initState() {
novel = widget.novel;
action = TitleBarAction(MdIcons.tune, "Settings".tl, () {
if (!isShowingSettings) {
_NovelReadingSettings.show(
Expand Down Expand Up @@ -68,6 +79,9 @@ class _NovelReadingPageState extends LoadingState<NovelReadingPage, String> {
StateController.find<TitleBarController>().addAction(action!);
});
super.initState();
if (novel.seriesId != null) {
loadSeries();
}
}

@override
Expand All @@ -78,15 +92,80 @@ class _NovelReadingPageState extends LoadingState<NovelReadingPage, String> {
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 = <Novel>[];
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];
Expand All @@ -97,16 +176,119 @@ class _NovelReadingPageState extends LoadingState<NovelReadingPage, String> {
);
}

/// 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<Res<String>> loadData() {
return Network().getNovelContent(widget.novel.id.toString());
return Network().getNovelContent(novel.id.toString());
}

Iterable<Widget> 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);
Expand All @@ -122,14 +304,13 @@ class _NovelReadingPageState extends LoadingState<NovelReadingPage, String> {
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,
Expand All @@ -155,6 +336,84 @@ class _NovelReadingPageState extends LoadingState<NovelReadingPage, String> {
}
}

/// 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<Novel> 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;

Expand Down