diff --git a/README.md b/README.md index 18f2df6..5d23d4c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,8 @@ Aplicativo TabNews feito com muito ♥️ e Flutter pela comunidade, para o site - [x] Meus conteúdos - [x] Gerencimaneto de conta - [x] Criação de conta pelo App -- [ ] Resposta dos conteúdos +- [x] Resposta dos conteúdos +- [x] Interação com Tabcoins - [x] Postagens de conteúdos - [x] Visualização do perfil de outros usuários - [x] Favoritos (local database) diff --git a/lib/src/controllers/comment.dart b/lib/src/controllers/comment.dart new file mode 100644 index 0000000..3bfa783 --- /dev/null +++ b/lib/src/controllers/comment.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import 'package:tabnews/src/controllers/app.dart'; +import 'package:tabnews/src/interfaces/view_action.dart'; +import 'package:tabnews/src/services/content.dart'; +import 'package:tabnews/src/services/http_response.dart'; + +class CommentController { + final ViewAction _view; + final ContentService _contentService = ContentService(); + + final ValueNotifier _isLoading = ValueNotifier(false); + ValueNotifier get isLoading => _isLoading; + + CommentController(this._view); + + void create(String body, String parentId) async { + if (body.isEmpty) { + _view.onError(message: 'É necessário preencher o campo obrigatório!'); + + return; + } + + _setLoading(true); + final HttpResponse content = await _contentService.postComment( + AppController.auth.value, + parentId, + body, + ); + + if (content.ok) { + _view.onSuccess(); + } else { + _view.onError(message: content.message); + } + + _setLoading(false); + } + + void _setLoading(bool value) { + _isLoading.value = value; + } +} diff --git a/lib/src/controllers/content.dart b/lib/src/controllers/content.dart index 7d39fea..0419570 100644 --- a/lib/src/controllers/content.dart +++ b/lib/src/controllers/content.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:tabnews/src/controllers/app.dart'; import 'package:tabnews/src/interfaces/view_action.dart'; +import 'package:tabnews/src/models/content.dart'; import 'package:tabnews/src/services/content.dart'; import 'package:tabnews/src/services/http_response.dart'; @@ -41,4 +42,31 @@ class ContentController { void _setLoading(bool value) { _isLoading.value = value; } + + void getContent(String slug) async { + _setLoading(true); + var contentResp = await _contentService.fetchContent(slug); + + if (contentResp.ok) { + if (contentResp.data['parent_id'] != null) { + contentResp.data['parent'] = await _getParentContent(slug); + } + + _view.onSuccess(data: contentResp.data); + } else { + _view.onError(message: contentResp.message); + } + + _setLoading(false); + } + + Future _getParentContent(String slug) async { + var parentContent = await _contentService.fetchContentParent(slug); + + if (parentContent.ok) { + return Content.fromJson(parentContent.data); + } else { + return null; + } + } } diff --git a/lib/src/models/content.dart b/lib/src/models/content.dart index be80c06..7b959ac 100644 --- a/lib/src/models/content.dart +++ b/lib/src/models/content.dart @@ -14,6 +14,7 @@ class Content { String? ownerUsername; int? tabcoins; int? childrenDeepCount; + Content? parent; Content({ this.id, @@ -31,6 +32,7 @@ class Content { this.ownerUsername, this.tabcoins, this.childrenDeepCount, + this.parent, }); Content.fromJson(Map json) { @@ -49,6 +51,7 @@ class Content { tabcoins = json['tabcoins']; ownerUsername = json['owner_username']; childrenDeepCount = json['children_deep_count']; + parent = json['parent']; } Map toJson() { @@ -69,6 +72,7 @@ class Content { data['tabcoins'] = tabcoins; data['owner_username'] = ownerUsername; data['children_deep_count'] = childrenDeepCount; + data['parent'] = parent; return data; } diff --git a/lib/src/services/content.dart b/lib/src/services/content.dart index 5c1dacc..7841e9c 100644 --- a/lib/src/services/content.dart +++ b/lib/src/services/content.dart @@ -47,14 +47,16 @@ class ContentService { } } - Future fetchContent(String slug) async { + Future fetchContent(String slug) async { final response = await http.get(Uri.parse('$apiUrl/$slug')); - if (response.statusCode == 200) { - return Content.fromJson(jsonDecode(response.body)); - } else { - throw Exception('Failed to load singular content'); - } + return HttpResponse(response.statusCode, response.body); + } + + Future fetchContentParent(String slug) async { + final response = await http.get(Uri.parse('$apiUrl/$slug/parent')); + + return HttpResponse(response.statusCode, response.body); } Future> fetchContentComments(String slug) async { @@ -117,7 +119,29 @@ class ContentService { }), ); - return HttpResponse(response.statusCode, response.body); + return HttpResponse(response.statusCode, response.body); + } + + Future postComment( + String token, + String parentId, + String body, + ) async { + final response = await http.post( + Uri.parse(apiUrl), + headers: { + 'Set-Cookie': 'session_id=$token', + 'Cookie': 'session_id=$token', + 'Content-Type': 'application/json; charset=UTF-8', + }, + body: jsonEncode({ + 'parent_id': parentId, + 'body': body, + 'status': 'published', + }), + ); + + return HttpResponse(response.statusCode, response.body); } Future postTabcoins(String slug, String type) async { @@ -133,6 +157,8 @@ class ContentService { }), ); + AppController.updateUser(); + return HttpResponse(response.statusCode, response.body); } } diff --git a/lib/src/ui/pages/content.dart b/lib/src/ui/pages/content.dart index 05b8a80..85e2f7c 100644 --- a/lib/src/ui/pages/content.dart +++ b/lib/src/ui/pages/content.dart @@ -1,12 +1,16 @@ import 'package:flutter/material.dart'; -import 'package:tabnews/src/ui/widgets/tabcoins.dart'; import 'package:timeago/timeago.dart' as timeago; +import 'package:tabnews/src/controllers/app.dart'; +import 'package:tabnews/src/controllers/content.dart'; +import 'package:tabnews/src/interfaces/view_action.dart'; +import 'package:tabnews/src/services/content.dart'; +import 'package:tabnews/src/ui/widgets/answer.dart'; +import 'package:tabnews/src/ui/widgets/source_url.dart'; +import 'package:tabnews/src/ui/widgets/tabcoins.dart'; import 'package:tabnews/src/controllers/favorites.dart'; -import 'package:tabnews/src/utils/open_link.dart'; import 'package:tabnews/src/extensions/dark_mode.dart'; import 'package:tabnews/src/models/content.dart'; -import 'package:tabnews/src/services/content.dart'; import 'package:tabnews/src/ui/widgets/markdown.dart'; import 'package:tabnews/src/ui/widgets/comments.dart'; import 'package:tabnews/src/ui/layouts/page.dart'; @@ -26,32 +30,65 @@ class ContentPage extends StatefulWidget { State createState() => _ContentPageState(); } -class _ContentPageState extends State { +class _ContentPageState extends State implements ViewAction { final FavoritesController _favoritesController = FavoritesController(); + late ContentController _contentController; Content content = Content.fromJson({}); final _contentService = ContentService(); final ScrollController _controller = ScrollController(); bool isLoading = true; + Key _refreshKey = UniqueKey(); + + String get slug => '${widget.username}/${widget.slug}'; + @override void initState() { super.initState(); + _contentController = ContentController(this); _getContent(); } - Future _getContent() async { - var contentResp = await _contentService.fetchContent( - '${widget.username}/${widget.slug}', + @override + onError({required String message}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + ), ); + } + @override + onSuccess({data}) { setState(() { - content = contentResp; + content = Content.fromJson(data); isLoading = false; }); } + _getContent() { + _contentController.getContent(slug); + } + + String _getTitleParent(Content parent) { + if (content.parentId != null) { + String body = parent.body!; + String title = '...'; + + if (body.length < 50) { + title = body.replaceRange(body.length, body.length, '...'); + } else { + title = body.replaceRange(50, body.length, '...'); + } + + return 'Respondendo a "$title"'; + } else { + return 'Em resposta a "${parent.title}"'; + } + } + _tabcoins(String vote) async { var tabcoinsResp = await _contentService.postTabcoins( '${widget.username}/${widget.slug}', @@ -75,109 +112,103 @@ class _ContentPageState extends State { ); } + void _onAnswer() { + setState(() { + _refreshKey = UniqueKey(); + }); + } + @override Widget build(BuildContext context) { timeago.setLocaleMessages('pt-BR', timeago.PtBrMessages()); return PageLayout( - onRefresh: _getContent, - actions: isLoading - ? null - : [ - IconButton( - onPressed: () => _favoritesController.toggle( - content, - ), - icon: ValueListenableBuilder( - valueListenable: _favoritesController.favorites, - builder: (context, favorites, child) { - bool isFavorited = favorites - .where((element) => element.id == content.id) - .isNotEmpty; - - return Icon( - isFavorited ? Icons.favorite : Icons.favorite_border, - ); - }, - ), - ), - ], + onRefresh: () => _getContent(), + actions: _buildActions(), body: isLoading ? const AppProgressIndicator() - : Padding( + : SingleChildScrollView( + controller: _controller, padding: const EdgeInsets.all(10.0), - child: CustomScrollView( - controller: _controller, - slivers: [ - SliverToBoxAdapter( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '${content.ownerUsername} · ${timeago.format(DateTime.parse(content.publishedAt!), locale: "pt-BR")}', + style: const TextStyle().copyWith( + color: context.isDarkMode + ? Colors.grey.shade400 + : Colors.grey.shade700, + ), + ), + const Spacer(), + ValueListenableBuilder( + valueListenable: AppController.isLoggedIn, + builder: (context, isLoggedIn, _) { + if (!isLoggedIn) { + return const SizedBox(); + } + + return Tabcoins( + upvote: () => _tabcoins('upvote'), + tabcoins: '${content.tabcoins}', + downvote: () => _tabcoins('downvote'), + ); + }, + ), + ], + ), + const SizedBox(height: 10.0), + Text( + content.parent != null + ? _getTitleParent(content.parent!) + : '${content.title}', + style: const TextStyle().copyWith( + fontSize: 18.0, + fontWeight: FontWeight.w700, + ), + ), + MarkedownReader( + body: '${content.body}', + controller: _controller, + ), + content.sourceUrl != null + ? SourceUrl(sourceUrl: content.sourceUrl!) + : const SizedBox(), + ValueListenableBuilder( + valueListenable: AppController.isLoggedIn, + builder: (context, isLoggedIn, child) { + if (isLoggedIn) { + return Column( children: [ - Text( - '${content.ownerUsername} · ${timeago.format(DateTime.parse(content.publishedAt!), locale: "pt-BR")}', - style: const TextStyle().copyWith( - color: context.isDarkMode - ? Colors.grey.shade400 - : Colors.grey.shade700, - ), - ), - const Spacer(), - Tabcoins( - upvote: () => _tabcoins('upvote'), - tabcoins: '${content.tabcoins}', - downvote: () => _tabcoins('downvote'), + const SizedBox(height: 30.0), + Answer( + parentId: content.id!, + onAnswer: _onAnswer, ), + const SizedBox(height: 30.0), ], - ), - const SizedBox(height: 10.0), - Text( - content.parentId != null - ? 'Em resposta a...' - : '${content.title}', - style: const TextStyle().copyWith( - fontSize: 18.0, - fontWeight: FontWeight.w700, - ), - ), - MarkedownReader( - body: '${content.body}', - controller: _controller, - ), - content.sourceUrl != null - ? Row( - children: [ - Text( - 'Fonte: ', - style: const TextStyle().copyWith( - fontWeight: FontWeight.w700, - ), - ), - InkWell( - onTap: () => OpenLink.open( - content.sourceUrl, - context, - ), - child: Text( - '${content.sourceUrl}', - style: const TextStyle().copyWith( - color: Colors.blue, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ) - : const SizedBox(), - const SizedBox(height: 30.0), - const Divider(), - const SizedBox(height: 30.0), - ], - ), + ); + } else { + return Column( + children: const [ + SizedBox(height: 30.0), + Divider(), + SizedBox(height: 30.0), + ], + ); + } + }, ), - SliverFillRemaining( + SizedBox( + height: content.parent != null + ? double + .minPositive // TODO: Aqui tem que verificar para ficar melhor + : double.maxFinite, child: CommentsRootWidget( + key: _refreshKey, slug: '${widget.username}/${widget.slug}', controller: _controller, ), @@ -187,4 +218,28 @@ class _ContentPageState extends State { ), ); } + + List? _buildActions() { + return isLoading + ? null + : [ + IconButton( + onPressed: () => _favoritesController.toggle( + content, + ), + icon: ValueListenableBuilder( + valueListenable: _favoritesController.favorites, + builder: (context, favorites, child) { + bool isFavorited = favorites + .where((element) => element.id == content.id) + .isNotEmpty; + + return Icon( + isFavorited ? Icons.favorite : Icons.favorite_border, + ); + }, + ), + ), + ]; + } } diff --git a/lib/src/ui/widgets/answer.dart b/lib/src/ui/widgets/answer.dart new file mode 100644 index 0000000..fff04b0 --- /dev/null +++ b/lib/src/ui/widgets/answer.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:markdown_editable_textinput/format_markdown.dart'; +import 'package:markdown_editable_textinput/markdown_text_input.dart'; + +import 'package:tabnews/src/controllers/comment.dart'; +import 'package:tabnews/src/extensions/dark_mode.dart'; +import 'package:tabnews/src/interfaces/view_action.dart'; +import 'package:tabnews/src/ui/widgets/markdown.dart'; + +class Answer extends StatefulWidget { + final bool inComment; + final String parentId; + final void Function() onAnswer; + + const Answer({ + super.key, + this.inComment = false, + required this.parentId, + required this.onAnswer, + }); + + @override + State createState() => _AnswerState(); +} + +class _AnswerState extends State implements ViewAction { + late CommentController _commentController; + + bool isAnswering = false; + bool isViewMarkdown = false; + + TextEditingController bodyTextController = TextEditingController(); + + @override + void initState() { + super.initState(); + + _commentController = CommentController(this); + } + + @override + onSuccess({data}) { + bodyTextController.clear(); + + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Comentário publicado com sucesso!', + ), + ), + ); + + setState(() { + isAnswering = false; + }); + + widget.onAnswer(); + } + + @override + onError({required String message}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + ), + ); + } + + @override + void dispose() { + bodyTextController.dispose(); + + super.dispose(); + } + + void cancel() { + bodyTextController.clear(); + + setState(() { + isAnswering = false; + }); + } + + @override + Widget build(BuildContext context) { + if (widget.inComment) { + return _buildAnswer(); + } + + return Container( + padding: const EdgeInsets.all(10.0), + decoration: BoxDecoration( + border: Border.all( + color: + context.isDarkMode ? Colors.grey.shade700 : Colors.grey.shade300, + ), + borderRadius: BorderRadius.circular(5.0), + ), + child: _buildAnswer(), + ); + } + + Widget _buildAnswer() { + return isAnswering + ? Column( + children: [ + isViewMarkdown + ? SizedBox( + height: 420.0, + child: MarkedownReader(body: bodyTextController.text), + ) + : MarkdownTextInput( + maxLines: 20, + label: 'Comentário', + controller: bodyTextController, + actions: MarkdownType.values, + (value) {}, + bodyTextController.text, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + TextButton( + onPressed: () { + setState(() { + isViewMarkdown = !isViewMarkdown; + }); + }, + child: Text(isViewMarkdown ? 'Escrever' : 'Visualizar'), + ), + const Spacer(), + TextButton( + onPressed: cancel, + child: const Text('Cancelar'), + ), + const SizedBox(width: 5.0), + ElevatedButton( + onPressed: () => _commentController.create( + bodyTextController.text, + widget.parentId, + ), + child: const Text('Publicar'), + ), + ], + ), + ], + ) + : Row( + children: [ + ElevatedButton( + onPressed: () { + setState(() { + isAnswering = true; + }); + }, + child: const Text('Responder'), + ), + ], + ); + } +} diff --git a/lib/src/ui/widgets/comments.dart b/lib/src/ui/widgets/comments.dart index 49bc315..2e9b131 100644 --- a/lib/src/ui/widgets/comments.dart +++ b/lib/src/ui/widgets/comments.dart @@ -23,6 +23,8 @@ class _CommentsRootWidgetState extends State { List comments = []; final api = ContentService(); + Key _refreshKey = UniqueKey(); + @override void initState() { super.initState(); @@ -38,6 +40,12 @@ class _CommentsRootWidgetState extends State { }); } + void _onAnswer() { + setState(() { + _refreshKey = UniqueKey(); + }); + } + @override Widget build(BuildContext context) { timeago.setLocaleMessages('pt-BR', timeago.PtBrMessages()); @@ -47,8 +55,10 @@ class _CommentsRootWidgetState extends State { itemCount: comments.length, separatorBuilder: (context, index) => const Divider(), itemBuilder: (context, index) => ItemComment( + key: _refreshKey, comment: comments[index], controller: widget.controller, + onAnswer: _onAnswer, ), ); } diff --git a/lib/src/ui/widgets/comments_children.dart b/lib/src/ui/widgets/comments_children.dart index 653de3b..11dfc47 100644 --- a/lib/src/ui/widgets/comments_children.dart +++ b/lib/src/ui/widgets/comments_children.dart @@ -8,11 +8,13 @@ import 'package:tabnews/src/ui/widgets/item_comment.dart'; class CommentsWidget extends StatefulWidget { final List comments; final ScrollController controller; + final void Function() onAnswer; const CommentsWidget({ super.key, required this.comments, required this.controller, + required this.onAnswer, }); @override @@ -34,6 +36,7 @@ class _CommentsWidgetState extends State { itemBuilder: (context, index) => ItemComment( comment: widget.comments[index], controller: widget.controller, + onAnswer: widget.onAnswer, ), ); } diff --git a/lib/src/ui/widgets/item_comment.dart b/lib/src/ui/widgets/item_comment.dart index e4e6917..2af367f 100644 --- a/lib/src/ui/widgets/item_comment.dart +++ b/lib/src/ui/widgets/item_comment.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:timeago/timeago.dart' as timeago; + +import 'package:tabnews/src/controllers/app.dart'; import 'package:tabnews/src/services/content.dart'; import 'package:tabnews/src/ui/pages/profile_user.dart'; +import 'package:tabnews/src/ui/widgets/answer.dart'; import 'package:tabnews/src/ui/widgets/comments_children.dart'; import 'package:tabnews/src/ui/widgets/tabcoins.dart'; import 'package:tabnews/src/utils/navigation.dart'; -import 'package:timeago/timeago.dart' as timeago; - import 'package:tabnews/src/extensions/dark_mode.dart'; import 'package:tabnews/src/models/comment.dart'; import 'package:tabnews/src/ui/widgets/markdown.dart'; @@ -13,11 +15,13 @@ import 'package:tabnews/src/ui/widgets/markdown.dart'; class ItemComment extends StatefulWidget { final Comment comment; final ScrollController controller; + final void Function() onAnswer; const ItemComment({ super.key, required this.comment, required this.controller, + required this.onAnswer, }); @override @@ -81,10 +85,19 @@ class _ItemCommentState extends State { ), ), const Spacer(), - Tabcoins( - upvote: () => _tabcoins('upvote'), - tabcoins: '${comment.tabcoins}', - downvote: () => _tabcoins('downvote'), + ValueListenableBuilder( + valueListenable: AppController.isLoggedIn, + builder: (context, isLoggedIn, _) { + if (!isLoggedIn) { + return const SizedBox(); + } + + return Tabcoins( + upvote: () => _tabcoins('upvote'), + tabcoins: '${comment.tabcoins}', + downvote: () => _tabcoins('downvote'), + ); + }, ), ], ), @@ -92,12 +105,33 @@ class _ItemCommentState extends State { body: comment.body!, controller: controller, ), + ValueListenableBuilder( + valueListenable: AppController.isLoggedIn, + builder: (context, isLoggedIn, child) { + if (isLoggedIn) { + return Column( + children: [ + const SizedBox(height: 15.0), + Answer( + parentId: comment.id!, + inComment: true, + onAnswer: widget.onAnswer, + ), + const SizedBox(height: 15.0), + ], + ); + } else { + return const SizedBox(); + } + }, + ), comment.children!.isNotEmpty ? Padding( padding: const EdgeInsets.only(left: 15.0), child: CommentsWidget( comments: comment.children!, controller: controller, + onAnswer: widget.onAnswer, ), ) : const SizedBox(), diff --git a/lib/src/ui/widgets/markdown.dart b/lib/src/ui/widgets/markdown.dart index f102b80..4fd874a 100644 --- a/lib/src/ui/widgets/markdown.dart +++ b/lib/src/ui/widgets/markdown.dart @@ -25,7 +25,7 @@ class MarkedownReader extends StatelessWidget { controller: controller, data: body, selectable: true, - onTapLink: (text, href, title) async { + onTapLink: (text, href, title) { OpenLink.open(href, context); }, extensionSet: md.ExtensionSet( diff --git a/lib/src/ui/widgets/source_url.dart b/lib/src/ui/widgets/source_url.dart new file mode 100644 index 0000000..bde69df --- /dev/null +++ b/lib/src/ui/widgets/source_url.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'package:tabnews/src/utils/open_link.dart'; + +class SourceUrl extends StatelessWidget { + final String sourceUrl; + + const SourceUrl({super.key, required this.sourceUrl}); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + 'Fonte: ', + style: const TextStyle().copyWith( + fontWeight: FontWeight.w700, + ), + ), + Expanded( + child: InkWell( + onTap: () => OpenLink.open( + sourceUrl, + context, + ), + child: Text( + sourceUrl, + overflow: TextOverflow.ellipsis, + style: const TextStyle().copyWith( + color: Colors.blue, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ); + } +}