part of 'components.dart'; class ComicTile extends StatelessWidget { const ComicTile({ super.key, required this.comic, this.enableLongPressed = true, this.badge, }); final Comic comic; final bool enableLongPressed; final String? badge; void onTap() {} void onLongPress() {} void onSecondaryTap(TapDownDetails details) {} @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 : 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, ), ), ], ), )); }); } 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 : 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, 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; @override Widget build(BuildContext context) { if (tags != null) { tags!.removeWhere((element) => element.removeAllBlank == ""); } 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!) Padding( padding: const EdgeInsets.fromLTRB(0, 0, 4, 3), child: Container( 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( s, style: const TextStyle(fontSize: 12), ), ), ) ], ), ), ), ), const SizedBox( height: 2, ), 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, }); final List comics; final void Function()? onLastItemBuild; @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, ); }, ); } } class _SliverGridComics extends StatelessWidget { const _SliverGridComics({ required this.comics, this.onLastItemBuild, }); final List comics; final void Function()? onLastItemBuild; @override Widget build(BuildContext context) { return SliverGrid( delegate: SliverChildBuilderDelegate( (context, index) { if (index == comics.length - 1) { onLastItemBuild?.call(); } return ComicTile(comic: comics[index]); }, 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; } } // TODO: check translated tags } } return null; } class ComicList extends StatefulWidget { const ComicList({ super.key, this.loadPage, this.loadNext, this.leadingSliver, this.trailingSliver, }); final Future>> Function(int page)? loadPage; final Future>> Function(String? next)? loadNext; final Widget? leadingSliver; final Widget? trailingSliver; @override State createState() => _ComicListState(); } class _ComicListState extends State { int? maxPage; Map> data = {}; int page = 1; String? error; Map loading = {}; String? nextUrl; 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: [ 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 []), buildSliverPageSelector(), if (widget.trailingSliver != null) widget.trailingSliver!, ], ); } }