diff --git a/lib/components/illust_widget.dart b/lib/components/illust_widget.dart index 89980a3..61462cd 100644 --- a/lib/components/illust_widget.dart +++ b/lib/components/illust_widget.dart @@ -2,46 +2,124 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:pixes/components/animated_image.dart'; import 'package:pixes/foundation/app.dart'; import 'package:pixes/foundation/image_provider.dart'; -import 'package:pixes/network/models.dart'; +import '../network/network.dart'; import '../pages/illust_page.dart'; +import 'md.dart'; -class IllustWidget extends StatelessWidget { +class IllustWidget extends StatefulWidget { const IllustWidget(this.illust, {super.key}); final Illust illust; + @override + State createState() => _IllustWidgetState(); +} + +class _IllustWidgetState extends State { + bool isBookmarking = false; + @override Widget build(BuildContext context) { return LayoutBuilder(builder: (context, constrains) { final width = constrains.maxWidth; - final height = illust.height * width / illust.width; - return Container( + final height = widget.illust.height * width / widget.illust.width; + return SizedBox( width: width, height: height, - padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), - child: Card( - padding: EdgeInsets.zero, - margin: EdgeInsets.zero, - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: (){ - context.to(() => IllustPage(illust)); - }, - child: ClipRRect( - borderRadius: BorderRadius.circular(4.0), - child: AnimatedImage( - image: CachedImageProvider(illust.images.first.medium), - fit: BoxFit.cover, - width: width-16.0, - height: height-16.0, + child: Stack( + children: [ + Positioned.fill(child: Container( + width: width, + height: height, + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0), + child: Card( + padding: EdgeInsets.zero, + margin: EdgeInsets.zero, + child: GestureDetector( + onTap: (){ + context.to(() => IllustPage(widget.illust, favoriteCallback: (v) { + setState(() { + widget.illust.isBookmarked = v; + }); + },)); + }, + child: ClipRRect( + borderRadius: BorderRadius.circular(4.0), + child: AnimatedImage( + image: CachedImageProvider(widget.illust.images.first.medium), + fit: BoxFit.cover, + width: width-16.0, + height: height-16.0, + ), + ), ), ), - ), - ), + )), + Positioned( + top: 16, + right: 16, + child: buildButton(), + ) + ], ), ); }); } + + Widget buildButton() { + void favorite() async{ + if(isBookmarking) return; + setState(() { + isBookmarking = true; + }); + var method = widget.illust.isBookmarked ? "delete" : "add"; + var res = await Network().addBookmark(widget.illust.id.toString(), method); + if(res.error) { + if(mounted) { + context.showToast(message: "Network Error"); + } + } else { + widget.illust.isBookmarked = !widget.illust.isBookmarked; + } + setState(() { + isBookmarking = false; + }); + } + + Widget child; + if(isBookmarking) { + child = const SizedBox( + width: 14, + height: 14, + child: ProgressRing(strokeWidth: 1.6,), + ); + } else if(widget.illust.isBookmarked) { + child = Icon( + MdIcons.favorite, + color: ColorScheme.of(context).error, + size: 22, + ); + } else { + child = Icon( + MdIcons.favorite, + color: ColorScheme.of(context).outline, + size: 22, + ); + } + + return SizedBox( + height: 24, + width: 24, + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: favorite, + child: Center( + child: child, + ), + ), + ), + ); + } } diff --git a/lib/network/models.dart b/lib/network/models.dart index 82042bc..2cc9e6d 100644 --- a/lib/network/models.dart +++ b/lib/network/models.dart @@ -342,3 +342,44 @@ class UserPreview { isFollowed = json['is_followed'], isBlocking = json['is_access_blocking_user'] ?? false; } + +/* +{ + "id": 176418447, + "comment": "", + "date": "2024-05-13T19:28:13+09:00", + "user": { + "id": 54898889, + "name": "Rorigod", + "account": "user_gjzr2787", + "profile_image_urls": { + "medium": "https://i.pximg.net/user-profile/img/2021/09/01/00/46/58/21334581_94fac3456245d2b680ecf1c60aba2c95_170.png" + } + }, + "has_replies": false, + "stamp": { + "stamp_id": 407, + "stamp_url": "https://s.pximg.net/common/images/stamp/generated-stamps/407_s.jpg?20180605" + } + } + */ +class Comment{ + final String id; + final String comment; + final DateTime date; + final String uid; + final String name; + final String avatar; + final bool hasReplies; + final String? stampUrl; + + Comment.fromJson(Map json) + : id = json['id'].toString(), + comment = json['comment'], + date = DateTime.parse(json['date']), + uid = json['user']['id'].toString(), + name = json['user']['name'], + avatar = json['user']['profile_image_urls']['medium'], + hasReplies = json['has_replies'], + stampUrl = json['stamp']?['stamp_url']; +} diff --git a/lib/network/network.dart b/lib/network/network.dart index f4bab25..20a48bf 100644 --- a/lib/network/network.dart +++ b/lib/network/network.dart @@ -370,4 +370,25 @@ class Network { return Res.error(res.errorMessage); } } + + Future>> getComments(String id, [String? nextUrl]) async { + var res = await apiGet(nextUrl ?? "/v3/illust/comments?illust_id=$id"); + if (res.success) { + return Res( + (res.data["comments"] as List).map((e) => Comment.fromJson(e)).toList(), + subData: res.data["next_url"]); + } else { + return Res.error(res.errorMessage); + } + } + + Future> comment(String id, String content) async { + var res = await apiPost("/v1/illust/comment/add", + data: {"illust_id": id, "comment": content}); + if (res.success) { + return const Res(true); + } else { + return Res.fromErrorRes(res); + } + } } diff --git a/lib/pages/illust_page.dart b/lib/pages/illust_page.dart index 6623a97..a1e63c5 100644 --- a/lib/pages/illust_page.dart +++ b/lib/pages/illust_page.dart @@ -1,6 +1,9 @@ import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/material.dart' show Icons; import 'package:pixes/components/animated_image.dart'; +import 'package:pixes/components/loading.dart'; +import 'package:pixes/components/message.dart'; +import 'package:pixes/components/page_route.dart'; import 'package:pixes/foundation/app.dart'; import 'package:pixes/foundation/image_provider.dart'; import 'package:pixes/network/download.dart'; @@ -15,10 +18,12 @@ import '../components/md.dart'; const _kBottomBarHeight = 64.0; class IllustPage extends StatefulWidget { - const IllustPage(this.illust, {super.key}); + const IllustPage(this.illust, {this.favoriteCallback, super.key}); final Illust illust; + final void Function(bool)? favoriteCallback; + @override State createState() => _IllustPageState(); } @@ -41,7 +46,12 @@ class _IllustPageState extends State { top: 0, child: buildBody(constrains.maxWidth, constrains.maxHeight), ), - _BottomBar(widget.illust, constrains.maxHeight, constrains.maxWidth), + _BottomBar( + widget.illust, + constrains.maxHeight, + constrains.maxWidth, + favoriteCallback: widget.favoriteCallback, + ), ], ); }), @@ -105,7 +115,9 @@ class _IllustPageState extends State { } class _BottomBar extends StatefulWidget { - const _BottomBar(this.illust, this.height, this.width); + const _BottomBar(this.illust, this.height, this.width, {this.favoriteCallback}); + + final void Function(bool)? favoriteCallback; final Illust illust; @@ -335,6 +347,7 @@ class _BottomBarState extends State<_BottomBar> { } } else { widget.illust.isBookmarked = !widget.illust.isBookmarked; + widget.favoriteCallback?.call(widget.illust.isBookmarked); } setState(() { isBookmarking = false; @@ -406,7 +419,7 @@ class _BottomBarState extends State<_BottomBar> { yield const SizedBox(width: 8,); yield Button( - onPressed: favorite, + onPressed: () => _CommentsPage.show(context, widget.illust.id.toString()), child: SizedBox( height: 28, child: Row( @@ -502,3 +515,162 @@ class _BottomBarState extends State<_BottomBar> { ).paddingVertical(8).paddingHorizontal(2); } } + +class _CommentsPage extends StatefulWidget { + const _CommentsPage(this.id); + + final String id; + + static void show(BuildContext context, String id) { + Navigator.of(context).push(SideBarRoute(_CommentsPage(id))); + } + + @override + State<_CommentsPage> createState() => _CommentsPageState(); +} + +class _CommentsPageState extends MultiPageLoadingState<_CommentsPage, Comment> { + bool isCommenting = false; + + @override + Widget buildContent(BuildContext context, List data) { + return Stack( + children: [ + Positioned.fill(child: buildBody(context, data)), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: buildBottom(context), + ) + ], + ); + } + + Widget buildBody(BuildContext context, List data) { + return ListView.builder( + itemCount: data.length + 2, + itemBuilder: (context, index) { + if(index == 0) { + return Text("Comments".tl, style: const TextStyle(fontSize: 20)).paddingVertical(8).paddingHorizontal(12); + } else if(index == data.length + 1) { + return const SizedBox(height: 64,); + } + index--; + var date = data[index].date; + var dateText = "${date.year}/${date.month}/${date.day}"; + return Card( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + margin: const EdgeInsets.symmetric(vertical: 4, horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + SizedBox( + height: 38, + width: 38, + child: ClipRRect( + borderRadius: BorderRadius.circular(38), + child: ColoredBox( + color: ColorScheme.of(context).secondaryContainer, + child: GestureDetector( + onTap: () => context.to(() => UserInfoPage(data[index].id.toString())), + child: AnimatedImage( + image: CachedImageProvider(data[index].avatar), + width: 38, + height: 38, + fit: BoxFit.cover, + filterQuality: FilterQuality.medium, + ), + ), + ), + ), + ), + const SizedBox(width: 8,), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(data[index].name, style: const TextStyle(fontSize: 14),), + Text(dateText, style: TextStyle(fontSize: 12, color: ColorScheme.of(context).outline),) + ], + ) + ], + ), + const SizedBox(height: 8,), + if(data[index].comment.isNotEmpty) + Text(data[index].comment, style: const TextStyle(fontSize: 16),), + if(data[index].stampUrl != null) + SizedBox( + height: 64, + width: 64, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: AnimatedImage( + image: CachedImageProvider(data[index].stampUrl!), + width: 64, + height: 64, + fit: BoxFit.cover, + ), + ), + ) + ], + ), + ); + } + ); + } + + Widget buildBottom(BuildContext context) { + return Card( + padding: EdgeInsets.zero, + backgroundColor: FluentTheme.of(context).micaBackgroundColor.withOpacity(0.96), + child: SizedBox( + height: 52, + child: TextBox( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + placeholder: "Comment".tl, + foregroundDecoration: BoxDecoration( + border: Border.all(color: Colors.transparent), + ), + onSubmitted: (s) { + showToast(context, message: "Sending".tl); + if(isCommenting) return; + setState(() { + isCommenting = true; + }); + Network().comment(widget.id, s).then((value) { + if(value.error) { + context.showToast(message: "Network Error"); + setState(() { + isCommenting = false; + }); + } else { + isCommenting = false; + nextUrl = null; + reset(); + } + }); + }, + ).paddingVertical(8).paddingHorizontal(12), + ), + ); + } + + String? nextUrl; + + @override + Future>> loadData(int page) async{ + if(nextUrl == "end") { + return Res.error("No more data"); + } + var res = await Network().getComments(widget.id, nextUrl); + if(!res.error) { + nextUrl = res.subData; + nextUrl ??= "end"; + } + return res; + } + +} +