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); } void onSecondaryTap(TapDownDetails details) { showMenu(details.globalPosition); } void showMenu(Offset location) { showMenuX( App.rootContext, location, [ MenuEntry( icon: Icons.chrome_reader_mode_outlined, text: 'Details'.tl, onClick: _onTap, ), 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); }, ), ...?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 { 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: onSecondaryTap, 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, tags: comic.tags, maxLines: 2, enableTranslate: ComicSource.find(comic.sourceKey) ?.enableTagsTranslate ?? false, ), ), ], ), )); }); } 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: onSecondaryTap, borderRadius: BorderRadius.circular(8), child: const SizedBox.expand(), ), ), ) ], ), ), ); } } 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, }); final String title; final String subtitle; final String description; final String? badge; final List? tags; final int maxLines; final bool enableTranslate; @override Widget build(BuildContext context) { if (tags != null) { tags!.removeWhere((element) => element.removeAllBlank == ""); } var enableTranslate = App.locale.languageCode == 'zh' && this.enableTranslate; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title, style: const TextStyle( fontWeight: FontWeight.w500, fontSize: 14.0, ), maxLines: maxLines, overflow: TextOverflow.ellipsis, ), if (subtitle != "") Text( subtitle, style: const TextStyle(fontSize: 10.0), maxLines: 1, ), const SizedBox( height: 4, ), if (tags != null) Expanded( child: LayoutBuilder( builder: (context, constraints) => Padding( padding: EdgeInsets.only(bottom: constraints.maxHeight % 23), child: Wrap( runAlignment: WrapAlignment.start, clipBehavior: Clip.antiAlias, crossAxisAlignment: WrapCrossAlignment.end, children: [ for (var s in tags!) Container( margin: const EdgeInsets.fromLTRB(0, 0, 4, 3), padding: const EdgeInsets.fromLTRB(3, 1, 3, 3), decoration: BoxDecoration( color: s == "Unavailable" ? Theme.of(context).colorScheme.errorContainer : Theme.of(context) .colorScheme .secondaryContainer, borderRadius: const BorderRadius.all(Radius.circular(8)), ), child: Text( enableTranslate ? TagsTranslation.translateTag(s) : s, style: const TextStyle(fontSize: 12), ), ), ], ), ), ), ), const SizedBox(height: 2), const Spacer(), Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ 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: Text( badge!, 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 SliverGridComicsController extends StateController {} class SliverGridComics extends StatelessWidget { 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 Widget build(BuildContext context) { return StateBuilder( init: SliverGridComicsController(), builder: (controller) { List comics = []; for (var comic in this.comics) { if (isBlocked(comic) == null) { comics.add(comic); } } return _SliverGridComics( comics: comics, onLastItemBuild: onLastItemBuild, badgeBuilder: badgeBuilder, menuBuilder: menuBuilder, onTap: 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; this._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 (_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['next'] == null) { _maxPage = _data.length; } else { _nextUrl = res.subData['next']; } } @override Widget build(BuildContext context) { if (widget.loadPage == null && widget.loadNext == null) { throw Exception("loadPage and loadNext can't be null at the same time"); } 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 const Center( child: CircularProgressIndicator(), ); } return SmoothCustomScrollView( slivers: [ if (widget.leadingSliver != null) widget.leadingSliver!, _buildSliverPageSelector(), SliverGridComics( comics: _data[_page] ?? const [], menuBuilder: widget.menuBuilder, ), if (_data[_page]!.length > 6) _buildSliverPageSelector(), if (widget.trailingSliver != null) widget.trailingSliver!, ], ); } }