mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
733 lines
21 KiB
Dart
733 lines
21 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() {
|
|
App.mainNavigatorKey?.currentContext
|
|
?.to(() => ComicPage(id: comic.id, sourceKey: comic.sourceKey));
|
|
}
|
|
|
|
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,
|
|
this.leadingSliver,
|
|
this.trailingSliver,
|
|
this.errorLeading,
|
|
});
|
|
|
|
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;
|
|
|
|
@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 != 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<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: [
|
|
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 []),
|
|
if (data[page]!.length > 6) buildSliverPageSelector(),
|
|
if (widget.trailingSliver != null) widget.trailingSliver!,
|
|
],
|
|
);
|
|
}
|
|
}
|