diff --git a/lib/widgets/feed.dart b/lib/widgets/feed.dart index 64f7b4b..4e0b64d 100644 --- a/lib/widgets/feed.dart +++ b/lib/widgets/feed.dart @@ -4,22 +4,51 @@ import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:solaragent/models/feed.dart'; import 'package:solaragent/widgets/image.dart'; import 'package:solaragent/widgets/posts/comments.dart'; +import 'package:solaragent/widgets/posts/reactions.dart'; -class FeedItem extends StatelessWidget { +class FeedItem extends StatefulWidget { final Feed item; const FeedItem({super.key, required this.item}); + @override + State createState() => _FeedItemState(); +} + +class _FeedItemState extends State { + int reactionCount = 0; + Map? reactionList; + void viewComments(BuildContext context) { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (context) => CommentListWidget(parent: item), + builder: (context) => CommentList(parent: widget.item), + ); + } + + void viewReactions(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => ReactionList( + parent: widget.item, + onReact: (symbol, num) { + setState(() { + if (!reactionList!.containsKey(symbol)) { + reactionList![symbol] = 0; + } + + reactionCount += num; + reactionList![symbol] += num; + }); + }, + ), ); } bool hasAttachments() => - item.attachments != null && item.attachments!.isNotEmpty; + widget.item.attachments != null && widget.item.attachments!.isNotEmpty; String getDescription(String desc) => desc.isEmpty ? "No description yet." : desc; @@ -27,6 +56,13 @@ class FeedItem extends StatelessWidget { String getFileUrl(String fileId) => 'https://co.solsynth.dev/api/attachments/o/$fileId'; + @override + void initState() { + reactionCount = widget.item.reactionCount; + reactionList = widget.item.reactionList ?? {}; + super.initState(); + } + @override Widget build(BuildContext context) { return Column( @@ -35,12 +71,12 @@ class FeedItem extends StatelessWidget { Container( color: Colors.grey[50], child: ListTile( - title: Text(item.author.name), + title: Text(widget.item.author.name), leading: CircleAvatar( - backgroundImage: NetworkImage(item.author.avatar), + backgroundImage: NetworkImage(widget.item.author.avatar), ), subtitle: Text( - getDescription(item.author.description), + getDescription(widget.item.author.description), overflow: TextOverflow.ellipsis, maxLines: 1, softWrap: false, @@ -49,7 +85,7 @@ class FeedItem extends StatelessWidget { ), // Content Markdown( - data: item.content, + data: widget.item.content, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), ), @@ -67,7 +103,7 @@ class FeedItem extends StatelessWidget { showIndicator: true, slideIndicator: const CircularSlideIndicator(), ), - items: item.attachments?.map((x) { + items: widget.item.attachments?.map((x) { return Builder( builder: (BuildContext context) { return SizedBox( @@ -104,9 +140,15 @@ class FeedItem extends StatelessWidget { children: [ TextButton.icon( icon: const Icon(Icons.comment), - label: Text(item.commentCount.toString()), + label: Text(widget.item.commentCount.toString()), onPressed: () => viewComments(context), - ) + ), + TextButton.icon( + icon: const Icon(Icons.emoji_emotions), + label: Text(reactionCount.toString()), + style: TextButton.styleFrom(foregroundColor: Colors.teal), + onPressed: () => viewReactions(context), + ), ], ), ), diff --git a/lib/widgets/posts/comments.dart b/lib/widgets/posts/comments.dart index 5af1f17..9bf5eed 100644 --- a/lib/widgets/posts/comments.dart +++ b/lib/widgets/posts/comments.dart @@ -9,16 +9,16 @@ import 'package:solaragent/models/pagination.dart'; import 'package:solaragent/router.dart'; import 'package:solaragent/widgets/feed.dart'; -class CommentListWidget extends StatefulWidget { +class CommentList extends StatefulWidget { final Feed parent; - const CommentListWidget({super.key, required this.parent}); + const CommentList({super.key, required this.parent}); @override - State createState() => _CommentListWidgetState(); + State createState() => _CommentListState(); } -class _CommentListWidgetState extends State { +class _CommentListState extends State { static const pageSize = 5; final client = Client(); diff --git a/lib/widgets/posts/reactions.dart b/lib/widgets/posts/reactions.dart new file mode 100644 index 0000000..46b5040 --- /dev/null +++ b/lib/widgets/posts/reactions.dart @@ -0,0 +1,169 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:solaragent/auth.dart'; +import 'package:solaragent/models/feed.dart'; + +class ReactionList extends StatefulWidget { + static const Map> emojis = { + 'thumb_up': {'icon': '👍', 'attitude': 1}, + 'clap': {'icon': '👏', 'attitude': 1} + }; + + final Feed parent; + final void Function(String symbol, int num) onReact; + + const ReactionList({super.key, required this.parent, required this.onReact}); + + @override + State createState() => _ReactionListState(); +} + +class _ReactionListState extends State { + bool isSubmitting = false; + + void viewReactMenu(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (context) { + return ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: ReactionList.emojis.length, + itemBuilder: (BuildContext context, int index) { + var element = ReactionList.emojis.entries.toList()[index]; + return InkWell( + borderRadius: const BorderRadius.all( + Radius.circular(64), + ), + onTap: () async { + await doReact(element.key, element.value['attitude']); + }, + child: ListTile( + title: Text(element.value['icon']), + subtitle: Text( + ":${element.key}:", + style: const TextStyle(fontFamily: "monospace"), + ), + ), + ); + }); + }, + ); + } + + Future doReact(String symbol, int attitude) async { + if (!await authClient.isAuthorized()) return; + + var dataset = "${widget.parent.modelType}s"; + var alias = widget.parent.id; + + var uri = Uri.parse( + "https://co.solsynth.dev/api/p/$dataset/$alias/react", + ); + + setState(() => isSubmitting = true); + var res = await authClient.client!.post( + uri, + headers: { + 'Content-Type': 'application/json', + }, + body: jsonEncode({ + 'symbol': symbol, + 'attitude': attitude, + }), + ); + if (res.statusCode == 201) { + widget.onReact(symbol, 1); + } else if (res.statusCode == 204) { + widget.onReact(symbol, -1); + } else { + var message = utf8.decode(res.bodyBytes); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Something went wrong... $message")), + ); + } + setState(() => isSubmitting = false); + } + + List> getReactionEntries() => + widget.parent.reactionList?.entries.toList() ?? List.empty(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + // Title + Container( + padding: const EdgeInsets.only(left: 10, right: 10, top: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 12.0, + ), + child: Text( + 'Reactions', + style: Theme + .of(context) + .textTheme + .headlineSmall, + ), + ), + FutureBuilder( + future: authClient.isAuthorized(), + builder: (context, snapshot) { + if (snapshot.hasData && snapshot.data == true) { + return TextButton.icon( + icon: const Icon(Icons.add_reaction), + label: const Text("REACT"), + onPressed: + isSubmitting ? null : () => viewReactMenu(context), + ); + } else { + return Container(); + } + }, + ), + ], + ), + ), + // Loading indicator + isSubmitting ? const LinearProgressIndicator() : Container(), + // Data list + Expanded( + child: ListView.separated( + itemCount: getReactionEntries().length, + itemBuilder: (BuildContext context, int index) { + var element = getReactionEntries()[index]; + return InkWell( + onTap: isSubmitting + ? null + : () { + doReact( + element.key, + ReactionList.emojis[element.key]!['attitude'], + ); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6.0), + child: ListTile( + title: Text( + "${ReactionList.emojis[element.key]!['icon']} x${element + .value.toString()}", + ), + subtitle: Text( + ":${element.key}:", + style: const TextStyle(fontFamily: "monospace"), + ), + ), + )); + }, + separatorBuilder: (context, index) => const Divider(), + ), + ), + ], + ); + } +}