diff --git a/lib/src/api/content_sources.dart b/lib/src/api/content_sources.dart index c971540..8e29561 100644 --- a/lib/src/api/content_sources.dart +++ b/lib/src/api/content_sources.dart @@ -2,6 +2,7 @@ abstract class ContentSource { String getPath(); } +// entries sources class ContentAll implements ContentSource { const ContentAll(); @@ -56,3 +57,59 @@ class ContentDomain implements ContentSource { @override String getPath() => '/api/domain/$id/entries'; } + +// posts sources +class ContentPostsAll implements ContentSource { + const ContentPostsAll(); + + @override + String getPath() => '/api/posts'; +} + +class ContentPostsSub implements ContentSource { + const ContentPostsSub(); + + @override + String getPath() => '/api/posts/subscribed'; +} + +class ContentPostsMod implements ContentSource { + const ContentPostsMod(); + + @override + String getPath() => '/api/posts/moderated'; +} + +class ContentPostsFav implements ContentSource { + const ContentPostsFav(); + + @override + String getPath() => '/api/posts/favourited'; +} + +class ContentPostsMagazine implements ContentSource { + final int id; + + const ContentPostsMagazine(this.id); + + @override + String getPath() => '/api/magazine/$id/posts'; +} + +class ContentPostsUser implements ContentSource { + final int id; + + const ContentPostsUser(this.id); + + @override + String getPath() => '/api/users/$id/posts'; +} + +class ContentPostsDomain implements ContentSource { + final int id; + + const ContentPostsDomain(this.id); + + @override + String getPath() => '/api/domain/$id/posts'; +} \ No newline at end of file diff --git a/lib/src/api/post_comments.dart b/lib/src/api/post_comments.dart new file mode 100644 index 0000000..d389504 --- /dev/null +++ b/lib/src/api/post_comments.dart @@ -0,0 +1,177 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:interstellar/src/utils/utils.dart'; + +import 'shared.dart'; + +class Comments { + late List items; + late Pagination pagination; + + Comments({required this.items, required this.pagination}); + + Comments.fromJson(Map json) { + items = []; + json['items'].forEach((v) { + items.add(Comment.fromJson(v)); + }); + + pagination = Pagination.fromJson(json['pagination']); + } +} + +class Comment { + late int commentId; + late User user; + late Magazine magazine; + late int postId; + int? parentId; + int? rootId; + Image? image; + late String body; + late String lang; + List? mentions; + late int uv; + late int dv; + late int favourites; + bool? isFavourited; + int? userVote; + bool? isAdult; + late DateTime createdAt; + DateTime? editedAt; + late DateTime lastActive; + String? apId; + List? children; + late int childCount; + late String visibility; + + Comment( + {required this.commentId, + required this.user, + required this.magazine, + required this.postId, + this.parentId, + this.rootId, + this.image, + required this.body, + required this.lang, + this.mentions, + required this.uv, + required this.dv, + required this.favourites, + this.isFavourited, + this.userVote, + this.isAdult, + required this.createdAt, + this.editedAt, + required this.lastActive, + this.apId, + this.children, + required this.childCount, + required this.visibility}); + + Comment.fromJson(Map json) { + commentId = json['commentId']; + user = User.fromJson(json['user']); + magazine = Magazine.fromJson(json['magazine']); + postId = json['postId']; + parentId = json['parentId']; + rootId = json['rootId']; + image = json['image'] != null ? Image.fromJson(json['image']) : null; + body = json['body'] ?? ''; + lang = json['lang']; + mentions = json['mentions']?.cast(); + uv = json['uv'] ?? 0; + dv = json['dv'] ?? 0; + favourites = json['favourites'] ?? 0; + isFavourited = json['isFavourited']; + userVote = json['userVote']; + isAdult = json['isAdult']; + createdAt = DateTime.parse(json['createdAt']); + editedAt = + json['editedAt'] != null ? DateTime.parse(json['editedAt']) : null; + lastActive = DateTime.parse(json['lastActive']); + apId = json['apId']; + if (json['children'] != null) { + children = []; + json['children'].forEach((v) { + children!.add(Comment.fromJson(v)); + }); + } + childCount = json['childCount']; + visibility = json['visibility']; + } +} + +enum CommentsSort { newest, top, hot, active, oldest } + +Future fetchComments( + http.Client client, + String instanceHost, + int postId, { + int? page, + CommentsSort? sort, + }) async { + final response = await client.get(Uri.https( + instanceHost, + '/api/posts/$postId/comments', + {'p': page?.toString(), 'sortBy': sort?.name})); + + if (response.statusCode == 200) { + return Comments.fromJson(jsonDecode(response.body) as Map); + } else { + throw Exception('Failed to load comments'); + } +} + +Future putVote( + http.Client client, + String instanceHost, + int commentId, + int choice, +) async { + final response = await client.put(Uri.https( + instanceHost, + '/api/post-comments/$commentId/vote/$choice', + )); + + httpErrorHandler(response, message: 'Failed to send vote'); + + return Comment.fromJson(jsonDecode(response.body) as Map); +} + +Future putFavorite( + http.Client client, + String instanceHost, + int commentId, +) async { + final response = await client.put(Uri.https( + instanceHost, + '/api/post-comments/$commentId/favourite', + )); + + httpErrorHandler(response, message: 'Failed to send vote'); + + return Comment.fromJson(jsonDecode(response.body) as Map); +} + +Future postComment( + http.Client client, + String instanceHost, + String body, + int postId, { + int? parentCommentId, +}) async { + final response = await client.post( + Uri.https( + instanceHost, + '/api/posts/$postId/comments${parentCommentId != null ? '/$parentCommentId/reply' : ''}', + ), + body: jsonEncode({'body': body}), + ); + + httpErrorHandler(response, message: 'Failed to post comment'); + + return Comment.fromJson(jsonDecode(response.body) as Map); +} diff --git a/lib/src/api/posts.dart b/lib/src/api/posts.dart new file mode 100644 index 0000000..dc0a0a2 --- /dev/null +++ b/lib/src/api/posts.dart @@ -0,0 +1,142 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:interstellar/src/api/content_sources.dart'; +import 'package:interstellar/src/utils/utils.dart'; + +import 'shared.dart'; + +class Posts { + late List items; + late Pagination pagination; + + Posts({required this.items, required this.pagination}); + + Posts.fromJson(Map json) { + items = []; + json['items'].forEach((v) { + items.add(PostItem.fromJson(v)); + }); + + pagination = Pagination.fromJson(json['pagination']); + } +} + +class PostItem { + late int postId; + late Magazine magazine; + late User user; + Image? image; + String? body; + late String lang; + late int numComments; + late int uv; + late int dv; + late int favourites; + bool? isFavourited; + int? userVote; + late bool isAdult; + late bool isPinned; + late DateTime createdAt; + DateTime? editedAt; + late DateTime lastActive; + late String slug; + String? apId; + //TODO: tags + //TODO: mentions + late String visibility; + + PostItem( + {required this.postId, + required this.magazine, + required this.user, + this.image, + this.body, + required this.lang, + required this.numComments, + required this.uv, + required this.dv, + required this.favourites, + this.isFavourited, + this.userVote, + required this.isAdult, + required this.isPinned, + required this.createdAt, + this.editedAt, + required this.lastActive, + required this.slug, + this.apId, + required this.visibility}); + + PostItem.fromJson(Map json) { + postId = json['postId']; + magazine = Magazine.fromJson(json['magazine']); + user = User.fromJson(json['user']); + image = json['image'] != null ? Image.fromJson(json['image']) : null; + body = json['body']; + lang = json['lang']; + numComments = json['comments']; + uv = json['uv']; + dv = json['dv']; + favourites = json['favourites']; + isFavourited = json['isFavourited']; + userVote = json['userVote']; + isAdult = json['isAdult']; + isPinned = json['isPinned']; + createdAt = DateTime.parse(json['createdAt']); + editedAt = + json['editedAt'] == null ? null : DateTime.parse(json['editedAt']); + lastActive = DateTime.parse(json['lastActive']); + slug = json['slug']; + apId = json['apId']; + visibility = json['visibility']; + } +} + +enum PostsSort { active, hot, newest, oldest, top, commented } + +Future fetchPosts( + http.Client client, + String instanceHost, + ContentSource source, { + int? page, + PostsSort? sort, + }) async { + final response = await client.get(Uri.https(instanceHost, source.getPath(), + {'p': page?.toString(), 'sort': sort?.name})); + + httpErrorHandler(response, message: 'Failed to load entries'); + + return Posts.fromJson(jsonDecode(response.body) as Map); +} + +Future putVote( + http.Client client, + String instanceHost, + int postID, + int choice, + ) async { + final response = await client.put(Uri.https( + instanceHost, + '/api/post/$postID/vote/$choice', + )); + + httpErrorHandler(response, message: 'Failed to send vote'); + + return PostItem.fromJson(jsonDecode(response.body) as Map); +} + +Future putFavorite( + http.Client client, + String instanceHost, + int postID, + ) async { + final response = await client.put(Uri.https( + instanceHost, + '/api/post/$postID/favourite', + )); + + httpErrorHandler(response, message: 'Failed to send vote'); + + return PostItem.fromJson(jsonDecode(response.body) as Map); +} diff --git a/lib/src/app.dart b/lib/src/app.dart index 71f23c2..4678e20 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -5,7 +5,7 @@ import 'package:interstellar/src/screens/explore/explore_screen.dart'; import 'package:interstellar/src/screens/profile/profile_screen.dart'; import 'package:provider/provider.dart'; -import 'screens/entries/entries_screen.dart'; +import 'screens/feed_screen.dart'; import 'screens/settings/settings_controller.dart'; import 'screens/settings/settings_screen.dart'; @@ -112,7 +112,7 @@ class _MyAppState extends State { ), Expanded( child: [ - const EntriesScreen(), + const FeedScreen(), const ExploreScreen(), const ProfileScreen(), SettingsScreen(controller: widget.settingsController) diff --git a/lib/src/screens/entries/entries_screen.dart b/lib/src/screens/entries/entries_screen.dart deleted file mode 100644 index 4f47d96..0000000 --- a/lib/src/screens/entries/entries_screen.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:interstellar/src/api/content_sources.dart'; -import 'package:interstellar/src/screens/entries/entries_list.dart'; -import 'package:interstellar/src/screens/settings/settings_controller.dart'; -import 'package:interstellar/src/utils/utils.dart'; -import 'package:provider/provider.dart'; - -class EntriesScreen extends StatefulWidget { - const EntriesScreen({ - super.key, - }); - - @override - State createState() => _EntriesScreenState(); -} - -class _EntriesScreenState extends State { - @override - Widget build(BuildContext context) { - return DefaultTabController( - length: 4, - child: Scaffold( - appBar: AppBar( - title: Text(context.read().selectedAccount + - (context.read().isLoggedIn - ? '' - : ' (Anonymous)')), - bottom: whenLoggedIn( - context, - const TabBar(tabs: [ - Tab( - text: 'Sub', - icon: Icon(Icons.group), - ), - Tab( - text: 'Mod', - icon: Icon(Icons.lock), - ), - Tab( - text: 'Fav', - icon: Icon(Icons.favorite), - ), - Tab( - text: 'All', - icon: Icon(Icons.newspaper), - ), - ]), - ), - ), - body: whenLoggedIn( - context, - const TabBarView( - children: [ - EntriesListView( - contentSource: ContentSub(), - ), - EntriesListView( - contentSource: ContentMod(), - ), - EntriesListView( - contentSource: ContentFav(), - ), - EntriesListView( - contentSource: ContentAll(), - ), - ], - ), - otherwise: const EntriesListView( - contentSource: ContentAll(), - ), - ), - ), - ); - } -} diff --git a/lib/src/screens/feed_screen.dart b/lib/src/screens/feed_screen.dart new file mode 100644 index 0000000..eaa57df --- /dev/null +++ b/lib/src/screens/feed_screen.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:interstellar/src/api/content_sources.dart'; +import 'package:interstellar/src/screens/entries/entries_list.dart'; +import 'package:interstellar/src/screens/posts/posts_list.dart'; +import 'package:interstellar/src/screens/settings/settings_controller.dart'; +import 'package:interstellar/src/utils/utils.dart'; +import 'package:provider/provider.dart'; + +class FeedScreen extends StatefulWidget { + const FeedScreen({ + super.key, + }); + + @override + State createState() => _FeedScreenState(); +} + +enum FeedMode { entries, posts } + +class _FeedScreenState extends State { + FeedMode _feedMode = FeedMode.entries; + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: 4, + child: Scaffold( + appBar: AppBar( + title: Text(context.read().selectedAccount + + (context.read().isLoggedIn + ? '' + : ' (Anonymous)')), + actions: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: FeedMode.entries, + label: Text("Threads") + ), + ButtonSegment( + value: FeedMode.posts, + label: Text("Posts") + ), + ], + style: const ButtonStyle( + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity(horizontal: -3, vertical: -3), + ), + selected: {_feedMode}, + onSelectionChanged: (Set newSelection) { + setState(() { + _feedMode = newSelection.first; + }); + }, + ), + ], + bottom: whenLoggedIn( + context, + const TabBar(tabs: [ + Tab( + text: 'Sub', + icon: Icon(Icons.group), + ), + Tab( + text: 'Mod', + icon: Icon(Icons.lock), + ), + Tab( + text: 'Fav', + icon: Icon(Icons.favorite), + ), + Tab( + text: 'All', + icon: Icon(Icons.newspaper), + ), + ]), + ), + ), + body: whenLoggedIn( + context, + TabBarView( + children: switch (_feedMode) { + FeedMode.entries => ([ + const EntriesListView( + contentSource: ContentSub(), + ), + const EntriesListView( + contentSource: ContentMod(), + ), + const EntriesListView( + contentSource: ContentFav(), + ), + const EntriesListView( + contentSource: ContentAll(), + ), + ]), + FeedMode.posts => ([ + const PostsListView( + contentSource: ContentPostsSub(), + ), + const PostsListView( + contentSource: ContentPostsMod(), + ), + const PostsListView( + contentSource: ContentPostsFav(), + ), + const PostsListView( + contentSource: ContentPostsAll(), + ), + ]) + }, + ), + otherwise: switch (_feedMode) { + FeedMode.entries => const EntriesListView( + contentSource: ContentAll(), + ), + FeedMode.posts => const PostsListView( + contentSource: ContentPostsAll(), + ), + }, + ), + ), + ); + } +} diff --git a/lib/src/screens/posts/post_comment.dart b/lib/src/screens/posts/post_comment.dart new file mode 100644 index 0000000..d9b1d94 --- /dev/null +++ b/lib/src/screens/posts/post_comment.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:interstellar/src/api/post_comments.dart' as api_comments; +import 'package:interstellar/src/screens/explore/user_screen.dart'; +import 'package:interstellar/src/screens/settings/settings_controller.dart'; +import 'package:interstellar/src/utils/utils.dart'; +import 'package:interstellar/src/widgets/action_bar.dart'; +import 'package:interstellar/src/widgets/display_name.dart'; +import 'package:interstellar/src/widgets/markdown.dart'; +import 'package:provider/provider.dart'; + +class PostComment extends StatefulWidget { + const PostComment(this.comment, this.onUpdate, {super.key}); + + final api_comments.Comment comment; + final void Function(api_comments.Comment) onUpdate; + + @override + State createState() => _EntryCommentState(); +} + +class _EntryCommentState extends State { + bool _isCollapsed = false; + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 0, 1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + DisplayName( + widget.comment.user.username, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => UserScreen( + widget.comment.user.userId, + ), + ), + ); + }, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + timeDiffFormat(widget.comment.createdAt), + style: const TextStyle(fontWeight: FontWeight.w300), + ), + ) + ], + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Markdown(widget.comment.body), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: ActionBar( + boosts: widget.comment.uv, + upVotes: widget.comment.favourites, + downVotes: widget.comment.dv, + isBoosted: widget.comment.userVote == 1, + isUpVoted: widget.comment.isFavourited == true, + isDownVoted: widget.comment.userVote == -1, + isCollapsed: _isCollapsed, + onBoost: whenLoggedIn(context, () async { + var newValue = await api_comments.putVote( + context.read().httpClient, + context.read().instanceHost, + widget.comment.commentId, + 1, + ); + newValue.childCount = widget.comment.childCount; + newValue.children = widget.comment.children; + widget.onUpdate(newValue); + }), + onUpVote: whenLoggedIn(context, () async { + var newValue = await api_comments.putFavorite( + context.read().httpClient, + context.read().instanceHost, + widget.comment.commentId, + ); + newValue.childCount = widget.comment.childCount; + newValue.children = widget.comment.children; + widget.onUpdate(newValue); + }), + onDownVote: whenLoggedIn(context, () async { + var newValue = await api_comments.putVote( + context.read().httpClient, + context.read().instanceHost, + widget.comment.commentId, + -1, + ); + newValue.childCount = widget.comment.childCount; + newValue.children = widget.comment.children; + widget.onUpdate(newValue); + }), + onCollapse: widget.comment.childCount > 0 + ? () => setState(() { + _isCollapsed = !_isCollapsed; + }) + : null, + onReply: (body) async { + var newSubComment = await api_comments.postComment( + context.read().httpClient, + context.read().instanceHost, + body, + widget.comment.postId, + parentCommentId: widget.comment.commentId, + ); + + var newComment = widget.comment; + newComment.childCount += 1; + newComment.children!.insert(0, newSubComment); + widget.onUpdate(newComment); + }, + ), + ), + const SizedBox(height: 4), + if (!_isCollapsed && widget.comment.childCount > 0) + Column( + children: widget.comment.children! + .asMap() + .entries + .map((item) => PostComment(item.value, (newValue) { + var newComment = widget.comment; + newComment.children![item.key] = newValue; + widget.onUpdate(newComment); + })) + .toList(), + ) + ], + ), + ), + ); + } +} diff --git a/lib/src/screens/posts/post_item.dart b/lib/src/screens/posts/post_item.dart new file mode 100644 index 0000000..056a5db --- /dev/null +++ b/lib/src/screens/posts/post_item.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:interstellar/src/api/posts.dart' as api_posts; +import 'package:interstellar/src/screens/explore/domain_screen.dart'; +import 'package:interstellar/src/screens/explore/magazine_screen.dart'; +import 'package:interstellar/src/screens/explore/user_screen.dart'; +import 'package:interstellar/src/screens/settings/settings_controller.dart'; +import 'package:interstellar/src/utils/utils.dart'; +import 'package:interstellar/src/widgets/action_bar.dart'; +import 'package:interstellar/src/widgets/display_name.dart'; +import 'package:interstellar/src/widgets/markdown.dart'; +import 'package:interstellar/src/widgets/open_webpage.dart'; +import 'package:interstellar/src/widgets/video.dart'; +import 'package:provider/provider.dart'; + +class PostItem extends StatelessWidget { + const PostItem( + this.item, + this.onUpdate, { + super.key, + this.isPreview = false, + this.onReply, + }); + + final api_posts.PostItem item; + final void Function(api_posts.PostItem) onUpdate; + final Future Function(String)? onReply; + final bool isPreview; + + _onImageClick(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + title: Text(item.user.username), + backgroundColor: const Color(0x66000000), + ), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: InteractiveViewer( + child: Image.network( + item.image!.storageUrl, + ), + ), + ) + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (item.image?.storageUrl != null) + isPreview + ? (InkWell( + onTap: () => _onImageClick(context), + child: Image.network( + item.image!.storageUrl, + height: 160, + width: double.infinity, + fit: BoxFit.cover, + ), + )) + : Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height / 2, + ), + child: InkWell( + onTap: () => _onImageClick(context), + child: Image.network( + item.image!.storageUrl, + ), + )), + Container( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 10), + Row( + children: [ + DisplayName( + item.user.username, + icon: item.user.avatar?.storageUrl, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => UserScreen(item.user.userId), + ), + ); + }, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + timeDiffFormat(item.createdAt), + style: const TextStyle(fontWeight: FontWeight.w300), + ), + ), + DisplayName( + item.magazine.name, + icon: item.magazine.icon?.storageUrl, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => MagazineScreen( + item.magazine.magazineId, + ), + ), + ); + }, + ), + ], + ), + if (item.body != null && item.body!.isNotEmpty) + const SizedBox(height: 10), + if (item.body != null && item.body!.isNotEmpty) + isPreview + ? Text( + item.body!, + maxLines: 4, + overflow: TextOverflow.ellipsis, + ) + : Markdown(item.body!), + const SizedBox(height: 10), + ActionBar( + boosts: item.uv, + upVotes: item.favourites, + downVotes: item.dv, + isBoosted: item.userVote == 1, + isUpVoted: item.isFavourited == true, + isDownVoted: item.userVote == -1, + onBoost: whenLoggedIn(context, () async { + onUpdate(await api_posts.putVote( + context.read().httpClient, + context.read().instanceHost, + item.postId, + 1, + )); + }), + onUpVote: whenLoggedIn(context, () async { + onUpdate(await api_posts.putFavorite( + context.read().httpClient, + context.read().instanceHost, + item.postId, + )); + }), + onDownVote: whenLoggedIn(context, () async { + onUpdate(await api_posts.putVote( + context.read().httpClient, + context.read().instanceHost, + item.postId, + -1, + )); + }), + onReply: onReply, + leadingWidgets: [ + const Icon(Icons.comment), + const SizedBox(width: 4), + Text(intFormat(item.numComments)), + const SizedBox(width: 8), + ], + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/src/screens/posts/post_page.dart b/lib/src/screens/posts/post_page.dart new file mode 100644 index 0000000..95a348b --- /dev/null +++ b/lib/src/screens/posts/post_page.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:interstellar/src/api/post_comments.dart' as api_comments; +import 'package:interstellar/src/api/posts.dart' as api_posts; +import 'package:interstellar/src/screens/posts/post_comment.dart'; +import 'package:interstellar/src/screens/posts/post_item.dart'; +import 'package:interstellar/src/screens/settings/settings_controller.dart'; +import 'package:provider/provider.dart'; + +class PostPage extends StatefulWidget { + const PostPage( + this.item, + this.onUpdate, { + super.key, + }); + + final api_posts.PostItem item; + final void Function(api_posts.PostItem) onUpdate; + + @override + State createState() => _PostPageState(); +} + +class _PostPageState extends State { + api_comments.CommentsSort commentsSort = api_comments.CommentsSort.hot; + + final PagingController _pagingController = + PagingController(firstPageKey: 1); + + @override + void initState() { + super.initState(); + + _pagingController.addPageRequestListener((pageKey) { + _fetchPage(pageKey); + }); + } + + Future _fetchPage(int pageKey) async { + try { + final newPage = await api_comments.fetchComments( + context.read().httpClient, + context.read().instanceHost, + widget.item.postId, + page: pageKey, + sort: commentsSort, + ); + + final isLastPage = + newPage.pagination.currentPage == newPage.pagination.maxPage; + + if (isLastPage) { + _pagingController.appendLastPage(newPage.items); + } else { + final nextPageKey = pageKey + 1; + _pagingController.appendPage(newPage.items, nextPageKey); + } + } catch (error) { + _pagingController.error = error; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.item.user.username), + ), + body: RefreshIndicator( + onRefresh: () => Future.sync( + () => _pagingController.refresh(), + ), + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: PostItem( + widget.item, + widget.onUpdate, + onReply: (body) async { + var newComment = await api_comments.postComment( + context.read().httpClient, + context.read().instanceHost, + body, + widget.item.postId, + ); + var newList = _pagingController.itemList; + newList?.insert(0, newComment); + setState(() { + _pagingController.itemList = newList; + }); + }, + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + DropdownButton( + value: commentsSort, + onChanged: (newSort) { + if (newSort != null) { + setState(() { + commentsSort = newSort; + _pagingController.refresh(); + }); + } + }, + items: const [ + DropdownMenuItem( + value: api_comments.CommentsSort.hot, + child: Text('Hot'), + ), + DropdownMenuItem( + value: api_comments.CommentsSort.top, + child: Text('Top'), + ), + DropdownMenuItem( + value: api_comments.CommentsSort.newest, + child: Text('Newest'), + ), + DropdownMenuItem( + value: api_comments.CommentsSort.active, + child: Text('Active'), + ), + DropdownMenuItem( + value: api_comments.CommentsSort.oldest, + child: Text('Oldest'), + ), + ], + ), + ], + ), + ), + ), + PagedSliverList( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => Padding( + padding: const EdgeInsets.all(8), + child: PostComment(item, (newValue) { + var newList = _pagingController.itemList; + newList![index] = newValue; + setState(() { + _pagingController.itemList = newList; + }); + }), + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/src/screens/posts/posts_list.dart b/lib/src/screens/posts/posts_list.dart new file mode 100644 index 0000000..b6091c1 --- /dev/null +++ b/lib/src/screens/posts/posts_list.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; +import 'package:interstellar/src/api/content_sources.dart'; +import 'package:interstellar/src/api/posts.dart' as api_posts; +import 'package:interstellar/src/screens/posts/post_item.dart'; +import 'package:interstellar/src/screens/posts/post_page.dart'; +import 'package:interstellar/src/screens/settings/settings_controller.dart'; +import 'package:provider/provider.dart'; + +class PostsListView extends StatefulWidget { + const PostsListView({ + super.key, + this.contentSource = const ContentAll(), + this.details, + }); + + final ContentSource contentSource; + final Widget? details; + + @override + State createState() => _PostsListViewState(); +} + +class _PostsListViewState extends State { + api_posts.PostsSort sort = api_posts.PostsSort.hot; + + final PagingController _pagingController = + PagingController(firstPageKey: 1); + + @override + void initState() { + super.initState(); + + _pagingController.addPageRequestListener((pageKey) { + _fetchPage(pageKey); + }); + } + + Future _fetchPage(int pageKey) async { + try { + final newPage = await api_posts.fetchPosts( + context.read().httpClient, + context.read().instanceHost, + widget.contentSource, + page: pageKey, + sort: sort, + ); + + final isLastPage = + newPage.pagination.currentPage == newPage.pagination.maxPage; + + if (isLastPage) { + _pagingController.appendLastPage(newPage.items); + } else { + final nextPageKey = pageKey + 1; + _pagingController.appendPage(newPage.items, nextPageKey); + } + } catch (error) { + _pagingController.error = error; + } + } + + @override + Widget build(BuildContext context) { + return RefreshIndicator( + onRefresh: () => Future.sync( + () => _pagingController.refresh(), + ), + child: CustomScrollView( + slivers: [ + if (widget.details != null) + SliverToBoxAdapter( + child: widget.details, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + DropdownButton( + value: sort, + onChanged: (newSort) { + if (newSort != null) { + setState(() { + sort = newSort; + _pagingController.refresh(); + }); + } + }, + items: const [ + DropdownMenuItem( + value: api_posts.PostsSort.hot, + child: Text('Hot'), + ), + DropdownMenuItem( + value: api_posts.PostsSort.top, + child: Text('Top'), + ), + DropdownMenuItem( + value: api_posts.PostsSort.newest, + child: Text('Newest'), + ), + DropdownMenuItem( + value: api_posts.PostsSort.active, + child: Text('Active'), + ), + DropdownMenuItem( + value: api_posts.PostsSort.commented, + child: Text('Commented'), + ), + DropdownMenuItem( + value: api_posts.PostsSort.oldest, + child: Text('Oldest'), + ), + ], + ), + ], + ), + ), + ), + PagedSliverList( + pagingController: _pagingController, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) => Padding( + padding: const EdgeInsets.all(8.0), + child: Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => PostPage(item, (newValue) { + var newList = _pagingController.itemList; + newList![index] = newValue; + setState(() { + _pagingController.itemList = newList; + }); + }), + ), + ); + }, + child: PostItem( + item, + (newValue) { + var newList = _pagingController.itemList; + newList![index] = newValue; + setState(() { + _pagingController.itemList = newList; + }); + }, + isPreview: true, + ), + ), + ), + ), + ), + ) + ], + ), + ); + } + + @override + void dispose() { + _pagingController.dispose(); + super.dispose(); + } +}