comic page

This commit is contained in:
nyne
2024-10-04 21:56:15 +08:00
parent 2772289a19
commit 07dbf6e6af
7 changed files with 915 additions and 178 deletions

View File

@@ -14,7 +14,10 @@ class ComicTile extends StatelessWidget {
final String? badge;
void onTap() {}
void onTap() {
App.mainNavigatorKey?.currentContext
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey));
}
void onLongPress() {}
@@ -721,8 +724,7 @@ class _ComicListState extends State<ComicList> {
if (widget.leadingSliver != null) widget.leadingSliver!,
buildSliverPageSelector(),
SliverGridComics(comics: data[page] ?? const []),
if(data[page]!.length > 6)
buildSliverPageSelector(),
if (data[page]!.length > 6) buildSliverPageSelector(),
if (widget.trailingSliver != null) widget.trailingSliver!,
],
);

View File

@@ -2,7 +2,6 @@ library components;
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'dart:math' as math;
import 'dart:ui';
@@ -23,8 +22,8 @@ import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/local.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/foundation/state_controller.dart';
import 'package:venera/pages/comic_page.dart';
import 'package:venera/utils/ext.dart';
import 'package:venera/utils/io.dart';
import 'package:venera/utils/translations.dart';
part 'image.dart';

View File

@@ -64,6 +64,8 @@ class _App {
'green' => Colors.green,
'orange' => Colors.orange,
'blue' => Colors.blue,
'yellow' => Colors.yellow,
'cyan' => Colors.cyan,
_ => Colors.blue,
};
}

View File

@@ -43,6 +43,8 @@ typedef GetImageLoadingConfigFunc = Map<String, dynamic> Function(
typedef GetThumbnailLoadingConfigFunc = Map<String, dynamic> Function(
String imageKey)?;
typedef ComicThumbnailLoader = Future<Res<List<String>>> Function(String comicId, String? next);
class ComicSource {
static final List<ComicSource> _sources = [];
@@ -140,6 +142,8 @@ class ComicSource {
/// Load comic info.
final LoadComicFunc? loadComicInfo;
final ComicThumbnailLoader? loadComicThumbnail;
/// Load comic pages.
final LoadComicPagesFunc? loadComicPages;
@@ -216,6 +220,7 @@ class ComicSource {
this.searchPageData,
this.settings,
this.loadComicInfo,
this.loadComicThumbnail,
this.loadComicPages,
this.getImageLoadingConfig,
this.getThumbnailLoadingConfig,
@@ -237,6 +242,7 @@ class ComicSource {
searchPageData = null,
settings = [],
loadComicInfo = null,
loadComicThumbnail = null,
loadComicPages = null,
getImageLoadingConfig = null,
getThumbnailLoadingConfig = null,
@@ -338,8 +344,8 @@ class SearchPageData {
/// If this is not null, the default value of search options will be first element.
final List<SearchOptions>? searchOptions;
final Widget Function(BuildContext, List<String> initialValues, void Function(List<String>))?
customOptionsBuilder;
final Widget Function(BuildContext, List<String> initialValues,
void Function(List<String>))? customOptionsBuilder;
final Widget Function(String keyword, List<String> options)?
overrideSearchResultBuilder;
@@ -399,7 +405,8 @@ class Comic {
final int? maxPage;
const Comic(this.title, this.cover, this.id, this.subtitle, this.tags, this.description, this.sourceKey, this.maxPage);
const Comic(this.title, this.cover, this.id, this.subtitle, this.tags,
this.description, this.sourceKey, this.maxPage);
Map<String, dynamic> toJson() {
return {
@@ -443,12 +450,7 @@ class ComicDetails with HistoryMixin {
final List<String>? thumbnails;
final Future<Res<List<String>>> Function(String id, int page)?
thumbnailLoader;
final int thumbnailMaxPage;
final List<Comic>? suggestions;
final List<Comic>? recommend;
final String sourceKey;
@@ -458,36 +460,17 @@ class ComicDetails with HistoryMixin {
final String? subId;
const ComicDetails(
this.title,
this.subTitle,
this.cover,
this.description,
this.tags,
this.chapters,
this.thumbnails,
this.thumbnailLoader,
this.thumbnailMaxPage,
this.suggestions,
this.sourceKey,
this.comicId,
{this.isFavorite,
this.subId});
final bool? isLiked;
Map<String, dynamic> toJson() {
return {
"title": title,
"subTitle": subTitle,
"cover": cover,
"description": description,
"tags": tags,
"chapters": chapters,
"sourceKey": sourceKey,
"comicId": comicId,
"isFavorite": isFavorite,
"subId": subId,
};
}
final int? likesCount;
final int? commentsCount;
final String? uploader;
final String? uploadTime;
final String? updateTime;
static Map<String, List<String>> _generateMap(Map<String, dynamic> map) {
var res = <String, List<String>>{};
@@ -503,15 +486,23 @@ class ComicDetails with HistoryMixin {
cover = json["cover"],
description = json["description"],
tags = _generateMap(json["tags"]),
chapters = Map<String, String>.from(json["chapters"]),
chapters = json["chapters"] == null
? null
: Map<String, String>.from(json["chapters"]),
sourceKey = json["sourceKey"],
comicId = json["comicId"],
thumbnails = null,
thumbnailLoader = null,
thumbnailMaxPage = 0,
suggestions = null,
thumbnails = ListOrNull.from(json["thumbnails"]),
recommend = (json["recommend"] as List?)
?.map((e) => Comic.fromJson(e, json["sourceKey"]))
.toList(),
isFavorite = json["isFavorite"],
subId = json["subId"];
subId = json["subId"],
likesCount = json["likesCount"],
isLiked = json["isLiked"],
commentsCount = json["commentsCount"],
uploader = json["uploader"],
uploadTime = json["uploadTime"],
updateTime = json["updateTime"];
@override
HistoryType get historyType => HistoryType(sourceKey.hashCode);

View File

@@ -63,7 +63,8 @@ class ComicSourceParser {
if (file.existsSync()) {
int i = 0;
while (file.existsSync()) {
file = File(FilePath.join(App.dataPath, "comic_source", "${fileName.split('.').first}($i).js"));
file = File(FilePath.join(App.dataPath, "comic_source",
"${fileName.split('.').first}($i).js"));
i++;
}
}
@@ -78,9 +79,12 @@ class ComicSourceParser {
Future<ComicSource> parse(String js, String filePath) async {
js = js.replaceAll("\r\n", "\n");
var line1 = js.split('\n')
var line1 = js
.split('\n')
.firstWhereOrNull((element) => element.removeAllBlank.isNotEmpty);
if(line1 == null || !line1.startsWith("class ") || !line1.contains("extends ComicSource")){
if (line1 == null ||
!line1.startsWith("class ") ||
!line1.contains("extends ComicSource")) {
throw ComicSourceParseException("Invalid Content");
}
var className = line1.split("class")[1].split("extends ComicSource").first;
@@ -91,18 +95,20 @@ class ComicSourceParser {
this['temp'] = new $className()
}).call()
""");
_name = JsEngine().runCode("this['temp'].name")
?? (throw ComicSourceParseException('name is required'));
var key = JsEngine().runCode("this['temp'].key")
?? (throw ComicSourceParseException('key is required'));
var version = JsEngine().runCode("this['temp'].version")
?? (throw ComicSourceParseException('version is required'));
_name = JsEngine().runCode("this['temp'].name") ??
(throw ComicSourceParseException('name is required'));
var key = JsEngine().runCode("this['temp'].key") ??
(throw ComicSourceParseException('key is required'));
var version = JsEngine().runCode("this['temp'].version") ??
(throw ComicSourceParseException('version is required'));
var minAppVersion = JsEngine().runCode("this['temp'].minAppVersion");
var url = JsEngine().runCode("this['temp'].url");
var matchBriefIdRegex = JsEngine().runCode("this['temp'].comic.matchBriefIdRegex");
var matchBriefIdRegex =
JsEngine().runCode("this['temp'].comic.matchBriefIdRegex");
if (minAppVersion != null) {
if (compareSemVer(minAppVersion, App.version.split('-').first)) {
throw ComicSourceParseException("minAppVersion $minAppVersion is required");
throw ComicSourceParseException(
"minAppVersion $minAppVersion is required");
}
}
for (var source in ComicSource.all()) {
@@ -120,10 +126,10 @@ class ComicSourceParser {
final account = _loadAccountConfig();
final explorePageData = _loadExploreData();
final categoryPageData = _loadCategoryData();
final categoryComicsData =
_loadCategoryComicsData();
final categoryComicsData = _loadCategoryComicsData();
final searchData = _loadSearchData();
final loadComicFunc = _parseLoadComicFunc();
final loadComicThumbnailFunc = _parseThumbnailLoader();
final loadComicPagesFunc = _parseLoadComicPagesFunc();
final getImageLoadingConfigFunc = _parseImageLoadingConfigFunc();
final getThumbnailLoadingConfigFunc = _parseThumbnailLoadingConfigFunc();
@@ -142,6 +148,7 @@ class ComicSourceParser {
searchData,
[],
loadComicFunc,
loadComicThumbnailFunc,
loadComicPagesFunc,
getImageLoadingConfigFunc,
getThumbnailLoadingConfigFunc,
@@ -150,7 +157,8 @@ class ComicSourceParser {
url ?? "",
version ?? "1.0.0",
commentsLoader,
sendCommentFunc);
sendCommentFunc,
);
await source.loadData();
@@ -202,12 +210,8 @@ class ComicSourceParser {
JsEngine().runCode("ComicSource.sources.$_key.account.logout()");
}
return AccountConfig(
login,
_getValue("account.login.website"),
_getValue("account.registerWebsite"),
logout
);
return AccountConfig(login, _getValue("account.login.website"),
_getValue("account.registerWebsite"), logout);
}
List<ExplorePageData> _loadExploreData() {
@@ -226,7 +230,8 @@ class ComicSourceParser {
try {
var res = await JsEngine()
.runCode("ComicSource.sources.$_key.explore[$i].load()");
return Res(List.from(res.keys.map((e) => ExplorePagePart(
return Res(List.from(res.keys
.map((e) => ExplorePagePart(
e,
(res[e] as List)
.map<Comic>((e) => Comic.fromJson(e, _key!))
@@ -241,8 +246,8 @@ class ComicSourceParser {
} else if (type == "multiPageComicList") {
loadPage = (int page) async {
try {
var res = await JsEngine()
.runCode("ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})");
var res = await JsEngine().runCode(
"ComicSource.sources.$_key.explore[$i].load(${jsonEncode(page)})");
return Res(
List.generate(res["comics"].length,
(index) => Comic.fromJson(res["comics"][index], _key!)),
@@ -317,12 +322,10 @@ class ComicSourceParser {
var value = split.join("-");
map[key] = value;
}
options.add(
CategoryComicsOptions(
options.add(CategoryComicsOptions(
map,
List.from(element["notShowWhen"] ?? []),
element["showWhen"] == null ? null : List.from(element["showWhen"])
));
element["showWhen"] == null ? null : List.from(element["showWhen"])));
}
RankingData? rankingData;
if (_checkExists("categoryComics.ranking")) {
@@ -412,27 +415,10 @@ class ComicSourceParser {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.comic.loadInfo(${jsonEncode(id)})
""");
var tags = <String, List<String>>{};
(res["tags"] as Map<String, dynamic>?)
?.forEach((key, value) => tags[key] = List.from(value ?? const []));
return Res(ComicDetails(
res["title"],
res["subTitle"],
res["cover"],
res["description"],
tags,
res["chapters"] == null ? null : Map.from(res["chapters"]),
ListOrNull.from(res["thumbnails"]),
// TODO: implement thumbnailLoader
null,
res["thumbnailMaxPage"] ?? 1,
(res["recommend"] as List?)
?.map((e) => Comic.fromJson(e, _key!))
.toList(),
_key!,
id,
isFavorite: res["isFavorite"],
subId: res["subId"],));
if (res is! Map<String, dynamic>) throw "Invalid data";
res['comicId'] = id;
res['sourceKey'] = _key;
return Res(ComicDetails.fromJson(res));
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
@@ -508,6 +494,7 @@ class ComicSourceParser {
return Res.error(e.toString());
}
}
return retryZone(func);
}
@@ -582,9 +569,10 @@ class ComicSourceParser {
${jsonEncode(id)}, ${jsonEncode(subId)}, ${jsonEncode(page)}, ${jsonEncode(replyTo)})
""");
return Res(
(res["comments"] as List).map((e) => Comment(
e["userName"], e["avatar"], e["content"], e["time"], e["replyCount"], e["id"].toString()
)).toList(),
(res["comments"] as List)
.map((e) => Comment(e["userName"], e["avatar"], e["content"],
e["time"], e["replyCount"], e["id"].toString()))
.toList(),
subData: res["maxPage"]);
} catch (e, s) {
Log.error("Network", "$e\n$s");
@@ -608,6 +596,7 @@ class ComicSourceParser {
return Res.error(e.toString());
}
}
var res = await func();
if (res.error && res.errorMessage!.contains("Login expired")) {
var reLoginRes = await ComicSource.find(_key!)!.reLogin();
@@ -648,4 +637,21 @@ class ComicSourceParser {
return res as Map<String, dynamic>;
};
}
ComicThumbnailLoader? _parseThumbnailLoader() {
if (!_checkExists("comic.loadThumbnail")) {
return null;
}
return (id, next) async {
try {
var res = await JsEngine().runCode("""
ComicSource.sources.$_key.comic.loadThumbnail(${jsonEncode(id)}, ${jsonEncode(next)})
""");
return Res(List<String>.from(res['thumbnails']), subData: res['next']);
} catch (e, s) {
Log.error("Network", "$e\n$s");
return Res.error(e.toString());
}
};
}
}

View File

@@ -34,4 +34,12 @@ extension Navigation on BuildContext {
void showMessage({required String message}) {
showToast(message: message, context: this);
}
Color useBackgroundColor(MaterialColor color) {
return color[brightness == Brightness.light ? 100 : 800]!;
}
Color useTextColor(MaterialColor color) {
return color[brightness == Brightness.light ? 800 : 100]!;
}
}

729
lib/pages/comic_page.dart Normal file
View File

@@ -0,0 +1,729 @@
import 'package:flutter/material.dart';
import 'package:venera/components/components.dart';
import 'package:venera/foundation/app.dart';
import 'package:venera/foundation/comic_source/comic_source.dart';
import 'package:venera/foundation/comic_type.dart';
import 'package:venera/foundation/consts.dart';
import 'package:venera/foundation/favorites.dart';
import 'package:venera/foundation/history.dart';
import 'package:venera/foundation/image_provider/cached_image.dart';
import 'package:venera/foundation/res.dart';
import 'package:venera/utils/translations.dart';
import 'dart:math' as math;
class ComicPage extends StatefulWidget {
const ComicPage({super.key, required this.id, required this.sourceKey});
final String id;
final String sourceKey;
@override
State<ComicPage> createState() => _ComicPageState();
}
class _ComicPageState extends LoadingState<ComicPage, ComicDetails>
with _ComicPageActions {
bool showAppbarTitle = false;
var scrollController = ScrollController();
@override
void initState() {
scrollController.addListener(onScroll);
super.initState();
}
@override
void update() {
setState(() {});
}
@override
ComicDetails get comic => data!;
void onScroll() {
if (scrollController.offset > 100) {
if (!showAppbarTitle) {
setState(() {
showAppbarTitle = true;
});
}
} else {
if (showAppbarTitle) {
setState(() {
showAppbarTitle = false;
});
}
}
}
@override
Widget buildContent(BuildContext context, ComicDetails data) {
return SmoothCustomScrollView(
controller: scrollController,
slivers: [
...buildTitle(),
buildActions(),
buildDescription(),
buildInfo(),
buildChapters(),
buildThumbnails(),
buildRecommend(),
],
);
}
@override
Future<Res<ComicDetails>> loadData() async {
var comicSource = ComicSource.find(widget.sourceKey);
isAddToLocalFav = LocalFavoritesManager().isExist(
widget.id,
ComicType(widget.sourceKey.hashCode),
);
history = await HistoryManager()
.find(widget.id, ComicType(widget.sourceKey.hashCode));
return comicSource!.loadComicInfo!(widget.id);
}
Iterable<Widget> buildTitle() sync* {
yield SliverAppbar(
title: AnimatedOpacity(
opacity: showAppbarTitle ? 1.0 : 0.0,
duration: const Duration(milliseconds: 200),
child: Text(comic.title),
),
actions: [
IconButton(
onPressed: showMoreActions, icon: const Icon(Icons.more_horiz))
],
);
yield Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 16),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
),
height: 144,
width: 144 * 0.72,
clipBehavior: Clip.antiAlias,
child: AnimatedImage(
image: CachedImageProvider(
comic.cover,
sourceKey: comic.sourceKey,
),
width: double.infinity,
height: double.infinity,
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(comic.title, style: ts.s18),
if (comic.subTitle != null) Text(comic.subTitle!, style: ts.s14),
Text(
(ComicSource.find(comic.sourceKey)?.name) ?? '',
style: ts.s12,
),
],
),
),
],
).toSliver();
}
Widget buildActions() {
bool isMobile = context.width < changePoint;
return SliverToBoxAdapter(
child: Column(
children: [
ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: [
if(history != null && (history!.ep > 1 || history!.page > 1))
_ActionButton(
icon: const Icon(Icons.menu_book),
text: 'Continue'.tl,
onPressed: continueRead,
iconColor: context.useTextColor(Colors.yellow),
),
if (!isMobile)
_ActionButton(
icon: const Icon(Icons.play_circle_outline),
text: 'Read'.tl,
onPressed: read,
iconColor: context.useTextColor(Colors.orange),
),
if (!isMobile)
_ActionButton(
icon: const Icon(Icons.download),
text: 'Download'.tl,
onPressed: download,
iconColor: context.useTextColor(Colors.cyan),
),
if (data!.isLiked != null)
_ActionButton(
icon: const Icon(Icons.favorite_border),
activeIcon: const Icon(Icons.favorite),
isActive: data!.isLiked,
text: (data!.likesCount ??
(comic.isLiked! ? 'Liked'.tl : 'Like'.tl))
.toString(),
isLoading: isLiking,
onPressed: likeOrUnlike,
iconColor: context.useTextColor(Colors.red),
),
_ActionButton(
icon: const Icon(Icons.bookmark_border),
activeIcon: const Icon(Icons.bookmark),
isActive: (data!.isFavorite ?? false) || isAddToLocalFav,
text: 'Favorite'.tl,
isLoading: isFavoriting,
onPressed: favoriteOrUnfavorite,
iconColor: context.useTextColor(Colors.purple),
),
if (comicSource.commentsLoader != null)
_ActionButton(
icon: const Icon(Icons.comment),
text: (comic.commentsCount ?? 'Comments'.tl).toString(),
isLoading: isFavoriting,
onPressed: favoriteOrUnfavorite,
iconColor: context.useTextColor(Colors.green),
),
_ActionButton(
icon: const Icon(Icons.share),
text: 'Share'.tl,
onPressed: share,
iconColor: context.useTextColor(Colors.blue),
),
],
).fixHeight(48),
if (isMobile)
Row(
children: [
Expanded(
child: FilledButton.tonal(
onPressed: () {},
child: Text("Download".tl),
),
),
const SizedBox(width: 16),
Expanded(
child: FilledButton(onPressed: read, child: Text("Read".tl)),
)
],
).paddingHorizontal(16).paddingVertical(8),
const Divider(),
],
).paddingTop(16),
);
}
Widget buildDescription() {
if (comic.description == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverToBoxAdapter(
child: Column(
children: [
ListTile(
title: Text("Description".tl),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SelectableText(comic.description!),
),
const SizedBox(height: 16),
const Divider(),
],
),
);
}
Widget buildInfo() {
if (comic.tags.isEmpty &&
comic.uploader == null &&
comic.uploadTime == null &&
comic.uploadTime == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
int i = 0;
Widget buildTag({
required String text,
VoidCallback? onTap,
bool isTitle = false,
}) {
Color color;
if (isTitle) {
const colors = [
Colors.blue,
Colors.cyan,
Colors.red,
Colors.pink,
Colors.purple,
Colors.indigo,
Colors.teal,
Colors.green,
Colors.lime,
Colors.yellow,
];
color = context.useBackgroundColor(colors[(i++) % (colors.length)]);
} else {
color = context.colorScheme.surfaceContainer;
}
final borderRadius = BorderRadius.circular(12);
const padding = EdgeInsets.symmetric(horizontal: 16, vertical: 6);
if (onTap != null) {
return Material(
color: color,
borderRadius: borderRadius,
child: InkWell(
borderRadius: borderRadius,
onTap: onTap,
child: Text(text).padding(padding),
),
);
} else {
return Container(
decoration: BoxDecoration(
color: color,
borderRadius: borderRadius,
),
child: Text(text).padding(padding),
);
}
}
Widget buildWrap({required List<Widget> children}) {
return Wrap(
runSpacing: 8,
spacing: 8,
children: children,
).paddingHorizontal(16).paddingBottom(8);
}
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text("Information".tl),
),
for (var e in comic.tags.entries)
buildWrap(
children: [
buildTag(text: e.key, isTitle: true),
for (var tag in e.value)
buildTag(text: tag, onTap: () => onTagTap(tag, e.key)),
],
),
if (comic.uploader != null)
buildWrap(
children: [
buildTag(text: 'Uploader'.tl, isTitle: true),
buildTag(text: comic.uploader!),
],
),
if (comic.uploadTime != null)
buildWrap(
children: [
buildTag(text: 'Upload Time'.tl, isTitle: true),
buildTag(text: comic.uploadTime!),
],
),
if (comic.uploadTime != null)
buildWrap(
children: [
buildTag(text: 'Update Time'.tl, isTitle: true),
buildTag(text: comicSource.name),
],
),
const SizedBox(height: 12),
const Divider(),
],
),
);
}
Widget buildChapters() {
if (comic.chapters == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return const _ComicChapters();
}
Widget buildThumbnails() {
if (comic.thumbnails == null && comicSource.loadComicThumbnail == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return const _ComicThumbnails();
}
Widget buildRecommend() {
if (comic.recommend == null) {
return const SliverPadding(padding: EdgeInsets.zero);
}
return SliverMainAxisGroup(slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Related".tl),
),
),
SliverGridComics(comics: comic.recommend!),
]);
}
}
// TODO: Implement the _ComicPageActions mixin
abstract mixin class _ComicPageActions {
void update();
ComicDetails get comic;
ComicSource get comicSource => ComicSource.find(comic.sourceKey)!;
History? history;
bool isLiking = false;
void likeOrUnlike() {}
bool isAddToLocalFav = false;
bool isFavoriting = false;
void favoriteOrUnfavorite() {}
void share() {}
/// read the comic
///
/// [ep] the episode number, start from 1
///
/// [page] the page number, start from 1
void read([int? ep, int? page]) {}
void continueRead() {}
void download() {}
void onTagTap(String tag, String namespace) {}
void showMoreActions() {}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.icon,
required this.text,
required this.onPressed,
this.activeIcon,
this.isActive,
this.isLoading,
this.iconColor,
});
final Widget icon;
final Widget? activeIcon;
final bool? isActive;
final String text;
final void Function() onPressed;
final bool? isLoading;
final Color? iconColor;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: context.colorScheme.outlineVariant,
width: 0.6,
),
),
child: InkWell(
onTap: onPressed,
borderRadius: BorderRadius.circular(18),
child: IconTheme.merge(
data: IconThemeData(size: 20, color: iconColor),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading ?? false)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 1.8),
)
else
(isActive ?? false) ? (activeIcon ?? icon) : icon,
const SizedBox(width: 8),
Text(text),
],
).paddingHorizontal(16),
),
),
);
}
}
class _ComicChapters extends StatefulWidget {
const _ComicChapters();
@override
State<_ComicChapters> createState() => _ComicChaptersState();
}
class _ComicChaptersState extends State<_ComicChapters> {
late _ComicPageState state;
bool reverse = false;
bool showAll = false;
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
super.didChangeDependencies();
}
@override
Widget build(BuildContext context) {
final eps = state.comic.chapters!;
int length = eps.length;
if (!showAll) {
length = math.min(length, 20);
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Chapters".tl),
trailing: Tooltip(
message: "Order".tl,
child: IconButton(
icon: Icon(reverse
? Icons.vertical_align_top
: Icons.vertical_align_bottom_outlined),
onPressed: () {
setState(() {
reverse = !reverse;
});
},
),
),
),
),
SliverGrid(
delegate:
SliverChildBuilderDelegate(childCount: length, (context, i) {
if (reverse) {
i = eps.length - i - 1;
}
var key = eps.keys.elementAt(i);
var value = eps[key]!;
bool visited =
(state.history?.readEpisode ?? const {}).contains(i + 1);
return Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 4),
child: InkWell(
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Material(
elevation: 5,
color: context.colorScheme.surface,
surfaceTintColor: context.colorScheme.surfaceTint,
borderRadius: const BorderRadius.all(Radius.circular(12)),
shadowColor: Colors.transparent,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Center(
child: Text(
value,
maxLines: 1,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color:
visited ? context.colorScheme.outline : null),
),
),
),
),
onTap: () => state.read(i + 1),
),
);
}),
gridDelegate: const SliverGridDelegateWithFixedHeight(
maxCrossAxisExtent: 200, itemHeight: 48),
),
if (eps.length > 20 && !showAll)
SliverToBoxAdapter(
child: Align(
alignment: Alignment.center,
child: FilledButton.tonal(
style: ButtonStyle(
shape: WidgetStateProperty.all(const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)))),
),
onPressed: () {
setState(() {
showAll = true;
});
},
child: Text("${"Show all".tl} (${eps.length})"),
).paddingTop(12),
),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}
class _ComicThumbnails extends StatefulWidget {
const _ComicThumbnails();
@override
State<_ComicThumbnails> createState() => _ComicThumbnailsState();
}
class _ComicThumbnailsState extends State<_ComicThumbnails> {
late _ComicPageState state;
late List<String> thumbnails;
bool isInitialLoading = false;
String? next;
@override
void didChangeDependencies() {
state = context.findAncestorStateOfType<_ComicPageState>()!;
thumbnails = List.from(state.comic.thumbnails ?? []);
super.didChangeDependencies();
}
bool isLoading = false;
void loadNext() async {
if(state.comicSource.loadComicThumbnail == null || isLoading) return;
if(!isInitialLoading && next == null) {
return;
}
setState(() {
isLoading = true;
});
var res = await state.comicSource.loadComicThumbnail!(state.comic.id, next);
if(res.success) {
thumbnails.addAll(res.data);
next = res.subData;
isInitialLoading = false;
}
setState(() {
isLoading = false;
});
}
@override
Widget build(BuildContext context) {
if(thumbnails.isEmpty) {
Future.microtask(loadNext);
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListTile(
title: Text("Preview".tl),
),
),
SliverGrid(
delegate: SliverChildBuilderDelegate(childCount: thumbnails.length,
(context, index) {
if (index == thumbnails.length - 1) {
loadNext();
}
return Padding(
padding: context.width < changePoint
? const EdgeInsets.all(4)
: const EdgeInsets.all(8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: InkWell(
onTap: () => state.read(null, index + 1),
borderRadius: const BorderRadius.all(Radius.circular(16)),
child: Container(
decoration: BoxDecoration(
borderRadius:
const BorderRadius.all(Radius.circular(16)),
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
),
width: double.infinity,
height: double.infinity,
child: ClipRRect(
borderRadius:
const BorderRadius.all(Radius.circular(16)),
child: AnimatedImage(
image: CachedImageProvider(
thumbnails[index],
sourceKey: state.widget.sourceKey,
),
fit: BoxFit.contain,
width: double.infinity,
height: double.infinity,
),
),
),
),
),
const SizedBox(
height: 4,
),
Text((index + 1).toString()),
],
),
);
}),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
childAspectRatio: 0.65,
),
),
if(isLoading)
const SliverToBoxAdapter(
child: ListLoadingIndicator(),
),
const SliverToBoxAdapter(
child: Divider(),
),
],
);
}
}