Files
venera/lib/components/comic.dart
AnxuNA 036474a5d2 Optimization _buildBriefMode (#51)
更改_buildBriefMode样式
2024-11-17 22:55:04 +08:00

1306 lines
37 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, 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.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);
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 ? () => onLongPress(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 ? () => onLongPress(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),
),
),
Positioned(
bottom: 0,
right: 0,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 4),
child: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0),
bottomRight: Radius.circular(10.0),
bottomLeft: Radius.circular(10.0),
),
child: Container(
color: Colors.black.withOpacity(0.5),
child: Padding(
padding:
const EdgeInsets.fromLTRB(8, 6, 8, 6),
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constraints.maxWidth * 0.88,
),
child: Text(
comic.description.isEmpty
? comic.subtitle
?.replaceAll('\n', '') ??
''
: comic.description
.split('|')
.join('\n'),
style: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 12,
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,
fontSize: 14.0,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
},
));
}
void block(BuildContext comicTileContext) {
showDialog(
context: App.rootContext,
builder: (context) {
var words = <String>[];
var all = <String>[];
all.addAll(comic.title.split(' ').where((element) => element != ''));
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.withOpacity(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.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;
@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,
);
}
}
class _SliverGridComics extends StatelessWidget {
const _SliverGridComics({
required this.comics,
this.onLastItemBuild,
this.badgeBuilder,
this.menuBuilder,
this.onTap,
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;
@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,
);
if(selection == null) {
return comic;
}
return Container(
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.surfaceContainer
: 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,
});
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 (!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();
}
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;
}
}