mirror of
https://github.com/venera-app/venera.git
synced 2025-09-27 15:57:25 +00:00
1137 lines
30 KiB
Dart
1137 lines
30 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,
|
|
});
|
|
|
|
final Comic comic;
|
|
|
|
final bool enableLongPressed;
|
|
|
|
final String? badge;
|
|
|
|
final List<MenuEntry>? 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: () {
|
|
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);
|
|
},
|
|
),
|
|
...?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 if (comic.cover.startsWith('file://')) {
|
|
image = FileImage(File(comic.cover.substring(7)));
|
|
} else if (comic.sourceKey == 'local') {
|
|
var localComic = LocalManager().find(comic.id, ComicType.local);
|
|
image = FileImage(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,
|
|
rating: comic.stars,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
));
|
|
});
|
|
}
|
|
|
|
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,
|
|
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 == "");
|
|
}
|
|
var enableTranslate =
|
|
App.locale.languageCode == 'zh' && this.enableTranslate;
|
|
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!)
|
|
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(),
|
|
if (rating != null) StarRating(value: rating!, size: 18),
|
|
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<Comic> comics;
|
|
|
|
final void Function()? onLastItemBuild;
|
|
|
|
final String? Function(Comic)? badgeBuilder;
|
|
|
|
final List<MenuEntry> Function(Comic)? menuBuilder;
|
|
|
|
final void Function(Comic)? onTap;
|
|
|
|
@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,
|
|
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<Comic> comics;
|
|
|
|
final void Function()? onLastItemBuild;
|
|
|
|
final String? Function(Comic)? badgeBuilder;
|
|
|
|
final List<MenuEntry> 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 ?? <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,
|
|
});
|
|
|
|
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;
|
|
|
|
@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;
|
|
|
|
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 (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 == 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(
|
|
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;
|
|
}
|
|
}
|