mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
1424 lines
40 KiB
Dart
1424 lines
40 KiB
Dart
part of 'components.dart';
|
|
|
|
class ComicTile extends StatelessWidget {
|
|
const ComicTile(
|
|
{super.key,
|
|
required this.comic,
|
|
this.enableLongPressed = true,
|
|
this.badge,
|
|
this.menuOptions,
|
|
this.onTap,
|
|
this.onLongPressed});
|
|
|
|
final Comic comic;
|
|
|
|
final bool enableLongPressed;
|
|
|
|
final String? badge;
|
|
|
|
final List<MenuEntry>? menuOptions;
|
|
|
|
final VoidCallback? onTap;
|
|
|
|
final VoidCallback? onLongPressed;
|
|
|
|
void _onTap() {
|
|
if (onTap != null) {
|
|
onTap!();
|
|
return;
|
|
}
|
|
App.mainNavigatorKey?.currentContext
|
|
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey));
|
|
}
|
|
|
|
void _onLongPressed(context) {
|
|
if (onLongPressed != null) {
|
|
onLongPressed!();
|
|
return;
|
|
}
|
|
onLongPress(context);
|
|
}
|
|
|
|
void onLongPress(BuildContext context) {
|
|
var renderBox = context.findRenderObject() as RenderBox;
|
|
var size = renderBox.size;
|
|
var location = renderBox.localToGlobal(
|
|
Offset((size.width - 242) / 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.toOpacity(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 = LocalComicImageProvider(comic as LocalComic);
|
|
} else if (comic is History) {
|
|
image = HistoryImageProvider(comic as History);
|
|
} else if (comic.sourceKey == 'local') {
|
|
var localComic = LocalManager().find(comic.id, ComicType.local);
|
|
if (localComic == null) {
|
|
return const SizedBox();
|
|
}
|
|
image = FileImage(localComic.coverFile);
|
|
} else {
|
|
image = CachedImageProvider(
|
|
comic.cover,
|
|
sourceKey: comic.sourceKey,
|
|
cid: comic.id,
|
|
);
|
|
}
|
|
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 ? () => _onLongPressed(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: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return InkWell(
|
|
borderRadius: BorderRadius.circular(8),
|
|
onTap: _onTap,
|
|
onLongPress:
|
|
enableLongPressed ? () => _onLongPressed(context) : null,
|
|
onSecondaryTapDown: (detail) => onSecondaryTap(detail, context),
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: SizedBox(
|
|
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),
|
|
),
|
|
),
|
|
Align(
|
|
alignment: Alignment.bottomRight,
|
|
child: (() {
|
|
final subtitle =
|
|
comic.subtitle?.replaceAll('\n', '').trim();
|
|
final text = comic.description.isNotEmpty
|
|
? comic.description.split('|').join('\n')
|
|
: (subtitle?.isNotEmpty == true
|
|
? subtitle
|
|
: null);
|
|
final scale =
|
|
(appdata.settings['comicTileScale'] as num)
|
|
.toDouble();
|
|
final fortSize = scale < 0.85
|
|
? 8.0 // 小尺寸
|
|
: (scale < 1.0 ? 10.0 : 12.0);
|
|
|
|
if (text == null) {
|
|
return const SizedBox
|
|
.shrink(); // 如果没有文本,则不显示任何内容
|
|
}
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 2, vertical: 2),
|
|
child: ClipRRect(
|
|
borderRadius: const BorderRadius.all(
|
|
Radius.circular(10.0),
|
|
),
|
|
child: Container(
|
|
color: Colors.black.toOpacity(0.5),
|
|
child: Padding(
|
|
padding:
|
|
const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxWidth: constraints.maxWidth,
|
|
),
|
|
child: Text(
|
|
text,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w500,
|
|
fontSize: fortSize,
|
|
color: Colors.white,
|
|
),
|
|
textAlign: TextAlign.right,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
})(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(8, 4, 8, 0),
|
|
child: Text(
|
|
comic.title.replaceAll('\n', ''),
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
));
|
|
}
|
|
|
|
List<String> _splitText(String text) {
|
|
// split text by space, comma. text in brackets will be kept together.
|
|
var words = <String>[];
|
|
var buffer = StringBuffer();
|
|
var inBracket = false;
|
|
for (var i = 0; i < text.length; i++) {
|
|
var c = text[i];
|
|
if (c == '[' || c == '(') {
|
|
inBracket = true;
|
|
} else if (c == ']' || c == ')') {
|
|
inBracket = false;
|
|
} else if (c == ' ' || c == ',') {
|
|
if (inBracket) {
|
|
buffer.write(c);
|
|
} else {
|
|
words.add(buffer.toString());
|
|
buffer.clear();
|
|
}
|
|
} else {
|
|
buffer.write(c);
|
|
}
|
|
}
|
|
if (buffer.isNotEmpty) {
|
|
words.add(buffer.toString());
|
|
}
|
|
return words;
|
|
}
|
|
|
|
void block(BuildContext comicTileContext) {
|
|
showDialog(
|
|
context: App.rootContext,
|
|
builder: (context) {
|
|
var words = <String>[];
|
|
var all = <String>[];
|
|
all.addAll(_splitText(comic.title));
|
|
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<String>? 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: <Widget>[
|
|
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.toOpacity(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.split(':').last,
|
|
style: const TextStyle(fontSize: 12),
|
|
softWrap: true,
|
|
overflow: TextOverflow.ellipsis,
|
|
maxLines: 1,
|
|
))),
|
|
],
|
|
),
|
|
).toAlign(Alignment.topCenter);
|
|
}),
|
|
)
|
|
else
|
|
const Spacer(),
|
|
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,
|
|
this.onLongPressed,
|
|
this.selections});
|
|
|
|
final List<Comic> comics;
|
|
|
|
final Map<Comic, bool>? selections;
|
|
|
|
final void Function()? onLastItemBuild;
|
|
|
|
final String? Function(Comic)? badgeBuilder;
|
|
|
|
final List<MenuEntry> Function(Comic)? menuBuilder;
|
|
|
|
final void Function(Comic)? onTap;
|
|
|
|
final void Function(Comic)? onLongPressed;
|
|
|
|
@override
|
|
State<SliverGridComics> createState() => _SliverGridComicsState();
|
|
}
|
|
|
|
class _SliverGridComicsState extends State<SliverGridComics> {
|
|
List<Comic> 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,
|
|
selection: widget.selections,
|
|
onLastItemBuild: widget.onLastItemBuild,
|
|
badgeBuilder: widget.badgeBuilder,
|
|
menuBuilder: widget.menuBuilder,
|
|
onTap: widget.onTap,
|
|
onLongPressed: widget.onLongPressed,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SliverGridComics extends StatelessWidget {
|
|
const _SliverGridComics({
|
|
required this.comics,
|
|
this.onLastItemBuild,
|
|
this.badgeBuilder,
|
|
this.menuBuilder,
|
|
this.onTap,
|
|
this.onLongPressed,
|
|
this.selection,
|
|
});
|
|
|
|
final List<Comic> comics;
|
|
|
|
final Map<Comic, bool>? selection;
|
|
|
|
final void Function()? onLastItemBuild;
|
|
|
|
final String? Function(Comic)? badgeBuilder;
|
|
|
|
final List<MenuEntry> Function(Comic)? menuBuilder;
|
|
|
|
final void Function(Comic)? onTap;
|
|
|
|
final void Function(Comic)? onLongPressed;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SliverGrid(
|
|
delegate: SliverChildBuilderDelegate(
|
|
(context, index) {
|
|
if (index == comics.length - 1) {
|
|
onLastItemBuild?.call();
|
|
}
|
|
var badge = badgeBuilder?.call(comics[index]);
|
|
var isSelected =
|
|
selection == null ? false : selection![comics[index]] ?? false;
|
|
var comic = ComicTile(
|
|
comic: comics[index],
|
|
badge: badge,
|
|
menuOptions: menuBuilder?.call(comics[index]),
|
|
onTap: onTap != null ? () => onTap!(comics[index]) : null,
|
|
onLongPressed: onLongPressed != null
|
|
? () => onLongPressed!(comics[index])
|
|
: null,
|
|
);
|
|
if (selection == null) {
|
|
return comic;
|
|
}
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 150),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? Theme.of(context).colorScheme.secondaryContainer.toOpacity(0.72)
|
|
: null,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
margin: const EdgeInsets.all(4),
|
|
child: comic,
|
|
);
|
|
},
|
|
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 ?? <String>[]) {
|
|
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,
|
|
this.controller,
|
|
this.refreshHandlerCallback,
|
|
});
|
|
|
|
final Future<Res<List<Comic>>> Function(int page)? loadPage;
|
|
|
|
final Future<Res<List<Comic>>> Function(String? next)? loadNext;
|
|
|
|
final Widget? leadingSliver;
|
|
|
|
final Widget? trailingSliver;
|
|
|
|
final Widget? errorLeading;
|
|
|
|
final List<MenuEntry> Function(Comic)? menuBuilder;
|
|
|
|
final ScrollController? controller;
|
|
|
|
final void Function(VoidCallback c)? refreshHandlerCallback;
|
|
|
|
@override
|
|
State<ComicList> createState() => ComicListState();
|
|
}
|
|
|
|
class ComicListState extends State<ComicList> {
|
|
int? _maxPage;
|
|
|
|
final Map<int, List<Comic>> _data = {};
|
|
|
|
int _page = 1;
|
|
|
|
String? _error;
|
|
|
|
final Map<int, bool> _loading = {};
|
|
|
|
String? _nextUrl;
|
|
|
|
Map<String, dynamic> get state => {
|
|
'maxPage': _maxPage,
|
|
'data': _data,
|
|
'page': _page,
|
|
'error': _error,
|
|
'loading': _loading,
|
|
'nextUrl': _nextUrl,
|
|
};
|
|
|
|
void restoreState(Map<String, dynamic>? state) {
|
|
if (state == null) {
|
|
return;
|
|
}
|
|
_maxPage = state['maxPage'];
|
|
_data.clear();
|
|
_data.addAll(state['data']);
|
|
_page = state['page'];
|
|
_error = state['error'];
|
|
_loading.clear();
|
|
_loading.addAll(state['loading']);
|
|
_nextUrl = state['nextUrl'];
|
|
}
|
|
|
|
void storeState() {
|
|
PageStorage.of(context).writeState(context, state);
|
|
}
|
|
|
|
void refresh() {
|
|
_data.clear();
|
|
_page = 1;
|
|
_maxPage = null;
|
|
_error = null;
|
|
_nextUrl = null;
|
|
_loading.clear();
|
|
storeState();
|
|
setState(() {});
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
restoreState(PageStorage.of(context).readState(context));
|
|
widget.refreshHandlerCallback?.call(refresh);
|
|
}
|
|
|
|
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: <TextInputFormatter>[
|
|
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<void> _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 (!mounted) return;
|
|
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();
|
|
}
|
|
if(mounted) {
|
|
setState(() {});
|
|
}
|
|
} catch (e) {
|
|
if(mounted) {
|
|
setState(() {
|
|
_error = e.toString();
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} finally {
|
|
_loading[page] = false;
|
|
storeState();
|
|
}
|
|
}
|
|
|
|
Future<void> _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(
|
|
key: const PageStorageKey('scroll'),
|
|
controller: widget.controller,
|
|
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<Rect> {
|
|
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<Rect> 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<double> 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<RatingWidget> createState() => _RatingWidgetState();
|
|
}
|
|
|
|
class _RatingWidgetState extends State<RatingWidget> {
|
|
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<Widget> buildRow() {
|
|
int full = fullStars();
|
|
List<Widget> 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<Widget> buildNormalRow() {
|
|
List<Widget> 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: <Widget>[
|
|
Row(
|
|
children: buildNormalRow(),
|
|
),
|
|
Row(
|
|
children: buildRow(),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
value = widget.value;
|
|
}
|
|
}
|
|
|
|
class SMClipper extends CustomClipper<Rect> {
|
|
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;
|
|
}
|
|
}
|