import 'dart:collection'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:shimmer_animation/shimmer_animation.dart'; import 'package:sliver_tools/sliver_tools.dart'; import 'package:url_launcher/url_launcher_string.dart'; import 'package:venera/components/components.dart'; import 'package:venera/foundation/app.dart'; import 'package:venera/foundation/appdata.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/local.dart'; import 'package:venera/foundation/res.dart'; import 'package:venera/network/download.dart'; import 'package:venera/pages/category_comics_page.dart'; import 'package:venera/pages/favorites/favorites_page.dart'; import 'package:venera/pages/reader/reader.dart'; import 'package:venera/pages/search_result_page.dart'; import 'package:venera/utils/app_links.dart'; import 'package:venera/utils/ext.dart'; import 'package:venera/utils/io.dart'; import 'package:venera/utils/tags_translation.dart'; import 'package:venera/utils/translations.dart'; import 'dart:math' as math; part 'comments_page.dart'; part 'chapters.dart'; part 'thumbnails.dart'; part 'favorite.dart'; part 'comments_preview.dart'; part 'actions.dart'; class ComicPage extends StatefulWidget { const ComicPage({ super.key, required this.id, required this.sourceKey, this.cover, this.title, this.heroID, }); final String id; final String sourceKey; final String? cover; final String? title; final int? heroID; @override State createState() => _ComicPageState(); } class _ComicPageState extends LoadingState with _ComicPageActions { @override History? history; bool showAppbarTitle = false; var scrollController = ScrollController(); bool isDownloaded = false; @override void onReadEnd() { history ??= HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode)); update(); } @override Widget buildLoading() { return _ComicPageLoadingPlaceHolder( cover: widget.cover, title: widget.title, sourceKey: widget.sourceKey, cid: widget.id, heroID: widget.heroID, ); } @override void initState() { scrollController.addListener(onScroll); super.initState(); } @override void dispose() { scrollController.removeListener(onScroll); super.dispose(); } @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; }); } } } var isFirst = true; @override Widget buildContent(BuildContext context, ComicDetails data) { return SmoothCustomScrollView( controller: scrollController, slivers: [ ...buildTitle(), buildActions(), buildDescription(), buildInfo(), buildChapters(), buildComments(), buildThumbnails(), buildRecommend(), SliverPadding(padding: EdgeInsets.only(bottom: context.padding.bottom)), ], ); } @override Future> loadData() async { if (widget.sourceKey == 'local') { var localComic = LocalManager().find(widget.id, ComicType.local); if (localComic == null) { return const Res.error('Local comic not found'); } var history = HistoryManager().find(widget.id, ComicType.local); if (isFirst) { Future.microtask(() { App.rootContext.to(() { return Reader( type: ComicType.local, cid: widget.id, name: localComic.title, chapters: localComic.chapters, initialPage: history?.page, initialChapter: history?.ep, initialChapterGroup: history?.group, history: history ?? History.fromModel( model: localComic, ep: 0, page: 0, ), author: localComic.subTitle ?? '', tags: localComic.tags, ); }); App.mainNavigatorKey!.currentContext!.pop(); }); isFirst = false; } await Future.delayed(const Duration(milliseconds: 200)); return const Res.error('Local comic'); } var comicSource = ComicSource.find(widget.sourceKey); if (comicSource == null) { return const Res.error('Comic source not found'); } isAddToLocalFav = LocalFavoritesManager().isExist( widget.id, ComicType(widget.sourceKey.hashCode), ); history = HistoryManager().find(widget.id, ComicType(widget.sourceKey.hashCode)); return comicSource.loadComicInfo!(widget.id); } @override Future onDataLoaded() async { isLiked = comic.isLiked ?? false; isFavorite = comic.isFavorite ?? false; if (comic.chapters == null) { isDownloaded = LocalManager().isDownloaded( comic.id, comic.comicType, 0, ); } } Iterable 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 const SliverPadding(padding: EdgeInsets.only(top: 8)); yield SliverLazyToBoxAdapter( child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(width: 16), Hero( tag: "cover${widget.heroID}", child: Container( decoration: BoxDecoration( color: context.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: context.colorScheme.outlineVariant, blurRadius: 1, offset: const Offset(0, 1), ), ], ), height: 144, width: 144 * 0.72, clipBehavior: Clip.antiAlias, child: AnimatedImage( image: CachedImageProvider( widget.cover ?? comic.cover, sourceKey: comic.sourceKey, cid: comic.id, ), width: double.infinity, height: double.infinity, ), ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SelectableText(comic.title, style: ts.s18), if (comic.subTitle != null) SelectableText(comic.subTitle!, style: ts.s14) .paddingVertical(4), Text( (ComicSource.find(comic.sourceKey)?.name) ?? '', style: ts.s12, ), ], ), ), ], ), ); } Widget buildActions() { bool isMobile = context.width < changePoint; bool hasHistory = history != null && (history!.ep > 1 || history!.page > 1); return SliverLazyToBoxAdapter( child: Column( children: [ ListView( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 8), children: [ if (hasHistory && !isMobile) _ActionButton( icon: const Icon(Icons.menu_book), text: 'Continue'.tl, onPressed: continueRead, iconColor: context.useTextColor(Colors.yellow), ), if (!isMobile || hasHistory) _ActionButton( icon: const Icon(Icons.play_circle_outline), text: 'Start'.tl, onPressed: read, iconColor: context.useTextColor(Colors.orange), ), if (!isMobile && !isDownloaded) _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: isLiked, text: ((data!.likesCount != null) ? (data!.likesCount! + (isLiked ? 1 : 0)) : (isLiked ? 'Liked'.tl : 'Like'.tl)) .toString(), isLoading: isLiking, onPressed: likeOrUnlike, iconColor: context.useTextColor(Colors.red), ), _ActionButton( icon: const Icon(Icons.bookmark_outline_outlined), activeIcon: const Icon(Icons.bookmark), isActive: isFavorite || isAddToLocalFav, text: 'Favorite'.tl, onPressed: openFavPanel, onLongPressed: quickFavorite, iconColor: context.useTextColor(Colors.purple), ), if (comicSource.commentsLoader != null) _ActionButton( icon: const Icon(Icons.comment), text: (comic.commentCount ?? 'Comments'.tl).toString(), onPressed: showComments, 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: download, child: Text("Download".tl), ), ), const SizedBox(width: 16), Expanded( child: hasHistory ? FilledButton( onPressed: continueRead, child: Text("Continue".tl)) : FilledButton(onPressed: read, child: Text("Read".tl)), ) ], ).paddingHorizontal(16).paddingVertical(8), if (history != null) Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: context.colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(16), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.history, color: context.useTextColor(Colors.teal)), const SizedBox(width: 8), Builder( builder: (context) { bool haveChapter = comic.chapters != null; var page = history!.page; var ep = history!.ep; var group = history!.group; String text; if (haveChapter) { var epName = group == null ? comic.chapters!.titles.elementAt( math.min(ep - 1, comic.chapters!.length - 1), ) : comic.chapters! .getGroupByIndex(group - 1) .values .elementAt(ep - 1); text = "Last Reading: @epName Page @page".tlParams({ 'epName': epName, 'page': page, }); } else { text = "Last Reading: Page @page".tlParams({ 'page': page, }); } return Text(text); }, ), const SizedBox(width: 4), ], ), ).toAlign(Alignment.centerLeft), const Divider(), ], ).paddingTop(16), ); } Widget buildDescription() { if (comic.description == null || comic.description!.trim().isEmpty) { return const SliverPadding(padding: EdgeInsets.zero); } return SliverLazyToBoxAdapter( child: Column( children: [ ListTile( title: Text("Description".tl), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SelectableText(comic.description!).fixWidth(double.infinity), ), 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.surfaceContainerLow; } 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, onLongPress: () { Clipboard.setData(ClipboardData(text: text)); context.showMessage(message: "Copied".tl); }, onSecondaryTapDown: (details) { showMenuX(context, details.globalPosition, [ MenuEntry( icon: Icons.remove_red_eye, text: "View".tl, onClick: onTap, ), MenuEntry( icon: Icons.copy, text: "Copy".tl, onClick: () { Clipboard.setData(ClipboardData(text: text)); context.showMessage(message: "Copied".tl); }, ), ]); }, child: Text(text).padding(padding), ), ); } else { return Container( decoration: BoxDecoration( color: color, borderRadius: borderRadius, ), child: Text(text).padding(padding), ); } } String formatTime(String time) { if (int.tryParse(time) != null) { var t = int.tryParse(time); if (t! > 1000000000000) { return DateTime.fromMillisecondsSinceEpoch(t) .toString() .substring(0, 19); } else { return DateTime.fromMillisecondsSinceEpoch(t * 1000) .toString() .substring(0, 19); } } if (time.contains('T') || time.contains('Z')) { var t = DateTime.parse(time); return t.toString().substring(0, 19); } return time; } Widget buildWrap({required List children}) { return Wrap( runSpacing: 8, spacing: 8, children: children, ).paddingHorizontal(16).paddingBottom(8); } bool enableTranslation = App.locale.languageCode == 'zh' && comicSource.enableTagsTranslate; return SliverLazyToBoxAdapter( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ ListTile( title: Text("Information".tl), ), if (comic.stars != null) Row( children: [ StarRating( value: comic.stars!, size: 24, onTap: starRating, ), const SizedBox(width: 8), Text(comic.stars!.toStringAsFixed(2)), ], ).paddingLeft(16).paddingVertical(8), for (var e in comic.tags.entries) buildWrap( children: [ if (e.value.isNotEmpty) buildTag(text: e.key.ts(comicSource.key), isTitle: true), for (var tag in e.value) buildTag( text: enableTranslation ? TagsTranslation.translationTagWithNamespace( tag, e.key.toLowerCase(), ) : tag, onTap: () => onTapTag(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: formatTime(comic.uploadTime!)), ], ), if (comic.updateTime != null) buildWrap( children: [ buildTag(text: 'Update Time'.tl, isTitle: true), buildTag(text: formatTime(comic.updateTime!)), ], ), const SizedBox(height: 12), const Divider(), ], ), ); } Widget buildChapters() { if (comic.chapters == null) { return const SliverPadding(padding: EdgeInsets.zero); } return _ComicChapters( history: history, groupedMode: comic.chapters!.isGrouped, ); } Widget buildThumbnails() { if (comic.thumbnails == null && comicSource.loadComicThumbnail == null) { return const SliverPadding(padding: EdgeInsets.zero); } return const _ComicThumbnails(); } Widget buildRecommend() { if (comic.recommend == null || comic.recommend!.isEmpty) { return const SliverPadding(padding: EdgeInsets.zero); } return SliverMainAxisGroup(slivers: [ SliverToBoxAdapter( child: ListTile( title: Text("Related".tl), ), ), SliverGridComics(comics: comic.recommend!), ]); } Widget buildComments() { if (comic.comments == null || comic.comments!.isEmpty) { return const SliverPadding(padding: EdgeInsets.zero); } return _CommentsPart( comments: comic.comments!, showMore: showComments, ); } } class _ActionButton extends StatelessWidget { const _ActionButton({ required this.icon, required this.text, required this.onPressed, this.onLongPressed, 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; final void Function()? onLongPressed; @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: () { if (!(isLoading ?? false)) { onPressed(); } }, onLongPress: onLongPressed, 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 _SelectDownloadChapter extends StatefulWidget { const _SelectDownloadChapter(this.eps, this.finishSelect, this.downloadedEps); final List eps; final void Function(List) finishSelect; final List downloadedEps; @override State<_SelectDownloadChapter> createState() => _SelectDownloadChapterState(); } class _SelectDownloadChapterState extends State<_SelectDownloadChapter> { List selected = []; @override Widget build(BuildContext context) { return Scaffold( appBar: Appbar( title: Text("Download".tl), backgroundColor: context.colorScheme.surfaceContainerLow, ), body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: ListView.builder( padding: EdgeInsets.zero, itemCount: widget.eps.length, itemBuilder: (context, i) { return CheckboxListTile( title: Text(widget.eps[i]), value: selected.contains(i) || widget.downloadedEps.contains(i), onChanged: widget.downloadedEps.contains(i) ? null : (v) { setState(() { if (selected.contains(i)) { selected.remove(i); } else { selected.add(i); } }); }); }, ), ), Container( height: 50, decoration: BoxDecoration( border: Border( top: BorderSide( color: context.colorScheme.outlineVariant, ), ), ), child: Row( children: [ const SizedBox(width: 16), Expanded( child: TextButton( onPressed: () { var res = []; for (int i = 0; i < widget.eps.length; i++) { if (!widget.downloadedEps.contains(i)) { res.add(i); } } widget.finishSelect(res); context.pop(); }, child: Text("Download All".tl), ), ), const SizedBox(width: 16), Expanded( child: FilledButton( onPressed: selected.isEmpty ? null : () { widget.finishSelect(selected); context.pop(); }, child: Text("Download Selected".tl), ), ), const SizedBox(width: 16), ], ), ), SizedBox(height: MediaQuery.of(context).padding.bottom), ], ), ); } } class _ComicPageLoadingPlaceHolder extends StatelessWidget { const _ComicPageLoadingPlaceHolder({ this.cover, this.title, required this.sourceKey, required this.cid, this.heroID, }); final String? cover; final String? title; final String sourceKey; final String cid; final int? heroID; @override Widget build(BuildContext context) { Widget buildContainer(double? width, double? height, {Color? color, double? radius}) { return Container( height: height, width: width, decoration: BoxDecoration( color: color ?? context.colorScheme.surfaceContainerLow, borderRadius: BorderRadius.circular(radius ?? 4), ), ); } return Shimmer( color: context.isDarkMode ? Colors.grey.shade700 : Colors.white, child: Column( children: [ Appbar(title: Text(""), backgroundColor: context.colorScheme.surface), const SizedBox(height: 8), Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(width: 16), buildImage(context), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (title != null) Text(title ?? "", style: ts.s18) else buildContainer(200, 25), const SizedBox(height: 8), buildContainer(80, 20), ], ), ), ], ), const SizedBox(height: 8), if (context.width < changePoint) Row( children: [ Expanded( child: buildContainer(null, 36, radius: 18), ), const SizedBox(width: 16), Expanded( child: buildContainer(null, 36, radius: 18), ), ], ).paddingHorizontal(16), const Divider(), const SizedBox(height: 8), Center( child: CircularProgressIndicator( strokeWidth: 2.4, ).fixHeight(24).fixWidth(24), ) ], ), ); } Widget buildImage(BuildContext context) { Widget child; if (cover != null) { child = AnimatedImage( image: CachedImageProvider( cover!, sourceKey: sourceKey, cid: cid, ), width: double.infinity, height: double.infinity, fit: BoxFit.cover, ); } else { child = const SizedBox(); } return Hero( tag: "cover$heroID", child: Container( decoration: BoxDecoration( color: context.colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: context.colorScheme.outlineVariant, blurRadius: 1, offset: const Offset(0, 1), ), ], ), height: 144, width: 144 * 0.72, clipBehavior: Clip.antiAlias, child: child, ), ); } }