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:
parent
31a8cd4c90
commit
8e37bba49b
|
@ -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';
|
||||
}
|
|
@ -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>);
|
||||
}
|
|
@ -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>);
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue