Files
venera/lib/components/comic.dart
2024-10-01 16:37:49 +08:00

712 lines
20 KiB
Dart

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<String>? tags;
final int maxLines;
@override
Widget build(BuildContext context) {
if (tags != null) {
tags!.removeWhere((element) => element.removeAllBlank == "");
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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<Comic> comics;
final void Function()? onLastItemBuild;
@override
Widget build(BuildContext context) {
return StateBuilder<SliverGridComicsController>(
init: SliverGridComicsController(),
builder: (controller) {
List<Comic> 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<Comic> 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 ?? <String>[]) {
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});
final Future<Res<List<Comic>>> Function(int page)? loadPage;
final Future<Res<List<Comic>>> Function(String? next)? loadNext;
@override
State<ComicList> createState() => _ComicListState();
}
class _ComicListState extends State<ComicList> {
int? maxPage;
Map<int, List<Comic>> data = {};
int page = 1;
String? error;
Map<int, bool> 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: <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;
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<void> 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?['maxPage'] != null) {
maxPage = res.subData['maxPage'];
}
});
}
} 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<void> 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: [
buildSliverPageSelector(),
SliverGridComics(comics: data[page] ?? const []),
buildSliverPageSelector(),
],
);
}
}