Add initial microblog support. (#2)

* Add initial support for microblogs (posts).

* Merged threads and microblogs feed.

* Revert and rework feed to use toggle for threads/posts rather than tabs.

* Properly add post comments.

* squash! Revert and rework feed to use toggle for threads/posts rather than tabs.

* Swapped order username and magazine are displayed in post item. Also added icon for user avatar to post.
This commit is contained in:
olorin99 2023-12-29 11:48:49 +10:00 committed by GitHub
parent 31a8cd4c90
commit 8e37bba49b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1145 additions and 77 deletions

View File

@ -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';
}

View File

@ -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<Comment> items;
late Pagination pagination;
Comments({required this.items, required this.pagination});
Comments.fromJson(Map<String, dynamic> json) {
items = <Comment>[];
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<String>? 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<Comment>? 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<String, dynamic> 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<String>();
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 = <Comment>[];
json['children'].forEach((v) {
children!.add(Comment.fromJson(v));
});
}
childCount = json['childCount'];
visibility = json['visibility'];
}
}
enum CommentsSort { newest, top, hot, active, oldest }
Future<Comments> 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<String, dynamic>);
} else {
throw Exception('Failed to load comments');
}
}
Future<Comment> 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<String, dynamic>);
}
Future<Comment> 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<String, dynamic>);
}
Future<Comment> 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<String, dynamic>);
}

142
lib/src/api/posts.dart Normal file
View File

@ -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<PostItem> items;
late Pagination pagination;
Posts({required this.items, required this.pagination});
Posts.fromJson(Map<String, dynamic> json) {
items = <PostItem>[];
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<String, dynamic> 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<Posts> 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<String, dynamic>);
}
Future<PostItem> 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<String, dynamic>);
}
Future<PostItem> 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<String, dynamic>);
}

View File

@ -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<MyApp> {
),
Expanded(
child: [
const EntriesScreen(),
const FeedScreen(),
const ExploreScreen(),
const ProfileScreen(),
SettingsScreen(controller: widget.settingsController)

View File

@ -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<EntriesScreen> createState() => _EntriesScreenState();
}
class _EntriesScreenState extends State<EntriesScreen> {
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: Text(context.read<SettingsController>().selectedAccount +
(context.read<SettingsController>().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(),
),
),
),
);
}
}

View File

@ -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<FeedScreen> createState() => _FeedScreenState();
}
enum FeedMode { entries, posts }
class _FeedScreenState extends State<FeedScreen> {
FeedMode _feedMode = FeedMode.entries;
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: Text(context.read<SettingsController>().selectedAccount +
(context.read<SettingsController>().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>{_feedMode},
onSelectionChanged: (Set<FeedMode> 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(),
),
},
),
),
);
}
}

View File

@ -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<PostComment> createState() => _EntryCommentState();
}
class _EntryCommentState extends State<PostComment> {
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<SettingsController>().httpClient,
context.read<SettingsController>().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<SettingsController>().httpClient,
context.read<SettingsController>().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<SettingsController>().httpClient,
context.read<SettingsController>().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<SettingsController>().httpClient,
context.read<SettingsController>().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(),
)
],
),
),
);
}
}

View File

@ -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<void> 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: <Widget>[
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: <Widget>[
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<SettingsController>().httpClient,
context.read<SettingsController>().instanceHost,
item.postId,
1,
));
}),
onUpVote: whenLoggedIn(context, () async {
onUpdate(await api_posts.putFavorite(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().instanceHost,
item.postId,
));
}),
onDownVote: whenLoggedIn(context, () async {
onUpdate(await api_posts.putVote(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().instanceHost,
item.postId,
-1,
));
}),
onReply: onReply,
leadingWidgets: [
const Icon(Icons.comment),
const SizedBox(width: 4),
Text(intFormat(item.numComments)),
const SizedBox(width: 8),
],
),
],
),
),
],
);
}
}

View File

@ -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<PostPage> createState() => _PostPageState();
}
class _PostPageState extends State<PostPage> {
api_comments.CommentsSort commentsSort = api_comments.CommentsSort.hot;
final PagingController<int, api_comments.Comment> _pagingController =
PagingController(firstPageKey: 1);
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener((pageKey) {
_fetchPage(pageKey);
});
}
Future<void> _fetchPage(int pageKey) async {
try {
final newPage = await api_comments.fetchComments(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().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<SettingsController>().httpClient,
context.read<SettingsController>().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<api_comments.CommentsSort>(
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<int, api_comments.Comment>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<api_comments.Comment>(
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;
});
}),
),
),
)
],
),
),
);
}
}

View File

@ -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<PostsListView> createState() => _PostsListViewState();
}
class _PostsListViewState extends State<PostsListView> {
api_posts.PostsSort sort = api_posts.PostsSort.hot;
final PagingController<int, api_posts.PostItem> _pagingController =
PagingController(firstPageKey: 1);
@override
void initState() {
super.initState();
_pagingController.addPageRequestListener((pageKey) {
_fetchPage(pageKey);
});
}
Future<void> _fetchPage(int pageKey) async {
try {
final newPage = await api_posts.fetchPosts(
context.read<SettingsController>().httpClient,
context.read<SettingsController>().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<api_posts.PostsSort>(
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<int, api_posts.PostItem>(
pagingController: _pagingController,
builderDelegate: PagedChildBuilderDelegate<api_posts.PostItem>(
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();
}
}