Merge pull request #261 from krawieck/feature/blocking-v2
This commit is contained in:
commit
a38574c314
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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
|
||||
|
||||
|
|
|
@ -111,6 +111,7 @@ linter:
|
|||
- use_setters_to_change_properties
|
||||
- use_to_and_as_if_applicable
|
||||
- void_checks
|
||||
- use_named_constants
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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()),
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
]
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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'),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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}
|
||||
''';
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
|
@ -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 {
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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'),
|
||||
// ),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
''';
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
''';
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
''';
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
|
@ -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;
|
|
@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -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'),
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
typedef IsFullPost = bool;
|
|
@ -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);
|
||||
}
|
|
@ -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}
|
||||
''';
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
],
|
||||
),
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue