part of 'components.dart'; class ComicTile extends StatelessWidget { const ComicTile({ super.key, required this.comic, this.enableLongPressed = true, this.badge, this.menuOptions, this.onTap, }); final Comic comic; final bool enableLongPressed; final String? badge; final List? menuOptions; final VoidCallback? onTap; void _onTap() { if (onTap != null) { onTap!(); return; } App.mainNavigatorKey?.currentContext ?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); } void onLongPress(BuildContext context) { var renderBox = context.findRenderObject() as RenderBox; var size = renderBox.size; var location = renderBox.localToGlobal( Offset(size.width / 2, size.height / 2), ); showMenu(location, context); } void onSecondaryTap(TapDownDetails details, BuildContext context) { showMenu(details.globalPosition, context); } void showMenu(Offset location, BuildContext context) { showMenuX( App.rootContext, location, [ MenuEntry( icon: Icons.chrome_reader_mode_outlined, text: 'Details'.tl, onClick: () { App.mainNavigatorKey?.currentContext ?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey)); }, ), MenuEntry( icon: Icons.copy, text: 'Copy Title'.tl, onClick: () { Clipboard.setData(ClipboardData(text: comic.title)); App.rootContext.showMessage(message: 'Title copied'.tl); }, ), MenuEntry( icon: Icons.stars_outlined, text: 'Add to favorites'.tl, onClick: () { addFavorite(comic); }, ), MenuEntry( icon: Icons.block, text: 'Block'.tl, onClick: () => block(context), ), ...?menuOptions, ], ); } @override Widget build(BuildContext context) { var type = appdata.settings['comicDisplayMode']; Widget child = type == 'detailed' ? _buildDetailedMode(context) : _buildBriefMode(context); var isFavorite = appdata.settings['showFavoriteStatusOnTile'] ? LocalFavoritesManager() .isExist(comic.id, ComicType(comic.sourceKey.hashCode)) : false; var history = appdata.settings['showHistoryStatusOnTile'] ? HistoryManager() .findSync(comic.id, ComicType(comic.sourceKey.hashCode)) : null; if (history?.page == 0) { history!.page = 1; } if (!isFavorite && history == null) { return child; } return Stack( children: [ Positioned.fill( child: child, ), Positioned( left: type == 'detailed' ? 16 : 6, top: 8, child: Container( height: 24, decoration: BoxDecoration( borderRadius: BorderRadius.circular(4), ), clipBehavior: Clip.antiAlias, child: Row( children: [ if (isFavorite) Container( height: 24, width: 24, color: Colors.green, child: const Icon( Icons.bookmark_rounded, size: 16, color: Colors.white, ), ), if (history != null) Container( height: 24, color: Colors.blue.withOpacity(0.9), constraints: const BoxConstraints(minWidth: 24), padding: const EdgeInsets.symmetric(horizontal: 4), child: CustomPaint( painter: _ReadingHistoryPainter(history.page, history.maxPage), ), ) ], ), ), ) ], ); } Widget buildImage(BuildContext context) { ImageProvider image; if (comic is LocalComic) { image = FileImage((comic as LocalComic).coverFile); } else if (comic.cover.startsWith('file://')) { image = FileImage(File(comic.cover.substring(7))); } else if (comic.sourceKey == 'local') { var localComic = LocalManager().find(comic.id, ComicType.local); image = FileImage(localComic!.coverFile); } else { image = CachedImageProvider(comic.cover, sourceKey: comic.sourceKey); } return AnimatedImage( image: image, fit: BoxFit.cover, width: double.infinity, height: double.infinity, ); } Widget _buildDetailedMode(BuildContext context) { return LayoutBuilder(builder: (context, constrains) { final height = constrains.maxHeight - 16; return InkWell( borderRadius: BorderRadius.circular(12), onTap: _onTap, onLongPress: enableLongPressed ? () => onLongPress(context) : null, onSecondaryTapDown: (detail) => onSecondaryTap(detail, context), child: Padding( padding: const EdgeInsets.fromLTRB(16, 8, 24, 8), child: Row( children: [ Container( width: height * 0.68, height: double.infinity, decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), clipBehavior: Clip.antiAlias, child: buildImage(context), ), SizedBox.fromSize( size: const Size(16, 5), ), Expanded( child: _ComicDescription( title: comic.maxPage == null ? comic.title.replaceAll("\n", "") : "[${comic.maxPage}P]${comic.title.replaceAll("\n", "")}", subtitle: comic.subtitle ?? '', description: comic.description, badge: badge ?? comic.language, tags: comic.tags, maxLines: 2, enableTranslate: ComicSource.find(comic.sourceKey) ?.enableTagsTranslate ?? false, rating: comic.stars, ), ), ], ), )); }); } Widget _buildBriefMode(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 8), child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(8), elevation: 1, child: Stack( children: [ Positioned.fill( child: Container( decoration: BoxDecoration( color: Theme.of(context).colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(8), ), clipBehavior: Clip.antiAlias, child: buildImage(context), ), ), Positioned( bottom: 0, left: 0, right: 0, child: Container( width: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withOpacity(0.3), Colors.black.withOpacity(0.5), ]), borderRadius: const BorderRadius.only( bottomLeft: Radius.circular(8), bottomRight: Radius.circular(8), ), ), child: Padding( padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), child: Text( comic.title.replaceAll("\n", ""), style: const TextStyle( fontWeight: FontWeight.w500, fontSize: 14.0, color: Colors.white, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ), )), Positioned.fill( child: Material( color: Colors.transparent, child: InkWell( onTap: _onTap, onLongPress: enableLongPressed ? () => onLongPress(context) : null, onSecondaryTapDown: (detail) => onSecondaryTap(detail, context), borderRadius: BorderRadius.circular(8), child: const SizedBox.expand(), ), ), ) ], ), ), ); } void block(BuildContext comicTileContext) { showDialog( context: App.rootContext, builder: (context) { var words = []; var all = []; all.addAll(comic.title.split(' ').where((element) => element != '')); if (comic.subtitle != null && comic.subtitle != "") { all.add(comic.subtitle!); } all.addAll(comic.tags ?? []); return StatefulBuilder(builder: (context, setState) { return ContentDialog( title: 'Block'.tl, content: Wrap( runSpacing: 8, spacing: 8, children: [ for (var word in all) OptionChip( text: word, isSelected: words.contains(word), onTap: () { setState(() { if (!words.contains(word)) { words.add(word); } else { words.remove(word); } }); }, ), ], ).paddingHorizontal(16), actions: [ Button.filled( onPressed: () { context.pop(); for (var word in words) { appdata.settings['blockedWords'].add(word); } appdata.saveData(); context.showMessage(message: 'Blocked'.tl); comicTileContext .findAncestorStateOfType<_SliverGridComicsState>()! .update(); }, child: Text('Block'.tl), ), ], ); }); }, ); } } class _ComicDescription extends StatelessWidget { const _ComicDescription({ required this.title, required this.subtitle, required this.description, required this.enableTranslate, this.badge, this.maxLines = 2, this.tags, this.rating, }); final String title; final String subtitle; final String description; final String? badge; final List? tags; final int maxLines; final bool enableTranslate; final double? rating; @override Widget build(BuildContext context) { if (tags != null) { tags!.removeWhere((element) => element.removeAllBlank == ""); for (var s in tags!) { s = s.replaceAll("\n", " "); } } var enableTranslate = App.locale.languageCode == 'zh' && this.enableTranslate; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title.trim(), style: const TextStyle( fontWeight: FontWeight.w500, fontSize: 14.0, ), maxLines: maxLines, overflow: TextOverflow.ellipsis, softWrap: true, ), if (subtitle != "") Text( subtitle, style: TextStyle( fontSize: 10.0, color: context.colorScheme.onSurface.withOpacity(0.7)), maxLines: 1, softWrap: true, overflow: TextOverflow.ellipsis, ), const SizedBox( height: 4, ), if (tags != null) Expanded( child: LayoutBuilder(builder: (context, constraints) { if (constraints.maxHeight < 22) { return Container(); } int cnt = (constraints.maxHeight - 22).toInt() ~/ 25; return Container( clipBehavior: Clip.antiAlias, height: 22 + cnt * 25, width: double.infinity, decoration: const BoxDecoration(), child: Wrap( runAlignment: WrapAlignment.start, clipBehavior: Clip.antiAlias, crossAxisAlignment: WrapCrossAlignment.end, spacing: 4, runSpacing: 3, children: [ for (var s in tags!) Container( height: 22, padding: const EdgeInsets.fromLTRB(3, 2, 3, 2), constraints: BoxConstraints( maxWidth: constraints.maxWidth * 0.45, ), decoration: BoxDecoration( color: s == "Unavailable" ? Theme.of(context).colorScheme.errorContainer : Theme.of(context) .colorScheme .secondaryContainer, borderRadius: const BorderRadius.all(Radius.circular(8)), ), child: Center( widthFactor: 1, child: Text( enableTranslate ? TagsTranslation.translateTag(s) : s, style: const TextStyle(fontSize: 12), softWrap: true, overflow: TextOverflow.ellipsis, maxLines: 1, ))), ], ), ).toAlign(Alignment.topCenter); }), ), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (rating != null) StarRating(value: rating!, size: 18), Text( description, style: const TextStyle( fontSize: 12.0, ), ), ], ), ), if (badge != null) Container( padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), decoration: BoxDecoration( color: Theme.of(context).colorScheme.tertiaryContainer, borderRadius: const BorderRadius.all(Radius.circular(8)), ), child: Center( child:Text( "${badge![0].toUpperCase()}${badge!.substring(1).toLowerCase()}", style: const TextStyle(fontSize: 12), ), ) ), ], ) ], ); } } class _ReadingHistoryPainter extends CustomPainter { final int page; final int? maxPage; const _ReadingHistoryPainter(this.page, this.maxPage); @override void paint(Canvas canvas, Size size) { if (maxPage == null) { // 在中央绘制page final textPainter = TextPainter( text: TextSpan( text: "$page", style: TextStyle( fontSize: size.width * 0.8, color: Colors.white, ), ), textDirection: TextDirection.ltr, ); textPainter.layout(); textPainter.paint( canvas, Offset((size.width - textPainter.width) / 2, (size.height - textPainter.height) / 2)); } else if (page == maxPage) { // 在中央绘制勾 final paint = Paint() ..color = Colors.white ..strokeWidth = 2 ..style = PaintingStyle.stroke; canvas.drawLine(Offset(size.width * 0.2, size.height * 0.5), Offset(size.width * 0.45, size.height * 0.75), paint); canvas.drawLine(Offset(size.width * 0.45, size.height * 0.75), Offset(size.width * 0.85, size.height * 0.3), paint); } else { // 在左上角绘制page, 在右下角绘制maxPage final textPainter = TextPainter( text: TextSpan( text: "$page", style: TextStyle( fontSize: size.width * 0.8, color: Colors.white, ), ), textDirection: TextDirection.ltr, ); textPainter.layout(); textPainter.paint(canvas, const Offset(0, 0)); final textPainter2 = TextPainter( text: TextSpan( text: "/$maxPage", style: TextStyle( fontSize: size.width * 0.5, color: Colors.white, ), ), textDirection: TextDirection.ltr, ); textPainter2.layout(); textPainter2.paint( canvas, Offset(size.width - textPainter2.width, size.height - textPainter2.height)); } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) { return oldDelegate is! _ReadingHistoryPainter || oldDelegate.page != page || oldDelegate.maxPage != maxPage; } } class SliverGridComics extends StatefulWidget { const SliverGridComics({ super.key, required this.comics, this.onLastItemBuild, this.badgeBuilder, this.menuBuilder, this.onTap, }); final List comics; final void Function()? onLastItemBuild; final String? Function(Comic)? badgeBuilder; final List Function(Comic)? menuBuilder; final void Function(Comic)? onTap; @override State createState() => _SliverGridComicsState(); } class _SliverGridComicsState extends State { List comics = []; @override void didUpdateWidget(covariant SliverGridComics oldWidget) { if (oldWidget.comics != widget.comics) { comics.clear(); for (var comic in widget.comics) { if (isBlocked(comic) == null) { comics.add(comic); } } } super.didUpdateWidget(oldWidget); } @override void initState() { for (var comic in widget.comics) { if (isBlocked(comic) == null) { comics.add(comic); } } super.initState(); } void update() { setState(() { comics.clear(); for (var comic in widget.comics) { if (isBlocked(comic) == null) { comics.add(comic); } } }); } @override Widget build(BuildContext context) { return _SliverGridComics( comics: comics, onLastItemBuild: widget.onLastItemBuild, badgeBuilder: widget.badgeBuilder, menuBuilder: widget.menuBuilder, onTap: widget.onTap, ); } } class _SliverGridComics extends StatelessWidget { const _SliverGridComics({ required this.comics, this.onLastItemBuild, this.badgeBuilder, this.menuBuilder, this.onTap, }); final List comics; final void Function()? onLastItemBuild; final String? Function(Comic)? badgeBuilder; final List Function(Comic)? menuBuilder; final void Function(Comic)? onTap; @override Widget build(BuildContext context) { return SliverGrid( delegate: SliverChildBuilderDelegate( (context, index) { if (index == comics.length - 1) { onLastItemBuild?.call(); } var badge = badgeBuilder?.call(comics[index]); return ComicTile( comic: comics[index], badge: badge, menuOptions: menuBuilder?.call(comics[index]), onTap: onTap != null ? () => onTap!(comics[index]) : null, ); }, childCount: comics.length, ), gridDelegate: SliverGridDelegateWithComics(), ); } } /// return the first blocked keyword, or null if not blocked String? isBlocked(Comic item) { for (var word in appdata.settings['blockedWords']) { if (item.title.contains(word)) { return word; } if (item.subtitle?.contains(word) ?? false) { return word; } if (item.description.contains(word)) { return word; } for (var tag in item.tags ?? []) { if (tag == word) { return word; } if (tag.contains(':')) { tag = tag.split(':')[1]; if (tag == word) { return word; } } } } return null; } class ComicList extends StatefulWidget { const ComicList({ super.key, this.loadPage, this.loadNext, this.leadingSliver, this.trailingSliver, this.errorLeading, this.menuBuilder, }); final Future>> Function(int page)? loadPage; final Future>> Function(String? next)? loadNext; final Widget? leadingSliver; final Widget? trailingSliver; final Widget? errorLeading; final List Function(Comic)? menuBuilder; @override State createState() => ComicListState(); } class ComicListState extends State { int? _maxPage; final Map> _data = {}; int _page = 1; String? _error; final Map _loading = {}; String? _nextUrl; void remove(Comic c) { if (_data[_page] == null || !_data[_page]!.remove(c)) { for (var page in _data.values) { if (page.remove(c)) { break; } } } setState(() {}); } Widget _buildPageSelector() { return Row( children: [ FilledButton( onPressed: _page > 1 ? () { setState(() { _error = null; _page--; }); } : null, child: Text("Back".tl), ).fixWidth(84), Expanded( child: Center( child: Material( color: Theme.of(context).colorScheme.surfaceContainer, borderRadius: BorderRadius.circular(8), child: InkWell( borderRadius: BorderRadius.circular(8), onTap: () { String value = ''; showDialog( context: App.rootContext, builder: (context) { return ContentDialog( title: "Jump to page".tl, content: TextField( keyboardType: TextInputType.number, decoration: InputDecoration( labelText: "Page".tl, ), inputFormatters: [ FilteringTextInputFormatter.digitsOnly ], onChanged: (v) { value = v; }, ).paddingHorizontal(16), actions: [ Button.filled( onPressed: () { Navigator.of(context).pop(); var page = int.tryParse(value); if (page == null) { context.showMessage(message: "Invalid page".tl); } else { if (page > 0 && (_maxPage == null || page <= _maxPage!)) { setState(() { _error = null; _page = page; }); } else { context.showMessage( message: "Invalid page".tl); } } }, child: Text("Jump".tl), ), ], ); }, ); }, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Text("Page $_page / ${_maxPage ?? '?'}"), ), ), ), ), ), FilledButton( onPressed: _page < (_maxPage ?? (_page + 1)) ? () { setState(() { _error = null; _page++; }); } : null, child: Text("Next".tl), ).fixWidth(84), ], ).paddingVertical(8).paddingHorizontal(16); } Widget _buildSliverPageSelector() { return SliverToBoxAdapter( child: _buildPageSelector(), ); } Future _loadPage(int page) async { if (widget.loadPage == null && widget.loadNext == null) { _error = "loadPage and loadNext can't be null at the same time"; Future.microtask(() { setState(() {}); }); } if (_loading[page] == true) { return; } _loading[page] = true; try { if (widget.loadPage != null) { var res = await widget.loadPage!(page); if (res.success) { if (res.data.isEmpty) { _data[page] = const []; setState(() { _maxPage = page; }); } else { setState(() { _data[page] = res.data; if (res.subData != null && res.subData is int) { _maxPage = res.subData; } }); } } else { setState(() { _error = res.errorMessage ?? "Unknown error".tl; }); } } else { try { while (_data[page] == null) { await _fetchNext(); } setState(() {}); } catch (e) { setState(() { _error = e.toString(); }); } } } finally { _loading[page] = false; } } Future _fetchNext() async { var res = await widget.loadNext!(_nextUrl); _data[_data.length + 1] = res.data; if (res.subData == null) { _maxPage = _data.length; } else { _nextUrl = res.subData; } } @override Widget build(BuildContext context) { if (_error != null) { return Column( children: [ if (widget.errorLeading != null) widget.errorLeading!, _buildPageSelector(), Expanded( child: NetworkError( withAppbar: false, message: _error!, retry: () { setState(() { _error = null; }); }, ), ), ], ); } if (_data[_page] == null) { _loadPage(_page); return Column( children: [ if (widget.errorLeading != null) widget.errorLeading!, const Expanded( child: Center( child: CircularProgressIndicator(), ), ), ], ); } return SmoothCustomScrollView( slivers: [ if (widget.leadingSliver != null) widget.leadingSliver!, if (_maxPage != 1) _buildSliverPageSelector(), SliverGridComics( comics: _data[_page] ?? const [], menuBuilder: widget.menuBuilder, ), if (_data[_page]!.length > 6 && _maxPage != 1) _buildSliverPageSelector(), if (widget.trailingSliver != null) widget.trailingSliver!, ], ); } } class StarRating extends StatelessWidget { const StarRating({ super.key, required this.value, this.onTap, this.size = 20, }); final double value; // 0-5 final VoidCallback? onTap; final double size; @override Widget build(BuildContext context) { var interval = size * 0.1; var value = this.value; if (value.isNaN) { value = 0; } var child = SizedBox( height: size, width: size * 5 + interval * 4, child: Row( children: [ for (var i = 0; i < 5; i++) _Star( value: (value - i).clamp(0.0, 1.0), size: size, ).paddingRight(i == 4 ? 0 : interval), ], ), ); return onTap == null ? child : GestureDetector( onTap: onTap, child: child, ); } } class _Star extends StatelessWidget { const _Star({required this.value, required this.size}); final double value; // 0-1 final double size; @override Widget build(BuildContext context) { return SizedBox( width: size, height: size, child: Stack( children: [ Icon( Icons.star_outline, size: size, color: context.colorScheme.secondary, ), ClipRect( clipper: _StarClipper(value), child: Icon( Icons.star, size: size, color: context.colorScheme.secondary, ), ), ], ), ); } } class _StarClipper extends CustomClipper { final double value; _StarClipper(this.value); @override Rect getClip(Size size) { return Rect.fromLTWH(0, 0, size.width * value, size.height); } @override bool shouldReclip(covariant CustomClipper oldClipper) { return oldClipper is! _StarClipper || oldClipper.value != value; } } class RatingWidget extends StatefulWidget { /// star number final int count; /// Max score final double maxRating; /// Current score value final double value; /// Star size final double size; /// Space between the stars final double padding; /// Whether the score can be modified by sliding final bool selectable; /// Callbacks when ratings change final ValueChanged onRatingUpdate; const RatingWidget( {super.key, this.maxRating = 10.0, this.count = 5, this.value = 10.0, this.size = 20, required this.padding, this.selectable = false, required this.onRatingUpdate}); @override State createState() => _RatingWidgetState(); } class _RatingWidgetState extends State { double value = 10; @override Widget build(BuildContext context) { return Listener( onPointerDown: (PointerDownEvent event) { double x = event.localPosition.dx; if (x < 0) x = 0; pointValue(x); }, onPointerMove: (PointerMoveEvent event) { double x = event.localPosition.dx; if (x < 0) x = 0; pointValue(x); }, onPointerUp: (_) {}, behavior: HitTestBehavior.deferToChild, child: buildRowRating(), ); } pointValue(double dx) { if (!widget.selectable) { return; } if (dx >= widget.size * widget.count + widget.padding * (widget.count - 1)) { value = widget.maxRating; } else { for (double i = 1; i < widget.count + 1; i++) { if (dx > widget.size * i + widget.padding * (i - 1) && dx < widget.size * i + widget.padding * i) { value = i * (widget.maxRating / widget.count); break; } else if (dx > widget.size * (i - 1) + widget.padding * (i - 1) && dx < widget.size * i + widget.padding * i) { value = (dx - widget.padding * (i - 1)) / (widget.size * widget.count) * widget.maxRating; break; } } } if (value % 1 >= 0.5) { value = value ~/ 1 + 1; } else { value = (value ~/ 1).toDouble(); } if (value < 0) { value = 0; } else if (value > 10) { value = 10; } setState(() { widget.onRatingUpdate(value); }); } int fullStars() { return (value / (widget.maxRating / widget.count)).floor(); } double star() { if (widget.count / fullStars() == widget.maxRating / value) { return 0; } return (value % (widget.maxRating / widget.count)) / (widget.maxRating / widget.count); } List buildRow() { int full = fullStars(); List children = []; for (int i = 0; i < full; i++) { children.add(Icon( Icons.star, size: widget.size, color: context.colorScheme.secondary, )); if (i < widget.count - 1) { children.add( SizedBox( width: widget.padding, ), ); } } if (full < widget.count) { children.add(ClipRect( clipper: SMClipper(rating: star() * widget.size), child: Icon( Icons.star, size: widget.size, color: context.colorScheme.secondary, ), )); } return children; } List buildNormalRow() { List children = []; for (int i = 0; i < widget.count; i++) { children.add(Icon( Icons.star_border, size: widget.size, color: context.colorScheme.secondary, )); if (i < widget.count - 1) { children.add(SizedBox( width: widget.padding, )); } } return children; } Widget buildRowRating() { return Stack( children: [ Row( children: buildNormalRow(), ), Row( children: buildRow(), ) ], ); } @override void initState() { super.initState(); value = widget.value; } } class SMClipper extends CustomClipper { final double rating; SMClipper({required this.rating}); @override Rect getClip(Size size) { return Rect.fromLTRB(0.0, 0.0, rating, size.height); } @override bool shouldReclip(SMClipper oldClipper) { return rating != oldClipper.rating; } }