Merge pull request #261 from krawieck/feature/blocking-v2

This commit is contained in:
Filip Krawczyk 2021-10-24 14:49:15 +02:00 committed by GitHub
commit a38574c314
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
55 changed files with 2243 additions and 1015 deletions

33
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,33 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "flutter",
"command": "flutter",
"args": [
"pub",
"run",
"build_runner",
"build",
"--delete-conflicting-outputs"
],
"problemMatcher": ["$dart-build_runner"],
"group": "build",
"label": "flutter: build_runner build"
},
{
"type": "flutter",
"command": "flutter",
"args": [
"pub",
"run",
"build_runner",
"watch",
"--delete-conflicting-outputs"
],
"problemMatcher": ["$dart-build_runner"],
"group": "build",
"label": "flutter: flutter pub run build_runner watch"
}
]
}

View File

@ -4,6 +4,11 @@
- Logging: local logs about some actions/errors. Can be accessed from **settings > about lemmur > logs**
- Android theme-aware splash screen (thanks to [@mimi89999](https://github.com/mimi89999))
- Blocking of users and communities (from post and from comment)
### Fixed
- Fixed a bug where post would go out of sync with full version of the post
## v0.6.0 - 2021-09-06

View File

@ -111,6 +111,7 @@ linter:
- use_setters_to_change_properties
- use_to_and_as_if_applicable
- void_checks
- use_named_constants
analyzer:
exclude:

View File

@ -39,6 +39,15 @@ extension on CommentSortType {
}
}
extension SortCommentTreeList on List<CommentTree> {
void sortBy(CommentSortType sortType) {
sort(sortType.sortFunction);
for (final el in this) {
el._sort(sortType.sortFunction);
}
}
}
class CommentTree {
CommentView comment;
List<CommentTree> children = [];
@ -71,14 +80,4 @@ class CommentTree {
el._sort(compare);
}
}
/// Sorts in-place a list of CommentTrees according to a given sortType
static List<CommentTree> sortList(
CommentSortType sortType, List<CommentTree> comms) {
comms.sort(sortType.sortFunction);
for (final el in comms) {
el._sort(sortType.sortFunction);
}
return comms;
}
}

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import '../pages/settings.dart';
import '../pages/settings/settings.dart';
import '../util/goto.dart';
import 'stores.dart';

View File

@ -12,8 +12,8 @@ import '../l10n/l10n.dart';
import '../util/extensions/api.dart';
import '../util/extensions/spaced.dart';
import '../util/goto.dart';
import '../util/icons.dart';
import '../util/intl.dart';
import '../util/more_icon.dart';
import '../util/share.dart';
import '../widgets/avatar.dart';
import '../widgets/bottom_modal.dart';

View File

@ -12,12 +12,11 @@ import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../util/extensions/api.dart';
import '../util/extensions/spaced.dart';
import '../util/goto.dart';
import '../util/pictrs.dart';
import '../widgets/editor.dart';
import '../widgets/markdown_mode_icon.dart';
import '../widgets/radio_picker.dart';
import 'full_post.dart';
import 'full_post/full_post.dart';
/// Fab that triggers the [CreatePost] modal
/// After creation it will navigate to the newly created post
@ -39,10 +38,8 @@ class CreatePostFab extends HookWidget {
);
if (postView != null) {
await goTo(
context,
(_) => FullPostPage.fromPostView(postView),
);
await Navigator.of(context)
.push(FullPostPage.fromPostViewRoute(postView));
}
}),
child: const Icon(Icons.add),

View File

@ -1,156 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import '../hooks/logged_in_action.dart';
import '../hooks/refreshable.dart';
import '../hooks/stores.dart';
import '../util/extensions/api.dart';
import '../util/more_icon.dart';
import '../util/share.dart';
import '../widgets/comment_section.dart';
import '../widgets/post.dart';
import '../widgets/reveal_after_scroll.dart';
import '../widgets/save_post_button.dart';
import '../widgets/write_comment.dart';
/// Displays a post with its comment section
class FullPostPage extends HookWidget {
final int id;
final String instanceHost;
final PostView? post;
const FullPostPage({required this.id, required this.instanceHost})
: post = null;
FullPostPage.fromPostView(PostView this.post)
: id = post.post.id,
instanceHost = post.instanceHost;
@override
Widget build(BuildContext context) {
final accStore = useAccountsStore();
final scrollController = useScrollController();
final fullPostRefreshable =
useRefreshable(() => LemmyApiV3(instanceHost).run(GetPost(
id: id,
auth: accStore.defaultUserDataFor(instanceHost)?.jwt.raw,
)));
final loggedInAction = useLoggedInAction(instanceHost);
final newComments = useState(const <CommentView>[]);
// FALLBACK VIEW
if (!fullPostRefreshable.snapshot.hasData && this.post == null) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (fullPostRefreshable.snapshot.hasError)
Text(fullPostRefreshable.snapshot.error.toString())
else
const CircularProgressIndicator(),
],
),
),
);
}
// VARIABLES
final post = fullPostRefreshable.snapshot.hasData
? fullPostRefreshable.snapshot.data!.postView
: this.post!;
final fullPost = fullPostRefreshable.snapshot.data;
// FUNCTIONS
refresh() async {
await HapticFeedback.mediumImpact();
try {
await fullPostRefreshable.refresh();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(e.toString()),
));
}
}
sharePost() => share(post.post.apId, context: context);
comment() async {
final newComment = await Navigator.of(context).push(
WriteComment.toPostRoute(post.post),
);
if (newComment != null) {
newComments.value = [...newComments.value, newComment];
}
}
return Scaffold(
appBar: AppBar(
centerTitle: false,
title: RevealAfterScroll(
scrollController: scrollController,
after: 65,
child: Text(
post.community.originPreferredName,
overflow: TextOverflow.fade,
),
),
actions: [
IconButton(icon: const Icon(Icons.share), onPressed: sharePost),
SavePostButton(post),
IconButton(
icon: Icon(moreIcon),
onPressed: () => PostWidget.showMoreMenu(
context: context,
post: post,
fullPost: true,
),
),
],
),
floatingActionButton: post.post.locked
? null
: FloatingActionButton(
onPressed: loggedInAction((_) => comment()),
child: const Icon(Icons.comment),
),
body: RefreshIndicator(
onRefresh: refresh,
child: ListView(
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(),
children: [
const SizedBox(height: 15),
PostWidget(post, fullPost: true),
if (fullPost != null)
CommentSection(
newComments.value.followedBy(fullPost.comments).toList(),
postCreatorId: fullPost.postView.creator.id)
else if (fullPostRefreshable.snapshot.hasError)
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 30),
child: Column(
children: [
const Icon(Icons.error),
Text('Error: ${fullPostRefreshable.snapshot.error}')
],
),
)
else
const Padding(
padding: EdgeInsets.only(top: 40),
child: Center(child: CircularProgressIndicator()),
),
],
),
));
}
}

View File

@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../comment_tree.dart';
import '../../l10n/l10n.dart';
import '../../stores/accounts_store.dart';
import '../../util/observer_consumers.dart';
import '../../widgets/bottom_modal.dart';
import '../../widgets/bottom_safe.dart';
import '../../widgets/comment/comment.dart';
import 'full_post.dart';
import 'full_post_store.dart';
class _SortSelection {
final IconData icon;
final String term;
const _SortSelection(this.icon, this.term);
}
/// Manages comments section, sorts them
class CommentSection extends StatelessWidget {
static const sortPairs = {
CommentSortType.hot: _SortSelection(Icons.whatshot, L10nStrings.hot),
CommentSortType.new_: _SortSelection(Icons.new_releases, L10nStrings.new_),
CommentSortType.old: _SortSelection(Icons.calendar_today, L10nStrings.old),
CommentSortType.top: _SortSelection(Icons.trending_up, L10nStrings.top),
CommentSortType.chat: _SortSelection(Icons.chat, L10nStrings.chat),
};
const CommentSection({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ObserverBuilder<FullPostStore>(
builder: (context, store) {
final fullPostView = store.fullPostView;
// error & spinner handling
if (fullPostView == null) {
if (store.fullPostState.errorTerm != null) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 30),
child: FailedToLoad(
message: 'Comments failed to load',
refresh: () => store.refresh(context
.read<AccountsStore>()
.defaultUserDataFor(store.instanceHost)
?.jwt)),
);
} else {
return const Padding(
padding: EdgeInsets.only(top: 40),
child: Center(child: CircularProgressIndicator.adaptive()),
);
}
}
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15),
child: Row(
children: [
OutlinedButton(
onPressed: () {
showBottomModal(
title: 'sort by',
context: context,
builder: (context) => Column(
children: [
for (final e in sortPairs.entries)
ListTile(
leading: Icon(e.value.icon),
title: Text((e.value.term).tr(context)),
trailing: store.sorting == e.key
? const Icon(Icons.check)
: null,
onTap: () {
Navigator.of(context).pop();
store.updateSorting(e.key);
},
)
],
),
);
},
child: Row(
children: [
Text((sortPairs[store.sorting]!.term).tr(context)),
const Icon(Icons.arrow_drop_down),
],
),
),
const Spacer(),
],
),
),
// sorting menu goes here
if (fullPostView.comments.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 50),
child: Text(
'no comments yet',
style: TextStyle(fontStyle: FontStyle.italic),
),
)
else ...[
for (final com in store.newComments)
CommentWidget.fromCommentView(
com,
detached: false,
key: ValueKey(com),
),
if (store.sorting == CommentSortType.chat)
for (final com in fullPostView.comments)
CommentWidget.fromCommentView(
com,
detached: false,
key: ValueKey(com),
)
else
for (final com in store.sortedCommentTree!)
CommentWidget(
com,
key: ValueKey(com),
),
const BottomSafe.fab(),
]
],
);
},
);
}
}

View File

@ -0,0 +1,186 @@
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:provider/provider.dart';
import '../../hooks/logged_in_action.dart';
import '../../stores/accounts_store.dart';
import '../../util/async_store_listener.dart';
import '../../util/extensions/api.dart';
import '../../util/icons.dart';
import '../../util/observer_consumers.dart';
import '../../util/share.dart';
import '../../widgets/post/post.dart';
import '../../widgets/post/post_more_menu.dart';
import '../../widgets/post/post_store.dart';
import '../../widgets/post/save_post_button.dart';
import '../../widgets/reveal_after_scroll.dart';
import '../../widgets/write_comment.dart';
import 'comment_section.dart';
import 'full_post_store.dart';
/// Displays a post with its comment section
class FullPostPage extends HookWidget {
const FullPostPage._();
@override
Widget build(BuildContext context) {
final scrollController = useScrollController();
final loggedInAction =
useLoggedInAction(context.read<FullPostStore>().instanceHost);
return AsyncStoreListener(
asyncStore: context.read<FullPostStore>().fullPostState,
child: AsyncStoreListener<BlockedCommunity>(
asyncStore: context.read<FullPostStore>().communityBlockingState,
successMessageBuilder: (context, data) {
final name = data.communityView.community.originPreferredName;
return '${data.blocked ? 'Blocked' : 'Unblocked'} $name';
},
child: ObserverBuilder<FullPostStore>(
builder: (context, store) {
Future<void> refresh() async {
unawaited(HapticFeedback.mediumImpact());
await store.refresh(context
.read<AccountsStore>()
.defaultUserDataFor(store.instanceHost)
?.jwt);
}
final postStore = store.postStore;
if (postStore == null) {
return Scaffold(
appBar: AppBar(),
body: Center(
child: (store.fullPostState.isLoading)
? const CircularProgressIndicator.adaptive()
: FailedToLoad(
message: 'Post failed to load', refresh: refresh),
),
);
}
final post = postStore.postView;
// VARIABLES
sharePost() => share(post.post.apId, context: context);
comment() async {
final newComment = await Navigator.of(context).push(
WriteComment.toPostRoute(post.post),
);
if (newComment != null) {
store.addComment(newComment);
}
}
return Scaffold(
appBar: AppBar(
centerTitle: false,
title: RevealAfterScroll(
scrollController: scrollController,
after: 65,
child: Text(
post.community.originPreferredName,
overflow: TextOverflow.fade,
),
),
actions: [
IconButton(icon: Icon(shareIcon), onPressed: sharePost),
Provider.value(
value: postStore,
child: const SavePostButton(),
),
IconButton(
icon: Icon(moreIcon),
onPressed: () => PostMoreMenuButton.show(
context: context,
postStore: postStore,
fullPostStore: store,
),
),
],
),
floatingActionButton: post.post.locked
? null
: FloatingActionButton(
onPressed: loggedInAction((_) => comment()),
child: const Icon(Icons.comment),
),
body: RefreshIndicator(
onRefresh: refresh,
child: ListView(
controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(),
children: [
const SizedBox(height: 15),
PostTile.fromPostStore(postStore),
const CommentSection(),
],
),
));
},
),
),
);
}
static Jwt? _tryGetJwt(BuildContext context, String instanceHost) {
return context.read<AccountsStore>().defaultUserDataFor(instanceHost)?.jwt;
}
static Route route(int id, String instanceHost) => MaterialPageRoute(
builder: (context) => Provider(
create: (context) =>
FullPostStore(instanceHost: instanceHost, postId: id)
..refresh(_tryGetJwt(context, instanceHost)),
child: const FullPostPage._(),
),
);
static Route fromPostViewRoute(PostView postView) => MaterialPageRoute(
builder: (context) => Provider(
create: (context) => FullPostStore.fromPostView(postView)
..refresh(_tryGetJwt(context, postView.instanceHost)),
child: const FullPostPage._(),
),
);
static Route fromPostStoreRoute(PostStore postStore) => MaterialPageRoute(
builder: (context) => Provider(
create: (context) => FullPostStore.fromPostStore(postStore)
..refresh(_tryGetJwt(context, postStore.postView.instanceHost)),
child: const FullPostPage._(),
),
);
}
class FailedToLoad extends StatelessWidget {
final String message;
final VoidCallback refresh;
const FailedToLoad({required this.refresh, required this.message});
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(message),
const SizedBox(height: 5),
ElevatedButton.icon(
onPressed: refresh,
icon: const Icon(Icons.refresh),
label: const Text('try again'),
)
],
);
}
}

View File

@ -0,0 +1,102 @@
import 'package:lemmy_api_client/v3.dart';
import 'package:mobx/mobx.dart';
import '../../comment_tree.dart';
import '../../util/async_store.dart';
import '../../widgets/post/post_store.dart';
part 'full_post_store.g.dart';
class FullPostStore = _FullPostStore with _$FullPostStore;
abstract class _FullPostStore with Store {
final int postId;
final String instanceHost;
_FullPostStore({
this.postStore,
required this.postId,
required this.instanceHost,
});
// ignore: unused_element
_FullPostStore.fromPostView(PostView postView)
: postId = postView.post.id,
instanceHost = postView.instanceHost,
postStore = PostStore(postView);
// ignore: unused_element
_FullPostStore.fromPostStore(PostStore this.postStore)
: postId = postStore.postView.post.id,
instanceHost = postStore.postView.instanceHost;
@observable
FullPostView? fullPostView;
@observable
ObservableList<CommentView> newComments = ObservableList<CommentView>();
@observable
CommentSortType sorting = CommentSortType.hot;
@observable
PostStore? postStore;
final fullPostState = AsyncStore<FullPostView>();
final communityBlockingState = AsyncStore<BlockedCommunity>();
@action
// ignore: use_setters_to_change_properties
void updateSorting(CommentSortType sort) {
sorting = sort;
}
@computed
List<CommentTree>? get commentTree {
if (fullPostView == null) return null;
return CommentTree.fromList(fullPostView!.comments);
}
@computed
List<CommentTree>? get sortedCommentTree {
return commentTree?..sortBy(sorting);
}
@computed
PostView? get postView => postStore?.postView;
@computed
Iterable<CommentView>? get comments =>
fullPostView?.comments.followedBy(newComments);
@action
Future<void> refresh([Jwt? token]) async {
final result = await fullPostState.runLemmy(
instanceHost, GetPost(id: postId, auth: token?.raw));
if (result != null) {
postStore ??= PostStore(result.postView);
fullPostView = result;
postStore!.updatePostView(result.postView);
}
}
@action
Future<void> blockCommunity(Jwt token) async {
final result = await communityBlockingState.runLemmy(
instanceHost,
BlockCommunity(
communityId: fullPostView!.communityView.community.id,
block: !fullPostView!.communityView.blocked,
auth: token.raw));
if (result != null) {
fullPostView =
fullPostView!.copyWith(communityView: result.communityView);
}
}
@action
void addComment(CommentView commentView) =>
newComments.insert(0, commentView);
}

View File

@ -0,0 +1,154 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'full_post_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$FullPostStore on _FullPostStore, Store {
Computed<List<CommentTree>?>? _$commentTreeComputed;
@override
List<CommentTree>? get commentTree => (_$commentTreeComputed ??=
Computed<List<CommentTree>?>(() => super.commentTree,
name: '_FullPostStore.commentTree'))
.value;
Computed<List<CommentTree>?>? _$sortedCommentTreeComputed;
@override
List<CommentTree>? get sortedCommentTree => (_$sortedCommentTreeComputed ??=
Computed<List<CommentTree>?>(() => super.sortedCommentTree,
name: '_FullPostStore.sortedCommentTree'))
.value;
Computed<PostView?>? _$postViewComputed;
@override
PostView? get postView =>
(_$postViewComputed ??= Computed<PostView?>(() => super.postView,
name: '_FullPostStore.postView'))
.value;
Computed<Iterable<CommentView>?>? _$commentsComputed;
@override
Iterable<CommentView>? get comments => (_$commentsComputed ??=
Computed<Iterable<CommentView>?>(() => super.comments,
name: '_FullPostStore.comments'))
.value;
final _$fullPostViewAtom = Atom(name: '_FullPostStore.fullPostView');
@override
FullPostView? get fullPostView {
_$fullPostViewAtom.reportRead();
return super.fullPostView;
}
@override
set fullPostView(FullPostView? value) {
_$fullPostViewAtom.reportWrite(value, super.fullPostView, () {
super.fullPostView = value;
});
}
final _$newCommentsAtom = Atom(name: '_FullPostStore.newComments');
@override
ObservableList<CommentView> get newComments {
_$newCommentsAtom.reportRead();
return super.newComments;
}
@override
set newComments(ObservableList<CommentView> value) {
_$newCommentsAtom.reportWrite(value, super.newComments, () {
super.newComments = value;
});
}
final _$sortingAtom = Atom(name: '_FullPostStore.sorting');
@override
CommentSortType get sorting {
_$sortingAtom.reportRead();
return super.sorting;
}
@override
set sorting(CommentSortType value) {
_$sortingAtom.reportWrite(value, super.sorting, () {
super.sorting = value;
});
}
final _$postStoreAtom = Atom(name: '_FullPostStore.postStore');
@override
PostStore? get postStore {
_$postStoreAtom.reportRead();
return super.postStore;
}
@override
set postStore(PostStore? value) {
_$postStoreAtom.reportWrite(value, super.postStore, () {
super.postStore = value;
});
}
final _$refreshAsyncAction = AsyncAction('_FullPostStore.refresh');
@override
Future<void> refresh([Jwt? token]) {
return _$refreshAsyncAction.run(() => super.refresh(token));
}
final _$blockCommunityAsyncAction =
AsyncAction('_FullPostStore.blockCommunity');
@override
Future<void> blockCommunity(Jwt token) {
return _$blockCommunityAsyncAction.run(() => super.blockCommunity(token));
}
final _$_FullPostStoreActionController =
ActionController(name: '_FullPostStore');
@override
void updateSorting(CommentSortType sort) {
final _$actionInfo = _$_FullPostStoreActionController.startAction(
name: '_FullPostStore.updateSorting');
try {
return super.updateSorting(sort);
} finally {
_$_FullPostStoreActionController.endAction(_$actionInfo);
}
}
@override
void addComment(CommentView commentView) {
final _$actionInfo = _$_FullPostStoreActionController.startAction(
name: '_FullPostStore.addComment');
try {
return super.addComment(commentView);
} finally {
_$_FullPostStoreActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
fullPostView: ${fullPostView},
newComments: ${newComments},
sorting: ${sorting},
postStore: ${postStore},
commentTree: ${commentTree},
sortedCommentTree: ${sortedCommentTree},
postView: ${postView},
comments: ${comments}
''';
}
}

View File

@ -13,8 +13,8 @@ import '../widgets/bottom_modal.dart';
import '../widgets/cached_network_image.dart';
import '../widgets/infinite_scroll.dart';
import '../widgets/sortable_infinite_list.dart';
import 'add_account.dart';
import 'inbox.dart';
import 'settings/add_account.dart';
/// First thing users sees when opening the app
/// Shows list of posts from all or just specific instances

View File

@ -13,7 +13,7 @@ import '../util/delayed_action.dart';
import '../util/extensions/api.dart';
import '../util/extensions/datetime.dart';
import '../util/goto.dart';
import '../util/more_icon.dart';
import '../util/icons.dart';
import '../widgets/bottom_modal.dart';
import '../widgets/cached_network_image.dart';
import '../widgets/comment/comment.dart';

View File

@ -8,7 +8,7 @@ import '../l10n/l10n.dart';
import '../util/extensions/api.dart';
import '../util/extensions/spaced.dart';
import '../util/goto.dart';
import '../util/more_icon.dart';
import '../util/icons.dart';
import '../util/share.dart';
import '../util/text_color.dart';
import '../widgets/avatar.dart';

View File

@ -9,7 +9,7 @@ import '../hooks/delayed_loading.dart';
import '../hooks/image_picker.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../util/more_icon.dart';
import '../util/icons.dart';
import '../util/pictrs.dart';
import '../widgets/bottom_modal.dart';
import '../widgets/bottom_safe.dart';

View File

@ -6,7 +6,7 @@ import '../util/goto.dart';
import '../widgets/radio_picker.dart';
import '../widgets/user_profile.dart';
import 'saved_page.dart';
import 'settings.dart';
import 'settings/settings.dart';
/// Profile page for a logged in user. The difference between this and
/// UserPage is that here you have access to settings

View File

@ -5,7 +5,7 @@ import 'package:lemmy_api_client/v3.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../widgets/comment/comment.dart';
import '../widgets/post.dart';
import '../widgets/post/post.dart';
import '../widgets/sortable_infinite_list.dart';
import 'communities_list.dart';
import 'users_list.dart';
@ -108,7 +108,7 @@ class _SearchResultsList extends HookWidget {
case SearchType.posts:
return Padding(
padding: const EdgeInsets.only(bottom: 20),
child: PostWidget(data as PostView),
child: PostTile.fromPostView(data as PostView),
);
case SearchType.users:
return UsersListItem(user: data as PersonViewSafe);

View File

@ -4,13 +4,13 @@ import 'package:lemmy_api_client/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import '../hooks/delayed_loading.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../stores/config_store.dart';
import '../widgets/cached_network_image.dart';
import '../widgets/fullscreenable_image.dart';
import '../widgets/radio_picker.dart';
import '../../hooks/delayed_loading.dart';
import '../../hooks/stores.dart';
import '../../l10n/l10n.dart';
import '../../stores/config_store.dart';
import '../../widgets/cached_network_image.dart';
import '../../widgets/fullscreenable_image.dart';
import '../../widgets/radio_picker.dart';
import 'add_instance.dart';
/// A modal where an account can be added for a given instance

View File

@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import '../hooks/debounce.dart';
import '../hooks/stores.dart';
import '../util/cleanup_url.dart';
import '../widgets/cached_network_image.dart';
import '../widgets/fullscreenable_image.dart';
import '../../hooks/debounce.dart';
import '../../hooks/stores.dart';
import '../../util/cleanup_url.dart';
import '../../widgets/cached_network_image.dart';
import '../../widgets/fullscreenable_image.dart';
/// A page that let's user add a new instance. Pops a url of the added instance
class AddInstancePage extends HookWidget {

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:provider/provider.dart';
import '../../../util/async_store_listener.dart';
import '../../../util/extensions/api.dart';
import '../../../util/goto.dart';
import '../../../util/observer_consumers.dart';
import '../../../widgets/avatar.dart';
import 'community_block_store.dart';
import 'user_block_store.dart';
class BlockPersonTile extends StatelessWidget {
const BlockPersonTile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return AsyncStoreListener(
asyncStore: context.read<UserBlockStore>().unblockingState,
child: ObserverBuilder<UserBlockStore>(
builder: (context, store) {
return ListTile(
leading: Avatar(url: store.person.avatar),
title: Text(store.person.originPreferredName),
trailing: IconButton(
icon: store.unblockingState.isLoading
? const CircularProgressIndicator.adaptive()
: const Icon(Icons.cancel),
tooltip: 'unblock',
onPressed: store.unblock,
),
onTap: () {
goToUser.byId(
context, store.person.instanceHost, store.person.id);
},
);
},
),
);
}
}
class BlockCommunityTile extends HookWidget {
const BlockCommunityTile({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return AsyncStoreListener(
asyncStore: context.read<CommunityBlockStore>().unblockingState,
child: ObserverBuilder<CommunityBlockStore>(
builder: (context, store) {
return ListTile(
leading: Avatar(url: store.community.icon),
title: Text(store.community.originPreferredName),
trailing: IconButton(
icon: store.unblockingState.isLoading
? const CircularProgressIndicator.adaptive()
: const Icon(Icons.cancel),
tooltip: 'unblock',
onPressed: store.unblock,
),
onTap: () {
goToCommunity.byId(
context, store.community.instanceHost, store.community.id);
},
);
},
),
);
}
}

View File

@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:provider/provider.dart';
import '../../../hooks/stores.dart';
import '../../../l10n/l10n_from_string.dart';
import '../../../stores/accounts_store.dart';
import '../../../util/async_store_listener.dart';
import '../../../util/observer_consumers.dart';
import 'block_tile.dart';
import 'blocks_store.dart';
class BlocksPage extends HookWidget {
const BlocksPage._();
@override
Widget build(BuildContext context) {
final accStore = useAccountsStore();
return DefaultTabController(
length: accStore.loggedInInstances.length,
child: Scaffold(
appBar: AppBar(
title: const Text('Blocks'),
bottom: TabBar(
isScrollable: true,
tabs: [
for (final instance in accStore.loggedInInstances)
Tab(
child: Text(
'${accStore.defaultUsernameFor(instance)!}@$instance'),
),
],
),
),
body: TabBarView(
children: [
for (final instance in accStore.loggedInInstances)
_UserBlocksWrapper(
instanceHost: instance,
username: accStore.defaultUsernameFor(instance)!,
)
],
),
),
);
}
static Route route() =>
MaterialPageRoute(builder: (context) => const BlocksPage._());
}
class _UserBlocksWrapper extends StatelessWidget {
final String instanceHost;
final String username;
const _UserBlocksWrapper(
{required this.instanceHost, required this.username});
@override
Widget build(BuildContext context) {
return Provider<BlocksStore>(
create: (context) => BlocksStore(
instanceHost: instanceHost,
token: context
.read<AccountsStore>()
.userDataFor(instanceHost, username)!
.jwt,
)..refresh(),
child: const _UserBlocks(),
);
}
}
class _UserBlocks extends StatelessWidget {
const _UserBlocks();
@override
Widget build(BuildContext context) {
return AsyncStoreListener(
asyncStore: context.read<BlocksStore>().blocksState,
child: ObserverBuilder<BlocksStore>(
builder: (context, store) {
return RefreshIndicator(
onRefresh: store.refresh,
child: ListView(
children: [
if (!store.isUsable) ...[
if (store.blocksState.isLoading)
const Padding(
padding: EdgeInsets.only(top: 64),
child: Center(
child: CircularProgressIndicator.adaptive(),
),
)
else if (store.blocksState.errorTerm != null)
Padding(
padding: const EdgeInsets.only(top: 64),
child: Center(
child: Text(
store.blocksState.errorTerm!.tr(context),
),
),
)
] else ...[
for (final user in store.blockedUsers!)
Provider(
create: (context) => user,
key: ValueKey(user),
child: const BlockPersonTile(),
),
if (store.blockedUsers!.isEmpty)
const ListTile(
title: Center(
child: Text('No users blocked'),
),
),
// TODO: add user search & block
// ListTile(
// leading: const Padding(
// padding: EdgeInsets.only(left: 16, right: 10),
// child: Icon(Icons.add),
// ),
// onTap: () {},
// title: const Text('Block User'),
// ),
const Divider(),
for (final community in store.blockedCommunities!)
Provider(
create: (context) => community,
key: ValueKey(community),
child: const BlockCommunityTile(),
),
if (store.blockedCommunities!.isEmpty)
const ListTile(
title: Center(
child: Text('No communities blocked'),
),
),
// TODO: add community search & block
// const ListTile(
// leading: Padding(
// padding: EdgeInsets.only(left: 16, right: 10),
// child: Icon(Icons.add),
// ),
// onTap: () {},
// title: Text('Block Community'),
// ),
],
],
),
);
},
),
);
}
}

View File

@ -0,0 +1,53 @@
import 'package:lemmy_api_client/v3.dart';
import 'package:mobx/mobx.dart';
import '../../../util/async_store.dart';
import 'community_block_store.dart';
import 'user_block_store.dart';
part 'blocks_store.g.dart';
class BlocksStore = _BlocksStore with _$BlocksStore;
abstract class _BlocksStore with Store {
final String instanceHost;
final Jwt token;
_BlocksStore({required this.instanceHost, required this.token});
@observable
List<UserBlockStore>? _blockedUsers;
@observable
List<CommunityBlockStore>? _blockedCommunities;
final blocksState = AsyncStore<FullSiteView>();
@computed
Iterable<UserBlockStore>? get blockedUsers =>
_blockedUsers?.where((u) => u.blocked);
@computed
Iterable<CommunityBlockStore>? get blockedCommunities =>
_blockedCommunities?.where((c) => c.blocked);
@computed
bool get isUsable => blockedUsers != null && blockedCommunities != null;
@action
Future<void> refresh() async {
final result =
await blocksState.runLemmy(instanceHost, GetSite(auth: token.raw));
if (result != null) {
_blockedUsers = result.myUser!.personBlocks
.map((e) => UserBlockStore(
instanceHost: instanceHost, token: token, person: e.target))
.toList();
_blockedCommunities = result.myUser!.communityBlocks
.map((e) => CommunityBlockStore(
instanceHost: instanceHost, token: token, community: e.community))
.toList();
}
}
}

View File

@ -0,0 +1,81 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'blocks_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$BlocksStore on _BlocksStore, Store {
Computed<Iterable<UserBlockStore>?>? _$blockedUsersComputed;
@override
Iterable<UserBlockStore>? get blockedUsers => (_$blockedUsersComputed ??=
Computed<Iterable<UserBlockStore>?>(() => super.blockedUsers,
name: '_BlocksStore.blockedUsers'))
.value;
Computed<Iterable<CommunityBlockStore>?>? _$blockedCommunitiesComputed;
@override
Iterable<CommunityBlockStore>? get blockedCommunities =>
(_$blockedCommunitiesComputed ??=
Computed<Iterable<CommunityBlockStore>?>(
() => super.blockedCommunities,
name: '_BlocksStore.blockedCommunities'))
.value;
Computed<bool>? _$isUsableComputed;
@override
bool get isUsable => (_$isUsableComputed ??=
Computed<bool>(() => super.isUsable, name: '_BlocksStore.isUsable'))
.value;
final _$_blockedUsersAtom = Atom(name: '_BlocksStore._blockedUsers');
@override
List<UserBlockStore>? get _blockedUsers {
_$_blockedUsersAtom.reportRead();
return super._blockedUsers;
}
@override
set _blockedUsers(List<UserBlockStore>? value) {
_$_blockedUsersAtom.reportWrite(value, super._blockedUsers, () {
super._blockedUsers = value;
});
}
final _$_blockedCommunitiesAtom =
Atom(name: '_BlocksStore._blockedCommunities');
@override
List<CommunityBlockStore>? get _blockedCommunities {
_$_blockedCommunitiesAtom.reportRead();
return super._blockedCommunities;
}
@override
set _blockedCommunities(List<CommunityBlockStore>? value) {
_$_blockedCommunitiesAtom.reportWrite(value, super._blockedCommunities, () {
super._blockedCommunities = value;
});
}
final _$refreshAsyncAction = AsyncAction('_BlocksStore.refresh');
@override
Future<void> refresh() {
return _$refreshAsyncAction.run(() => super.refresh());
}
@override
String toString() {
return '''
blockedUsers: ${blockedUsers},
blockedCommunities: ${blockedCommunities},
isUsable: ${isUsable}
''';
}
}

View File

@ -0,0 +1,38 @@
import 'package:lemmy_api_client/v3.dart';
import 'package:mobx/mobx.dart';
import '../../../util/async_store.dart';
part 'community_block_store.g.dart';
class CommunityBlockStore = _CommunityBlockStore with _$CommunityBlockStore;
abstract class _CommunityBlockStore with Store {
final String instanceHost;
final Jwt token;
final CommunitySafe community;
_CommunityBlockStore({
required this.instanceHost,
required this.token,
required this.community,
});
final unblockingState = AsyncStore<BlockedCommunity>();
@observable
bool blocked = true;
Future<void> unblock() async {
final result = await unblockingState.runLemmy(
instanceHost,
BlockCommunity(
communityId: community.id,
block: false,
auth: token.raw,
));
if (result != null) {
blocked = result.blocked;
}
}
}

View File

@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'community_block_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$CommunityBlockStore on _CommunityBlockStore, Store {
final _$blockedAtom = Atom(name: '_CommunityBlockStore.blocked');
@override
bool get blocked {
_$blockedAtom.reportRead();
return super.blocked;
}
@override
set blocked(bool value) {
_$blockedAtom.reportWrite(value, super.blocked, () {
super.blocked = value;
});
}
@override
String toString() {
return '''
blocked: ${blocked}
''';
}
}

View File

@ -0,0 +1,38 @@
import 'package:lemmy_api_client/v3.dart';
import 'package:mobx/mobx.dart';
import '../../../util/async_store.dart';
part 'user_block_store.g.dart';
class UserBlockStore = _UserBlockStore with _$UserBlockStore;
abstract class _UserBlockStore with Store {
final String instanceHost;
final Jwt token;
final PersonSafe person;
_UserBlockStore({
required this.instanceHost,
required this.token,
required this.person,
});
final unblockingState = AsyncStore<BlockedPerson>();
@observable
bool blocked = true;
Future<void> unblock() async {
final result = await unblockingState.runLemmy(
instanceHost,
BlockPerson(
personId: person.id,
block: false,
auth: token.raw,
));
if (result != null) {
blocked = result.blocked;
}
}
}

View File

@ -0,0 +1,33 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_block_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$UserBlockStore on _UserBlockStore, Store {
final _$blockedAtom = Atom(name: '_UserBlockStore.blocked');
@override
bool get blocked {
_$blockedAtom.reportRead();
return super.blocked;
}
@override
set blocked(bool value) {
_$blockedAtom.reportWrite(value, super.blocked, () {
super.blocked = value;
});
}
@override
String toString() {
return '''
blocked: ${blocked}
''';
}
}

View File

@ -4,52 +4,65 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_speed_dial/flutter_speed_dial.dart';
import 'package:lemmy_api_client/v3.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../util/goto.dart';
import '../widgets/about_tile.dart';
import '../widgets/bottom_modal.dart';
import '../widgets/radio_picker.dart';
import '../../hooks/stores.dart';
import '../../l10n/l10n.dart';
import '../../util/goto.dart';
import '../../widgets/about_tile.dart';
import '../../widgets/bottom_modal.dart';
import '../../widgets/radio_picker.dart';
import '../manage_account.dart';
import 'add_account.dart';
import 'add_instance.dart';
import 'manage_account.dart';
import 'blocks/blocks.dart';
/// Page with a list of different settings sections
class SettingsPage extends StatelessWidget {
class SettingsPage extends HookWidget {
const SettingsPage();
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
title: Text(L10n.of(context)!.settings),
),
body: ListView(
children: [
Widget build(BuildContext context) {
final hasAnyUsers = useAccountsStoreSelect((store) => !store.hasNoAccount);
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context)!.settings),
),
body: ListView(
children: [
ListTile(
leading: const Icon(Icons.settings),
title: const Text('General'),
onTap: () {
goTo(context, (_) => const GeneralConfigPage());
},
),
ListTile(
leading: const Icon(Icons.person),
title: const Text('Accounts'),
onTap: () {
goTo(context, (_) => AccountsConfigPage());
},
),
if (hasAnyUsers)
ListTile(
leading: const Icon(Icons.settings),
title: const Text('General'),
leading: const Icon(Icons.block),
title: const Text('Blocks'),
onTap: () {
goTo(context, (_) => const GeneralConfigPage());
Navigator.of(context).push(BlocksPage.route());
},
),
ListTile(
leading: const Icon(Icons.person),
title: const Text('Accounts'),
onTap: () {
goTo(context, (_) => AccountsConfigPage());
},
),
ListTile(
leading: const Icon(Icons.color_lens),
title: const Text('Appearance'),
onTap: () {
goTo(context, (_) => const AppearanceConfigPage());
},
),
const AboutTile()
],
),
);
ListTile(
leading: const Icon(Icons.color_lens),
title: const Text('Appearance'),
onTap: () {
goTo(context, (_) => const AppearanceConfigPage());
},
),
const AboutTile()
],
),
);
}
}
/// Settings for theme color, AMOLED switch

View File

@ -5,21 +5,24 @@ import '../l10n/l10n_from_string.dart';
import 'async_store.dart';
import 'observer_consumers.dart';
class AsyncStoreListener extends StatelessWidget {
final AsyncStore asyncStore;
final String? successMessage;
class AsyncStoreListener<T> extends StatelessWidget {
final AsyncStore<T> asyncStore;
final String Function(
BuildContext context,
T data,
)? successMessageBuilder;
final Widget child;
const AsyncStoreListener({
Key? key,
required this.asyncStore,
this.successMessage,
this.successMessageBuilder,
required this.child,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return ObserverListener<AsyncStore>(
return ObserverListener<AsyncStore<T>>(
store: asyncStore,
listener: (context, store) {
final errorTerm = store.errorTerm;
@ -29,10 +32,12 @@ class AsyncStoreListener extends StatelessWidget {
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(errorTerm.tr(context))));
} else if (store.asyncState is AsyncStateData &&
successMessage != null) {
(successMessageBuilder != null)) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(successMessage!)));
..showSnackBar(SnackBar(
content: Text(successMessageBuilder!(
context, (store.asyncState as AsyncStateData).data))));
}
},
child: child,

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:lemmy_api_client/v3.dart';
import '../pages/community.dart';
import '../pages/full_post.dart';
import '../pages/full_post/full_post.dart';
import '../pages/instance.dart';
import '../pages/media_view.dart';
import '../pages/user.dart';
@ -62,8 +62,8 @@ abstract class goToUser {
goToUser.byId(context, personSafe.instanceHost, personSafe.id);
}
void goToPost(BuildContext context, String instanceHost, int postId) => goTo(
context, (context) => FullPostPage(instanceHost: instanceHost, id: postId));
void goToPost(BuildContext context, String instanceHost, int postId) =>
Navigator.of(context).push(FullPostPage.route(postId, instanceHost));
void goToMedia(BuildContext context, String url) => Navigator.push(
context,

9
lib/util/icons.dart Normal file
View File

@ -0,0 +1,9 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
final _isApple = Platform.isIOS || Platform.isMacOS;
final moreIcon = _isApple ? Icons.more_horiz : Icons.more_vert;
final shareIcon = _isApple ? Icons.ios_share : Icons.share;

View File

@ -1,6 +0,0 @@
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
final moreIcon =
(Platform.isIOS || Platform.isMacOS) ? Icons.more_horiz : Icons.more_vert;

View File

@ -90,13 +90,20 @@ class CommentWidget extends StatelessWidget {
detached: detached,
hideOnRead: hideOnRead,
),
builder: (context, child) => AsyncStoreListener(
asyncStore: context.read<CommentStore>().votingState,
builder: (context, child) => AsyncStoreListener<BlockedPerson>(
asyncStore: context.read<CommentStore>().blockingState,
successMessageBuilder: (context, state) {
final name = state.personView.person.preferredName;
return state.blocked ? '$name blocked' : '$name unblocked';
},
child: AsyncStoreListener(
asyncStore: context.read<CommentStore>().deletingState,
asyncStore: context.read<CommentStore>().votingState,
child: AsyncStoreListener(
asyncStore: context.read<CommentStore>().savingState,
child: const _CommentWidget(),
asyncStore: context.read<CommentStore>().deletingState,
child: AsyncStoreListener(
asyncStore: context.read<CommentStore>().savingState,
child: const _CommentWidget(),
),
),
),
),

View File

@ -128,18 +128,33 @@ class _CommentMoreMenuPopup extends HookWidget {
Navigator.of(context).pop();
},
),
if (store.isMine)
if (store.isMine) ...[
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Edit'),
onTap: handleEdit,
),
if (store.isMine)
ListTile(
leading: Icon(comment.deleted ? Icons.restore : Icons.delete),
title: Text(comment.deleted ? 'Restore' : 'Delete'),
onTap: loggedInAction(handleDelete),
),
] else
ListTile(
leading: store.blockingState.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(),
)
: const Icon(Icons.block),
title: Text(
'${store.comment.creatorBlocked ? 'Unblock' : 'Block'} ${store.comment.creator.preferredName}'),
onTap: loggedInAction((token) {
Navigator.of(context).pop();
store.block(token);
}),
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Nerd stuff'),

View File

@ -35,6 +35,7 @@ abstract class _CommentStore with Store {
final savingState = AsyncStore<FullCommentView>();
final markPersonMentionAsReadState = AsyncStore<PersonMentionView>();
final markAsReadState = AsyncStore<FullCommentView>();
final blockingState = AsyncStore<BlockedPerson>();
@computed
bool get isMine =>
@ -103,6 +104,21 @@ abstract class _CommentStore with Store {
if (result != null) comment = result.commentView;
}
@action
Future<void> block(Jwt token) async {
final result = await blockingState.runLemmy(
comment.instanceHost,
BlockPerson(
personId: comment.creator.id,
block: !comment.creatorBlocked,
auth: token.raw,
),
);
if (result != null) {
comment = comment.copyWith(creatorBlocked: result.blocked);
}
}
@action
Future<void> markAsRead(Jwt token) async {
if (userMentionId != null) {

View File

@ -102,6 +102,13 @@ mixin _$CommentStore on _CommentStore, Store {
return _$saveAsyncAction.run(() => super.save(token));
}
final _$blockAsyncAction = AsyncAction('_CommentStore.block');
@override
Future<void> block(Jwt token) {
return _$blockAsyncAction.run(() => super.block(token));
}
final _$markAsReadAsyncAction = AsyncAction('_CommentStore.markAsRead');
@override

View File

@ -1,111 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import '../comment_tree.dart';
import '../l10n/l10n.dart';
import 'bottom_modal.dart';
import 'bottom_safe.dart';
import 'comment/comment.dart';
/// Manages comments section, sorts them
class CommentSection extends HookWidget {
final List<CommentView> rawComments;
final List<CommentTree> comments;
final int postCreatorId;
final CommentSortType sortType;
static const sortPairs = {
CommentSortType.hot: [Icons.whatshot, L10nStrings.hot],
CommentSortType.new_: [Icons.new_releases, L10nStrings.new_],
CommentSortType.old: [Icons.calendar_today, L10nStrings.old],
CommentSortType.top: [Icons.trending_up, L10nStrings.top],
CommentSortType.chat: [Icons.chat, L10nStrings.chat],
};
CommentSection(
List<CommentView> rawComments, {
required this.postCreatorId,
this.sortType = CommentSortType.hot,
}) : comments =
CommentTree.sortList(sortType, CommentTree.fromList(rawComments)),
rawComments = rawComments
..sort((b, a) => a.comment.published.compareTo(b.comment.published));
@override
Widget build(BuildContext context) {
final sorting = useState(sortType);
void sortComments(CommentSortType sort) {
if (sort != sorting.value && sort != CommentSortType.chat) {
CommentTree.sortList(sort, comments);
}
sorting.value = sort;
}
return Column(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15),
child: Row(
children: [
OutlinedButton(
onPressed: () {
showBottomModal(
title: 'sort by',
context: context,
builder: (context) => Column(
children: [
for (final e in sortPairs.entries)
ListTile(
leading: Icon(e.value[0] as IconData),
title: Text((e.value[1] as String).tr(context)),
trailing: sorting.value == e.key
? const Icon(Icons.check)
: null,
onTap: () {
Navigator.of(context).pop();
sortComments(e.key);
},
)
],
),
);
},
child: Row(
children: [
Text((sortPairs[sorting.value]![1] as String).tr(context)),
const Icon(Icons.arrow_drop_down),
],
),
),
const Spacer(),
],
),
),
// sorting menu goes here
if (comments.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 50),
child: Text(
'no comments yet',
style: TextStyle(fontStyle: FontStyle.italic),
),
)
else if (sorting.value == CommentSortType.chat)
for (final com in rawComments)
CommentWidget.fromCommentView(
com,
detached: false,
key: ValueKey(com),
)
else
for (final com in comments)
CommentWidget(
com,
key: ValueKey(com),
),
const BottomSafe.fab(),
]);
}
}

View File

@ -1,595 +0,0 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:intl/intl.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import '../hooks/delayed_loading.dart';
import '../hooks/logged_in_action.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../pages/create_post.dart';
import '../pages/full_post.dart';
import '../stores/accounts_store.dart';
import '../url_launcher.dart';
import '../util/cleanup_url.dart';
import '../util/extensions/api.dart';
import '../util/extensions/datetime.dart';
import '../util/goto.dart';
import '../util/more_icon.dart';
import '../util/share.dart';
import 'avatar.dart';
import 'bottom_modal.dart';
import 'cached_network_image.dart';
import 'fullscreenable_image.dart';
import 'info_table_popup.dart';
import 'markdown_text.dart';
import 'save_post_button.dart';
enum MediaType {
image,
gallery,
video,
other,
none,
}
MediaType whatType(String? url) {
if (url == null || url.isEmpty) return MediaType.none;
// TODO: make detection more nuanced
if (url.endsWith('.jpg') ||
url.endsWith('.jpeg') ||
url.endsWith('.png') ||
url.endsWith('.gif') ||
url.endsWith('.webp') ||
url.endsWith('.bmp') ||
url.endsWith('.wbpm')) {
return MediaType.image;
}
return MediaType.other;
}
/// A post overview card
class PostWidget extends HookWidget {
final PostView post;
final String instanceHost;
final bool fullPost;
PostWidget(this.post, {this.fullPost = false})
: instanceHost = post.instanceHost;
// == ACTIONS ==
static void showMoreMenu({
required BuildContext context,
required PostView post,
bool fullPost = false,
}) {
final isMine = context
.read<AccountsStore>()
.defaultUserDataFor(post.instanceHost)
?.userId ==
post.creator.id;
showBottomModal(
context: context,
builder: (context) => Column(
children: [
ListTile(
leading: const Icon(Icons.open_in_browser),
title: const Text('Open in browser'),
onTap: () async => await ul.canLaunch(post.post.apId)
? ul.launch(post.post.apId)
: ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("can't open in browser"))),
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Nerd stuff'),
onTap: () {
showInfoTablePopup(context: context, table: {
'% of upvotes':
'${(100 * (post.counts.upvotes / (post.counts.upvotes + post.counts.downvotes))).toInt()}%',
...post.toJson(),
});
},
),
if (isMine)
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Edit'),
onTap: () async {
final postView = await Navigator.of(context).push(
CreatePostPage.editRoute(post.post),
);
if (postView != null) {
Navigator.of(context).pop();
if (fullPost) {
await goToReplace(
context,
(_) => FullPostPage.fromPostView(postView),
);
} else {
await goTo(
context,
(_) => FullPostPage.fromPostView(postView),
);
}
}
},
),
],
),
);
}
// == UI ==
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
void _openLink(String url) =>
linkLauncher(context: context, url: url, instanceHost: instanceHost);
final urlDomain = () {
if (whatType(post.post.url) == MediaType.none) return null;
return urlHost(post.post.url!);
}();
/// assemble info section
Widget info() => Column(
children: [
Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
Column(
mainAxisSize: MainAxisSize.min,
children: [
if (post.community.icon != null)
Padding(
padding: const EdgeInsets.only(right: 10),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => goToCommunity.byId(
context, instanceHost, post.community.id),
child: Avatar(
url: post.community.icon,
noBlank: true,
radius: 20,
),
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
RichText(
overflow:
TextOverflow.ellipsis, // TODO: fix overflowing
text: TextSpan(
style: TextStyle(
fontSize: 15,
color: theme.textTheme.bodyText1?.color),
children: [
const TextSpan(
text: '!',
style:
TextStyle(fontWeight: FontWeight.w300)),
TextSpan(
text: post.community.name,
style: const TextStyle(
fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => goToCommunity.byId(
context,
instanceHost,
post.community.id)),
const TextSpan(
text: '@',
style:
TextStyle(fontWeight: FontWeight.w300)),
TextSpan(
text: post.post.originInstanceHost,
style: const TextStyle(
fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => goToInstance(context,
post.post.originInstanceHost)),
],
),
)
],
),
Row(
children: [
RichText(
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: TextStyle(
fontSize: 13,
color: theme.textTheme.bodyText1?.color),
children: [
TextSpan(
text: L10n.of(context)!.by,
style: const TextStyle(
fontWeight: FontWeight.w300),
),
TextSpan(
text: ' ${post.creator.originPreferredName}',
style: const TextStyle(
fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => goToUser.fromPersonSafe(
context,
post.creator,
),
),
TextSpan(
text:
' · ${post.post.published.fancyShort}'),
if (post.post.locked)
const TextSpan(text: ' · 🔒'),
if (post.post.stickied)
const TextSpan(text: ' · 📌'),
if (post.post.nsfw) const TextSpan(text: ' · '),
if (post.post.nsfw)
TextSpan(
text: L10n.of(context)!.nsfw,
style:
const TextStyle(color: Colors.red)),
if (urlDomain != null)
TextSpan(text: ' · $urlDomain'),
if (post.post.removed)
const TextSpan(text: ' · REMOVED'),
if (post.post.deleted)
const TextSpan(text: ' · DELETED'),
],
),
)
],
),
],
),
const Spacer(),
if (!fullPost)
Column(
children: [
IconButton(
onPressed: () =>
showMoreMenu(context: context, post: post),
icon: Icon(moreIcon),
padding: const EdgeInsets.all(0),
visualDensity: VisualDensity.compact,
)
],
)
],
),
),
],
);
/// assemble title section
Widget title() => Padding(
padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10),
child: Row(
children: [
Expanded(
flex: 100,
child: Text(
post.post.name,
textAlign: TextAlign.left,
softWrap: true,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w600),
),
),
if (whatType(post.post.url) == MediaType.other &&
post.post.thumbnailUrl != null) ...[
const Spacer(),
InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _openLink(post.post.url!),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CachedNetworkImage(
imageUrl: post.post.thumbnailUrl!,
width: 70,
height: 70,
fit: BoxFit.cover,
errorBuilder: (context, error) =>
Text(error.toString()),
),
),
const Positioned(
top: 8,
right: 8,
child: Icon(
Icons.launch,
size: 20,
),
)
],
),
)
]
],
),
);
/// assemble link preview
Widget linkPreview() {
assert(post.post.url != null);
return Padding(
padding: const EdgeInsets.all(10),
child: InkWell(
onTap: () => _openLink(post.post.url!),
child: Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).iconTheme.color!.withAlpha(170)),
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
children: [
Row(
children: [
const Spacer(),
Text('$urlDomain ',
style: theme.textTheme.caption
?.apply(fontStyle: FontStyle.italic)),
const Icon(Icons.launch, size: 12),
],
),
Row(
children: [
Flexible(
child: Text(
post.post.embedTitle ?? '',
style: theme.textTheme.subtitle1
?.apply(fontWeightDelta: 2),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
)
],
),
if (post.post.embedDescription != null &&
post.post.embedDescription!.isNotEmpty)
Row(
children: [
Flexible(
child: Text(
post.post.embedDescription!,
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
)
],
),
],
),
),
),
),
);
}
/// assemble image
Widget postImage() {
assert(post.post.url != null);
return FullscreenableImage(
url: post.post.url!,
child: CachedNetworkImage(
imageUrl: post.post.url!,
errorBuilder: (_, ___) => const Icon(Icons.warning),
loadingBuilder: (context, progress) =>
CircularProgressIndicator(value: progress?.progress),
),
);
}
/// assemble actions section
Widget actions() => Padding(
padding: const EdgeInsets.fromLTRB(10, 5, 10, 5),
child: Row(
children: [
const Icon(Icons.comment),
const SizedBox(width: 6),
Expanded(
flex: 999,
child: Text(
L10n.of(context)!.number_of_comments(post.counts.comments),
overflow: TextOverflow.fade,
softWrap: false,
),
),
const Spacer(),
if (!fullPost)
IconButton(
icon: const Icon(Icons.share),
onPressed: () => share(post.post.apId, context: context),
),
if (!fullPost) SavePostButton(post),
_Voting(post),
],
),
);
return Container(
decoration: BoxDecoration(
boxShadow: const [BoxShadow(blurRadius: 10, color: Colors.black45)],
color: theme.cardColor,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: GestureDetector(
onTap: fullPost
? null
: () => goTo(context, (context) => FullPostPage.fromPostView(post)),
child: Material(
type: MaterialType.transparency,
child: Column(
children: [
info(),
title(),
if (whatType(post.post.url) != MediaType.other &&
whatType(post.post.url) != MediaType.none)
postImage()
else if (post.post.url != null && post.post.url!.isNotEmpty)
linkPreview(),
if (post.post.body != null && fullPost)
Padding(
padding: const EdgeInsets.all(10),
child: MarkdownText(
post.post.body!,
instanceHost: instanceHost,
selectable: true,
),
),
if (post.post.body != null && !fullPost)
LayoutBuilder(
builder: (context, constraints) {
final span = TextSpan(
text: post.post.body,
);
final tp = TextPainter(
text: span,
maxLines: 10,
textDirection: Directionality.of(context),
)..layout(maxWidth: constraints.maxWidth - 20);
if (tp.didExceedMaxLines) {
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: tp.height),
child: Stack(
alignment: Alignment.bottomCenter,
children: [
ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: 0.8,
child: Padding(
padding: const EdgeInsets.all(10),
child: MarkdownText(post.post.body!,
instanceHost: instanceHost)),
),
),
Container(
height: tp.preferredLineHeight * 2.5,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
theme.cardColor.withAlpha(0),
theme.cardColor,
],
),
),
),
],
),
);
} else {
return Padding(
padding: const EdgeInsets.all(10),
child: MarkdownText(post.post.body!,
instanceHost: instanceHost));
}
},
),
actions(),
],
),
),
),
);
}
}
class _Voting extends HookWidget {
final PostView post;
final bool wasVoted;
_Voting(this.post)
: wasVoted = (post.myVote ?? VoteType.none) != VoteType.none;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final myVote = useState(post.myVote ?? VoteType.none);
final loading = useDelayedLoading();
final showScores =
useConfigStoreSelect((configStore) => configStore.showScores);
final loggedInAction = useLoggedInAction(post.instanceHost);
vote(VoteType vote, Jwt token) async {
final api = LemmyApiV3(post.instanceHost);
loading.start();
try {
final res = await api.run(
CreatePostLike(postId: post.post.id, score: vote, auth: token.raw));
myVote.value = res.myVote ?? VoteType.none;
} catch (e) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('voting failed :(')));
return;
}
loading.cancel();
}
return Row(
children: [
IconButton(
icon: Icon(
Icons.arrow_upward,
color: myVote.value == VoteType.up
? theme.colorScheme.secondary
: null,
),
onPressed: loggedInAction(
(token) => vote(
myVote.value == VoteType.up ? VoteType.none : VoteType.up,
token,
),
),
),
if (loading.loading)
const SizedBox(
width: 20, height: 20, child: CircularProgressIndicator())
else if (showScores)
Text(NumberFormat.compact()
.format(post.counts.score + (wasVoted ? 0 : myVote.value.value))),
IconButton(
icon: Icon(
Icons.arrow_downward,
color: myVote.value == VoteType.down ? Colors.red : null,
),
onPressed: loggedInAction(
(token) => vote(
myVote.value == VoteType.down ? VoteType.none : VoteType.down,
token,
),
),
),
],
);
}
}

View File

@ -0,0 +1,92 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:provider/provider.dart';
import '../../pages/full_post/full_post.dart';
import '../../util/async_store_listener.dart';
import '../../util/extensions/api.dart';
import 'post_actions.dart';
import 'post_body.dart';
import 'post_info_section.dart';
import 'post_link_preview.dart';
import 'post_media.dart';
import 'post_status.dart';
import 'post_store.dart';
import 'post_title.dart';
class PostTile extends StatelessWidget {
final PostStore postStore;
final IsFullPost fullPost;
const PostTile.fromPostStore(this.postStore, {this.fullPost = true});
PostTile.fromPostView(PostView post, {this.fullPost = false})
: postStore = PostStore(post);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider.value(value: postStore),
Provider.value(value: fullPost),
],
builder: (context, child) {
return AsyncStoreListener(
asyncStore: context.read<PostStore>().savingState,
child: AsyncStoreListener(
asyncStore: context.read<PostStore>().votingState,
child: AsyncStoreListener<BlockedPerson>(
asyncStore: context.read<PostStore>().userBlockingState,
successMessageBuilder: (context, state) {
final name = state.personView.person.preferredName;
return state.blocked ? '$name blocked' : '$name unblocked';
},
child: const _Post(),
),
),
);
},
);
}
}
/// A post overview card
class _Post extends StatelessWidget {
const _Post();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isFullPost = context.read<IsFullPost>();
return Container(
decoration: BoxDecoration(
boxShadow: const [BoxShadow(blurRadius: 10, color: Colors.black45)],
color: theme.cardColor,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
child: GestureDetector(
onTap: isFullPost
? null
: () {
final postStore = context.read<PostStore>();
Navigator.of(context)
.push(FullPostPage.fromPostStoreRoute(postStore));
},
child: Material(
type: MaterialType.transparency,
child: Column(
children: const [
PostInfoSection(),
PostTitle(),
PostMedia(),
PostLinkPreview(),
PostBody(),
PostActions(),
],
),
),
),
);
}
}

View File

@ -0,0 +1,51 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:provider/provider.dart';
import '../../l10n/l10n.dart';
import '../../util/icons.dart';
import '../../util/observer_consumers.dart';
import '../../util/share.dart';
import 'post_status.dart';
import 'post_store.dart';
import 'post_voting.dart';
import 'save_post_button.dart';
class PostActions extends HookWidget {
const PostActions();
@override
Widget build(BuildContext context) {
final fullPost = context.read<IsFullPost>();
// assemble actions section
return ObserverBuilder<PostStore>(builder: (context, store) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: Row(
children: [
const Icon(Icons.comment),
const SizedBox(width: 6),
Expanded(
child: Text(
L10n.of(context)!
.number_of_comments(store.postView.counts.comments),
overflow: TextOverflow.fade,
softWrap: false,
),
),
if (!fullPost)
IconButton(
icon: Icon(shareIcon),
onPressed: () =>
share(store.postView.post.apId, context: context),
),
if (!fullPost) const SavePostButton(),
const PostVoting(),
],
),
);
});
}
}

View File

@ -0,0 +1,87 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../util/observer_consumers.dart';
import '../markdown_text.dart';
import 'post_status.dart';
import 'post_store.dart';
class PostBody extends StatelessWidget {
const PostBody();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fullPost = context.read<IsFullPost>();
return ObserverBuilder<PostStore>(builder: (context, store) {
final body = store.postView.post.body;
if (body == null) return const SizedBox();
if (fullPost) {
return Padding(
padding: const EdgeInsets.all(10),
child: MarkdownText(
body,
instanceHost: store.postView.instanceHost,
selectable: true,
),
);
} else {
return LayoutBuilder(
builder: (context, constraints) {
final span = TextSpan(
text: body,
);
final tp = TextPainter(
text: span,
maxLines: 10,
textDirection: Directionality.of(context),
)..layout(maxWidth: constraints.maxWidth - 20);
if (tp.didExceedMaxLines) {
return ConstrainedBox(
constraints: BoxConstraints(maxHeight: tp.height),
child: Stack(
alignment: Alignment.bottomCenter,
children: [
ClipRect(
child: Align(
alignment: Alignment.topCenter,
heightFactor: 0.8,
child: Padding(
padding: const EdgeInsets.all(10),
child: MarkdownText(body,
instanceHost: store.postView.instanceHost),
),
),
),
Container(
height: tp.preferredLineHeight * 2.5,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
theme.cardColor.withAlpha(0),
theme.cardColor,
],
),
),
),
],
),
);
} else {
return Padding(
padding: const EdgeInsets.all(10),
child: MarkdownText(body,
instanceHost: store.postView.instanceHost));
}
},
);
}
});
}
}

View File

@ -0,0 +1,129 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/l10n.dart';
import '../../util/extensions/api.dart';
import '../../util/extensions/datetime.dart';
import '../../util/goto.dart';
import '../../util/observer_consumers.dart';
import '../avatar.dart';
import 'post_more_menu.dart';
import 'post_status.dart';
import 'post_store.dart';
class PostInfoSection extends StatelessWidget {
const PostInfoSection();
@override
Widget build(BuildContext context) {
return ObserverBuilder<PostStore>(builder: (context, store) {
final fullPost = context.read<IsFullPost>();
final post = store.postView;
final instanceHost = store.postView.instanceHost;
final theme = Theme.of(context);
return Column(
children: [
Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
if (post.community.icon != null)
Padding(
padding: const EdgeInsets.only(right: 10),
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => goToCommunity.byId(
context, instanceHost, post.community.id),
child: Avatar(
url: post.community.icon,
noBlank: true,
radius: 20,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RichText(
overflow: TextOverflow.ellipsis, // TODO: fix overflowing
text: TextSpan(
style: TextStyle(
fontSize: 15,
color: theme.textTheme.bodyText1?.color),
children: [
const TextSpan(
text: '!',
style: TextStyle(fontWeight: FontWeight.w300)),
TextSpan(
text: post.community.name,
style:
const TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => goToCommunity.byId(
context, instanceHost, post.community.id)),
const TextSpan(
text: '@',
style: TextStyle(fontWeight: FontWeight.w300)),
TextSpan(
text: post.post.originInstanceHost,
style:
const TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => goToInstance(
context, post.post.originInstanceHost)),
],
),
),
RichText(
overflow: TextOverflow.ellipsis,
text: TextSpan(
style: TextStyle(
fontSize: 13,
color: theme.textTheme.bodyText1?.color),
children: [
TextSpan(
text: L10n.of(context)!.by,
style: const TextStyle(fontWeight: FontWeight.w300),
),
TextSpan(
text: ' ${post.creator.originPreferredName}',
style: const TextStyle(fontWeight: FontWeight.w600),
recognizer: TapGestureRecognizer()
..onTap = () => goToUser.fromPersonSafe(
context,
post.creator,
),
),
TextSpan(
text: ' · ${post.post.published.fancyShort}'),
if (post.post.locked) const TextSpan(text: ' · 🔒'),
if (post.post.stickied) const TextSpan(text: ' · 📌'),
if (post.post.nsfw) const TextSpan(text: ' · '),
if (post.post.nsfw)
TextSpan(
text: L10n.of(context)!.nsfw,
style: const TextStyle(color: Colors.red)),
if (store.urlDomain != null)
TextSpan(text: ' · ${store.urlDomain}'),
if (post.post.removed)
const TextSpan(text: ' · REMOVED'),
if (post.post.deleted)
const TextSpan(text: ' · DELETED'),
],
),
)
],
),
const Spacer(),
if (!fullPost) const PostMoreMenuButton(),
],
),
),
],
);
});
}
}

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import '../../url_launcher.dart';
import '../../util/observer_consumers.dart';
import 'post_store.dart';
class PostLinkPreview extends StatelessWidget {
const PostLinkPreview();
@override
Widget build(BuildContext context) {
return ObserverBuilder<PostStore>(
builder: (context, store) {
final url = store.postView.post.url;
if (store.hasMedia || url == null || url.isEmpty) {
return const SizedBox();
}
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(10),
child: InkWell(
onTap: () => linkLauncher(
context: context,
url: url,
instanceHost: store.postView.instanceHost,
),
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).iconTheme.color!.withAlpha(170)),
borderRadius: BorderRadius.circular(5)),
child: Padding(
padding: const EdgeInsets.all(10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Spacer(),
Text('${store.urlDomain} ',
style: theme.textTheme.caption
?.apply(fontStyle: FontStyle.italic)),
const Icon(Icons.launch, size: 12),
],
),
Text(
store.postView.post.embedTitle ?? '',
style:
theme.textTheme.subtitle1?.apply(fontWeightDelta: 2),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (store.postView.post.embedDescription?.isNotEmpty ??
false)
Text(
store.postView.post.embedDescription!,
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
],
),
),
),
),
);
},
);
}
}

View File

@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import '../../util/observer_consumers.dart';
import '../cached_network_image.dart';
import '../fullscreenable_image.dart';
import 'post_store.dart';
/// assembles image
class PostMedia extends StatelessWidget {
const PostMedia();
@override
Widget build(BuildContext context) {
return ObserverBuilder<PostStore>(
builder: (context, store) {
final post = store.postView.post;
if (!store.hasMedia) return const SizedBox();
final url = post.url!; // hasMedia returns false if url is null
return FullscreenableImage(
url: url,
child: CachedNetworkImage(
imageUrl: url,
errorBuilder: (_, ___) => const Icon(Icons.warning),
loadingBuilder: (context, progress) =>
CircularProgressIndicator.adaptive(value: progress?.progress),
),
);
},
);
}
}

View File

@ -0,0 +1,139 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart' as ul;
import '../../hooks/logged_in_action.dart';
import '../../pages/create_post.dart';
import '../../pages/full_post/full_post_store.dart';
import '../../stores/accounts_store.dart';
import '../../util/icons.dart';
import '../../util/observer_consumers.dart';
import '../bottom_modal.dart';
import '../info_table_popup.dart';
import 'post_store.dart';
class PostMoreMenuButton extends StatelessWidget {
const PostMoreMenuButton();
static void show({
required BuildContext context,
required PostStore postStore,
required FullPostStore? fullPostStore,
}) {
// TODO: add blocking!
showBottomModal(
context: context,
builder: (context) =>
PostMoreMenu(postStore: postStore, fullPostStore: fullPostStore),
);
}
@override
Widget build(BuildContext context) {
return IconButton(
onPressed: () => show(
context: context,
postStore: context.read<PostStore>(),
fullPostStore: null,
),
icon: Icon(moreIcon),
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
);
}
}
class PostMoreMenu extends HookWidget {
final PostStore postStore;
final FullPostStore? fullPostStore;
const PostMoreMenu({
required this.postStore,
required this.fullPostStore,
});
@override
Widget build(BuildContext context) {
final loggedInAction = useLoggedInAction(postStore.postView.instanceHost);
final isMine = context
.read<AccountsStore>()
.defaultUserDataFor(postStore.postView.instanceHost)
?.userId ==
postStore.postView.creator.id;
return ObserverBuilder<PostStore>(
store: postStore,
builder: (context, store) {
final post = store.postView;
return Column(
children: [
ListTile(
leading: const Icon(Icons.open_in_browser),
title: const Text('Open in browser'),
onTap: () async => await ul.canLaunch(post.post.apId)
? ul.launch(post.post.apId)
: ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("can't open in browser"))),
),
if (isMine)
ListTile(
leading: const Icon(Icons.edit),
title: const Text('Edit'),
onTap: () async {
final postView = await Navigator.of(context).push(
CreatePostPage.editRoute(post.post),
);
if (postView != null) {
store.updatePostView(postView);
}
},
)
else
ListTile(
leading: store.userBlockingState.isLoading
? const CircularProgressIndicator.adaptive()
: const Icon(Icons.block),
title:
Text('${post.creatorBlocked ? 'Unblock' : 'Block'} user'),
onTap: () {
Navigator.of(context).pop();
loggedInAction(store.blockUser)();
},
),
if (fullPostStore?.fullPostView != null)
ObserverBuilder<FullPostStore>(
store: fullPostStore,
builder: (context, store) {
return ListTile(
leading: store.communityBlockingState.isLoading
? const CircularProgressIndicator.adaptive()
: const Icon(Icons.block),
title: Text(
'${store.fullPostView!.communityView.blocked ? 'Unblock' : 'Block'} community'),
onTap: () {
Navigator.of(context).pop();
loggedInAction(store.blockCommunity)();
},
);
},
),
ListTile(
leading: const Icon(Icons.info_outline),
title: const Text('Nerd stuff'),
onTap: () {
showInfoTablePopup(context: context, table: {
'% of upvotes':
'${(100 * (post.counts.upvotes / (post.counts.upvotes + post.counts.downvotes))).toInt()}%',
...post.toJson(),
});
},
),
],
);
});
}
}

View File

@ -0,0 +1 @@
typedef IsFullPost = bool;

View File

@ -0,0 +1,99 @@
import 'package:lemmy_api_client/v3.dart';
import 'package:mobx/mobx.dart';
import '../../util/async_store.dart';
import '../../util/cleanup_url.dart';
part 'post_store.g.dart';
class PostStore = _PostStore with _$PostStore;
abstract class _PostStore with Store {
_PostStore(this.postView);
final votingState = AsyncStore<PostView>();
final savingState = AsyncStore<PostView>();
final userBlockingState = AsyncStore<BlockedPerson>();
@observable
PostView postView;
@computed
String? get urlDomain =>
postView.post.url != null ? urlHost(postView.post.url!) : null;
@computed
bool get hasMedia {
final url = postView.post.url;
if (url == null || url.isEmpty) return false;
// TODO: detect video
// this is how they do it in lemmy-ui:
// https://github.com/LemmyNet/lemmy-ui/blob/018c10c9193082237b5f8036e215a40325591acb/src/shared/utils.ts#L291
if (!url.startsWith('https://') && !url.startsWith('http://')) {
return false;
}
return url.endsWith('.jpg') ||
url.endsWith('.jpeg') ||
url.endsWith('.png') ||
url.endsWith('.gif') ||
url.endsWith('.webp') ||
url.endsWith('.bmp') ||
url.endsWith('.svg') ||
url.endsWith('.wbpm');
}
@action
Future<void> save(Jwt token) async {
final result = await savingState.runLemmy(
postView.instanceHost,
SavePost(
postId: postView.post.id, save: !postView.saved, auth: token.raw));
if (result != null) postView = result;
}
@action
Future<void> blockUser(Jwt token) async {
final result = await userBlockingState.runLemmy(
postView.post.instanceHost,
BlockPerson(
personId: postView.creator.id,
block: !postView.creatorBlocked,
auth: token.raw,
));
if (result != null) {
postView = postView.copyWith(creatorBlocked: result.blocked);
}
}
@action
// ignore: use_setters_to_change_properties
void updatePostView(PostView postView) {
this.postView = postView;
}
// VOTING
@action
Future<void> _vote(Jwt token, VoteType voteType) async {
final result = await votingState.runLemmy(
postView.instanceHost,
CreatePostLike(
postId: postView.post.id, score: voteType, auth: token.raw),
);
if (result != null) postView = result;
}
@action
Future<void> upVote(Jwt token) => _vote(
token, postView.myVote == VoteType.up ? VoteType.none : VoteType.up);
@action
Future<void> downVote(Jwt token) => _vote(
token, postView.myVote == VoteType.down ? VoteType.none : VoteType.down);
}

View File

@ -0,0 +1,105 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'post_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$PostStore on _PostStore, Store {
Computed<String?>? _$urlDomainComputed;
@override
String? get urlDomain =>
(_$urlDomainComputed ??= Computed<String?>(() => super.urlDomain,
name: '_PostStore.urlDomain'))
.value;
Computed<bool>? _$hasMediaComputed;
@override
bool get hasMedia => (_$hasMediaComputed ??=
Computed<bool>(() => super.hasMedia, name: '_PostStore.hasMedia'))
.value;
final _$postViewAtom = Atom(name: '_PostStore.postView');
@override
PostView get postView {
_$postViewAtom.reportRead();
return super.postView;
}
@override
set postView(PostView value) {
_$postViewAtom.reportWrite(value, super.postView, () {
super.postView = value;
});
}
final _$saveAsyncAction = AsyncAction('_PostStore.save');
@override
Future<void> save(Jwt token) {
return _$saveAsyncAction.run(() => super.save(token));
}
final _$blockUserAsyncAction = AsyncAction('_PostStore.blockUser');
@override
Future<void> blockUser(Jwt token) {
return _$blockUserAsyncAction.run(() => super.blockUser(token));
}
final _$_voteAsyncAction = AsyncAction('_PostStore._vote');
@override
Future<void> _vote(Jwt token, VoteType voteType) {
return _$_voteAsyncAction.run(() => super._vote(token, voteType));
}
final _$_PostStoreActionController = ActionController(name: '_PostStore');
@override
void updatePostView(PostView postView) {
final _$actionInfo = _$_PostStoreActionController.startAction(
name: '_PostStore.updatePostView');
try {
return super.updatePostView(postView);
} finally {
_$_PostStoreActionController.endAction(_$actionInfo);
}
}
@override
Future<void> upVote(Jwt token) {
final _$actionInfo =
_$_PostStoreActionController.startAction(name: '_PostStore.upVote');
try {
return super.upVote(token);
} finally {
_$_PostStoreActionController.endAction(_$actionInfo);
}
}
@override
Future<void> downVote(Jwt token) {
final _$actionInfo =
_$_PostStoreActionController.startAction(name: '_PostStore.downVote');
try {
return super.downVote(token);
} finally {
_$_PostStoreActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
postView: ${postView},
urlDomain: ${urlDomain},
hasMedia: ${hasMedia}
''';
}
}

View File

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
import '../../url_launcher.dart';
import '../../util/observer_consumers.dart';
import '../cached_network_image.dart';
import 'post_store.dart';
class PostTitle extends StatelessWidget {
const PostTitle();
@override
Widget build(BuildContext context) {
return ObserverBuilder<PostStore>(
builder: (context, store) {
final post = store.postView.post;
final thumbnailUrl = post.thumbnailUrl;
final url = post.url;
return Padding(
padding: const EdgeInsets.all(10).copyWith(top: 0),
child: Row(
children: [
Expanded(
child: Text(
post.name,
textAlign: TextAlign.left,
softWrap: true,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w600),
),
),
if (!store.hasMedia && thumbnailUrl != null && url != null) ...[
InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => linkLauncher(
context: context,
url: url,
instanceHost: store.postView.instanceHost),
child: Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: CachedNetworkImage(
imageUrl: thumbnailUrl,
width: 70,
height: 70,
fit: BoxFit.cover,
errorBuilder: (context, error) =>
Text(error.toString()),
),
),
const Positioned(
top: 8,
right: 8,
child: Icon(
Icons.launch,
size: 20,
),
)
],
),
),
],
],
),
);
},
);
}
}

View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:provider/provider.dart';
import '../../hooks/logged_in_action.dart';
import '../../hooks/stores.dart';
import '../../util/intl.dart';
import '../../util/observer_consumers.dart';
import 'post_store.dart';
class PostVoting extends HookWidget {
const PostVoting();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final showScores =
useConfigStoreSelect((configStore) => configStore.showScores);
final loggedInAction = useLoggedInAction(context
.select<PostStore, String>((store) => store.postView.instanceHost));
return ObserverBuilder<PostStore>(builder: (context, store) {
return Row(
children: [
IconButton(
icon: Icon(
Icons.arrow_upward,
color: store.postView.myVote == VoteType.up
? theme.colorScheme.secondary
: null,
),
onPressed: loggedInAction(store.upVote),
),
if (store.votingState.isLoading)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator.adaptive(),
)
else if (showScores)
Text(compactNumber(store.postView.counts.score)),
IconButton(
icon: Icon(
Icons.arrow_downward,
color: store.postView.myVote == VoteType.down ? Colors.red : null,
),
onPressed: loggedInAction(store.downVote),
),
],
);
});
}
}

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:provider/provider.dart';
import '../../hooks/logged_in_action.dart';
import '../../util/observer_consumers.dart';
import 'post_store.dart';
class SavePostButton extends HookWidget {
const SavePostButton();
@override
Widget build(BuildContext context) {
final loggedInAction = useLoggedInAction(context
.select<PostStore, String>((store) => store.postView.instanceHost));
return ObserverBuilder<PostStore>(
builder: (context, store) {
final savedIcon =
store.postView.saved ? Icons.bookmark : Icons.bookmark_border;
return IconButton(
tooltip: 'Save post',
icon: store.savingState.isLoading
? const CircularProgressIndicator.adaptive()
: Icon(savedIcon),
onPressed: loggedInAction(store.save),
);
},
);
}
}

View File

@ -1,55 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lemmy_api_client/v3.dart';
import '../hooks/delayed_loading.dart';
import '../hooks/logged_in_action.dart';
// TODO: sync this button between post and fullpost. the same with voting
class SavePostButton extends HookWidget {
final PostView post;
const SavePostButton(this.post);
@override
Widget build(BuildContext context) {
final isSaved = useState(post.saved);
final savedIcon = isSaved.value ? Icons.bookmark : Icons.bookmark_border;
final loading = useDelayedLoading();
final loggedInAction = useLoggedInAction(post.instanceHost);
savePost(Jwt token) async {
final api = LemmyApiV3(post.instanceHost);
loading.start();
try {
final res = await api.run(SavePost(
postId: post.post.id, save: !isSaved.value, auth: token.raw));
isSaved.value = res.saved;
} catch (e) {
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('saving failed :(')));
}
loading.cancel();
}
if (loading.loading) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 5),
child: SizedBox(
width: 30,
height: 30,
child: CircularProgressIndicator(
backgroundColor: Theme.of(context).iconTheme.color,
)),
);
}
return IconButton(
tooltip: 'Save post',
icon: Icon(savedIcon),
onPressed: loggedInAction(loading.pending ? (_) {} : savePost),
);
}
}

View File

@ -7,7 +7,7 @@ import '../hooks/infinite_scroll.dart';
import '../hooks/stores.dart';
import 'comment/comment.dart';
import 'infinite_scroll.dart';
import 'post.dart';
import 'post/post.dart';
import 'post_list_options.dart';
typedef FetcherWithSorting<T> = Future<List<T>> Function(
@ -74,7 +74,7 @@ class InfinitePostList extends SortableInfiniteList<PostView> {
}) : super(
itemBuilder: (post) => Column(
children: [
PostWidget(post),
PostTile.fromPostView(post),
const SizedBox(height: 20),
],
),

View File

@ -18,7 +18,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
version: 0.6.0+17
environment:
sdk: ">=2.12.0 <3.0.0"
sdk: ">=2.14.0 <3.0.0"
dependencies:
# widgets