diff --git a/CHANGELOG.md b/CHANGELOG.md index a709776f..61d1821c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +## v0.9.11+13 - 2023-06-24 +- Merged in iOS support +- Added clickthrough for NSFW content, and associated setting. +- Added option to hide Everything feed as this was v confusing to new users. +- Improved messaging when interacting with content delivered via non-signed instances. +- Added 'via' information on posts to help with above. +- Limit total size of rich text field in post bodies as v large images +could fill pages. +- Replaced DELETE account (from instance) button with a Remove account (from app) button to prevent +risk of death threats :-) +- Limited width of main body components for a more reasonable feel on iPad. +- Minor UI tweaks + + + ## v0.9.11 - 2023-06-24 - Updated api client to better support 0.18.x - Fixes login stalling on 0.18.x instances diff --git a/README.md b/README.md index e21c7469..e277959c 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,17 @@ Join us on the matrix for support in contributions! [#liftoff-dev:matrix.org](ht - Clone this repo: `git clone https://github.com/liftoff-app/liftoff` - Enter the repo: `cd liftoff` +### iOS + +Visual Studio Code build configurations are provided for development testing. + +For final release, run: + +1. `flutter build ipa --flavor prod` + +The .api will be in `build/ios/ipa` + + ### Android 1. Build: `flutter build apk --flavor prod --target lib/main_prod.dart --release` diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index dd25fc65..d0cf13f1 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -80,6 +80,8 @@ "@show_nsfw": {}, "send_notifications_to_email": "Send notifications to Email", "@send_notifications_to_email": {}, + "remove_account": "Remove account", + "@remove_account": {}, "delete_account": "Delete account", "@delete_account": {}, "saved": "Saved", @@ -276,7 +278,9 @@ "@messages": {}, "banned_users": "Banned users", "@banned_users": {}, - "delete_account_confirm": "Warning: this will permanently delete all of your data from this instance. Your data may not be deleted on other, existing instances. Enter your password to confirm.", + "remove_account_confirm": "This will delete your account and data from this app. Your account and data will remain on the instance.", + "@remove_account_confirm": {}, + "delete_account_confirm": "Warning: this will permanently delete your account and all of your data from the app AND instance. Your data may not be deleted on other, existing instances. Enter your password to confirm.", "@delete_account_confirm": {}, "new_password": "New password", "@new_password": {}, diff --git a/lib/app.dart b/lib/app.dart index e516f4ee..706bd6e1 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -15,7 +15,7 @@ class MyApp extends StatelessWidget { return KeyboardDismisser( child: ObserverBuilder( builder: (context, store) => MaterialApp( - title: 'liftoff', + title: 'Liftoff', supportedLocales: L10n.supportedLocales, localizationsDelegates: L10n.localizationsDelegates, themeMode: store.theme, diff --git a/lib/hooks/logged_in_action.dart b/lib/hooks/logged_in_action.dart index 6a13382e..5f2bd008 100644 --- a/lib/hooks/logged_in_action.dart +++ b/lib/hooks/logged_in_action.dart @@ -23,7 +23,8 @@ VoidCallback Function( if (store.hasNoAccount) { return () { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(message ?? 'you have to be logged in to do that'), + content: Text( + message ?? 'You have to add an account and log in to do that'), action: SnackBarAction( label: 'log in', onPressed: () => goTo(context, (_) => AccountsConfigPage())), @@ -45,7 +46,8 @@ VoidCallback Function( if (store.isAnonymousFor(instanceHost)) { return () { ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(message ?? 'you have to be logged in to do that'), + content: Text(message ?? + 'This thread was retrieved via $instanceHost.\nYou are not logged in there.'), action: SnackBarAction( label: 'log in', onPressed: () => goTo(context, (_) => AccountsConfigPage())), diff --git a/lib/l10n/l10n.dart b/lib/l10n/l10n.dart index c74ca899..21d0cac3 100644 --- a/lib/l10n/l10n.dart +++ b/lib/l10n/l10n.dart @@ -95,5 +95,5 @@ extension NumberFormatExtensions on num { /// returns `this` as a formatted compact number String compact(BuildContext context) => NumberFormat.compact( locale: Localizations.localeOf(context).toLanguageTag(), - ).format(this); + ).format(this < 1000 ? this : num.parse(toStringAsPrecision(2))); } diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 00000000..b4a7b6e6 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,6 @@ +import 'app_config.dart'; +import 'main_common.dart'; + +void main() { + mainCommon(const AppConfig(debugMode: false)); +} diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart index 5f504c81..60f43a02 100644 --- a/lib/pages/create_post/create_post.dart +++ b/lib/pages/create_post/create_post.dart @@ -72,6 +72,7 @@ class CreatePostPage extends HookWidget { }, child: Scaffold( appBar: AppBar( + title: const Text('Create post'), actions: [ ObserverBuilder( builder: (context, store) => IconButton( @@ -81,68 +82,76 @@ class CreatePostPage extends HookWidget { ), ], ), - body: Stack( - children: [ - SafeArea( - child: SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.all(5), - child: Form( - key: formKey, - child: Column( - children: [ - if (!context.read().isEdit) ...const [ - CreatePostInstancePicker(), - CreatePostCommunityPicker(), - ], - CreatePostUrlField(titleFocusNode), - title, - body, - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Stack( + children: [ + SafeArea( + child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(5), + child: Form( + key: formKey, + child: Column( children: [ - ObserverBuilder( - builder: (context, store) => GestureDetector( - onTap: () => store.nsfw = !store.nsfw, - child: Row( - children: [ - Checkbox( - value: store.nsfw, - onChanged: (val) { - if (val != null) store.nsfw = val; - }, + if (!context + .read() + .isEdit) ...const [ + CreatePostInstancePicker(), + CreatePostCommunityPicker(), + ], + CreatePostUrlField(titleFocusNode), + title, + body, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ObserverBuilder( + builder: (context, store) => GestureDetector( + onTap: () => store.nsfw = !store.nsfw, + child: Row( + children: [ + Checkbox( + value: store.nsfw, + onChanged: (val) { + if (val != null) store.nsfw = val; + }, + ), + Text(L10n.of(context).nsfw) + ], ), - Text(L10n.of(context).nsfw) - ], + ), ), - ), + ObserverBuilder( + builder: (context, store) => TextButton( + onPressed: store.submitState.isLoading + ? () {} + : loggedInAction(handleSubmit), + child: store.submitState.isLoading + ? const CircularProgressIndicator + .adaptive() + : Text( + store.isEdit + ? L10n.of(context).edit + : L10n.of(context).post, + ), + ), + ) + ], ), - ObserverBuilder( - builder: (context, store) => TextButton( - onPressed: store.submitState.isLoading - ? () {} - : loggedInAction(handleSubmit), - child: store.submitState.isLoading - ? const CircularProgressIndicator.adaptive() - : Text( - store.isEdit - ? L10n.of(context).edit - : L10n.of(context).post, - ), - ), - ) - ], + EditorToolbar.safeArea, + ].spaced(6), ), - EditorToolbar.safeArea, - ].spaced(6), + ), ), ), - ), - ), - BottomSticky( - child: EditorToolbar(editorController), + BottomSticky( + child: EditorToolbar(editorController), + ), + ], ), - ], + ), ), ), ); diff --git a/lib/pages/full_post/comment_section.dart b/lib/pages/full_post/comment_section.dart index f352d2e9..a5153aef 100644 --- a/lib/pages/full_post/comment_section.dart +++ b/lib/pages/full_post/comment_section.dart @@ -42,7 +42,8 @@ class CommentSection extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 30), child: FailedToLoad( - message: 'Comments failed to load', + message: 'ERROR: Comments failed to load. ' + '${store.fullPostState.errorTerm}', refresh: () => store.refresh(context .read() .defaultUserDataFor(store.instanceHost) @@ -56,83 +57,89 @@ class CommentSection extends StatelessWidget { } } - 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( + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: 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: [ - 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); - }, - ) + Text(sortPairs[store.sorting]!.term.tr(context)), + const Icon(Icons.arrow_drop_down), ], ), - ); - }, - child: Row( - children: [ - Text(sortPairs[store.sorting]!.term.tr(context)), - const Icon(Icons.arrow_drop_down), - ], - ), + ), + const Spacer(), + ], ), - const Spacer(), - ], - ), - ), - // sorting menu goes here - if (postComments != null && postComments.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 50), - child: Text( - 'no comments yet', - style: TextStyle(fontStyle: FontStyle.italic), - ), - ) - else ...[ - for (final com in store.pinnedComments!) - CommentWidget.fromCommentView( - com, - key: ValueKey(com), - ), - for (final com in store.newComments) - CommentWidget.fromCommentView( - com, - detached: false, - key: ValueKey(com), - ), - // if (store.sorting == CommentSortType.chat) - // for (final com in postComments!) - // CommentWidget.fromCommentView( - // com, - // detached: false, - // key: ValueKey(com), - // ) - // else - for (final com in store.sortedCommentTree!) - CommentWidget( - com, - key: ValueKey(com), ), - const BottomSafe.fab(), - ] - ], + // sorting menu goes here + if (postComments != null && postComments.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 50), + child: Text( + 'no comments yet', + style: TextStyle(fontStyle: FontStyle.italic), + ), + ) + else ...[ + for (final com in store.pinnedComments!) + CommentWidget.fromCommentView( + com, + key: ValueKey(com), + ), + for (final com in store.newComments) + CommentWidget.fromCommentView( + com, + detached: false, + key: ValueKey(com), + ), + // if (store.sorting == CommentSortType.chat) + // for (final com in postComments!) + // CommentWidget.fromCommentView( + // com, + // detached: false, + // key: ValueKey(com), + // ) + // else + for (final com in store.sortedCommentTree!) + CommentWidget( + com, + key: ValueKey(com), + ), + const BottomSafe.fab(), + ] + ], + ), + ), ); }, ); diff --git a/lib/pages/full_post/full_post.dart b/lib/pages/full_post/full_post.dart index 8559cf1a..06f1bdb1 100644 --- a/lib/pages/full_post/full_post.dart +++ b/lib/pages/full_post/full_post.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -31,6 +32,8 @@ class FullPostPage extends HookWidget { @override Widget build(BuildContext context) { final scrollController = useScrollController(); + final shareButtonKey = GlobalKey(); + var scrollOffset = 0.0; final loggedInAction = useLoggedInAction(context.read().instanceHost); @@ -75,7 +78,18 @@ class FullPostPage extends HookWidget { // VARIABLES - sharePost() => share(post.post.apId, context: context); + sharePost() { + final renderbox = + shareButtonKey.currentContext!.findRenderObject()! as RenderBox; + final position = renderbox.localToGlobal(Offset.zero); + + return share(post.post.apId, + context: context, + sharePositionOrigin: Rect.fromPoints( + position, + position.translate( + renderbox.size.width, renderbox.size.height))); + } comment() async { final newComment = await Navigator.of(context).push( @@ -87,23 +101,51 @@ class FullPostPage extends HookWidget { } } + tapScrollAction() { + var targetOffset = 0.0; + if (scrollController.offset != 0) { + scrollOffset = scrollController.offset; + } else { + targetOffset = scrollOffset; + } + scrollController.animateTo(targetOffset, + duration: const Duration(milliseconds: 100), + curve: Curves.bounceInOut); + } + return Scaffold( appBar: AppBar( + flexibleSpace: GestureDetector( + onTap: tapScrollAction, + ), centerTitle: false, - title: RevealAfterScroll( - scrollController: scrollController, - after: 65, - child: Text( - post.community.originPreferredName, - overflow: TextOverflow.fade, + title: GestureDetector( + onTap: tapScrollAction, + child: RevealAfterScroll( + scrollController: scrollController, + after: 65, + child: Text( + '${post.community.originPreferredName} > ' + '"${post.post.name}"', + overflow: TextOverflow.fade, + ), ), ), actions: [ - IconButton(icon: Icon(shareIcon), onPressed: sharePost), + IconButton( + key: shareButtonKey, + icon: Icon(shareIcon), + onPressed: sharePost, + ), MobxProvider.value( value: postStore, child: const SavePostButton(), ), + if (!Platform.isAndroid && !post.post.locked) + IconButton( + onPressed: loggedInAction((_) => comment()), + icon: const Icon(Icons.reply), + ), IconButton( icon: Icon(moreIcon), onPressed: () => PostMoreMenuButton.show( @@ -114,7 +156,7 @@ class FullPostPage extends HookWidget { ), ], ), - floatingActionButton: post.post.locked + floatingActionButton: !Platform.isAndroid || post.post.locked ? null : FloatingActionButton( onPressed: loggedInAction((_) => comment()), diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 6b3ecb5f..e0385b90 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -60,8 +62,9 @@ class HomePage extends HookWidget { const SizedBox(height: kMinInteractiveDimension / 2), ], ), - floatingActionButton: const CreatePostFab(), - floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked, + floatingActionButton: Platform.isAndroid ? const CreatePostFab() : null, + floatingActionButtonLocation: + Platform.isAndroid ? FloatingActionButtonLocation.centerDocked : null, bottomNavigationBar: BottomAppBar( shape: const CircularNotchedRectangle(), notchMargin: 7, @@ -72,8 +75,8 @@ class HomePage extends HookWidget { children: [ tabButton(Icons.home), tabButton(Icons.list), - const SizedBox.shrink(), - const SizedBox.shrink(), + if (Platform.isAndroid) const SizedBox.shrink(), + if (Platform.isAndroid) const SizedBox.shrink(), tabButton(Icons.search), tabButton(Icons.person), ], diff --git a/lib/pages/home_tab.dart b/lib/pages/home_tab.dart index 6eb9088d..ecaeb619 100644 --- a/lib/pages/home_tab.dart +++ b/lib/pages/home_tab.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:math' show max; import 'package:flutter/material.dart'; @@ -5,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:lemmy_api_client/v3.dart'; import '../hooks/infinite_scroll.dart'; +import '../hooks/logged_in_action.dart'; import '../hooks/memo_future.dart'; import '../hooks/stores.dart'; import '../l10n/l10n.dart'; @@ -14,6 +16,8 @@ import '../widgets/bottom_modal.dart'; import '../widgets/cached_network_image.dart'; import '../widgets/infinite_scroll.dart'; import '../widgets/sortable_infinite_list.dart'; +import 'create_post/create_post.dart'; +import 'full_post/full_post.dart'; import 'inbox.dart'; import 'instance/instance.dart'; import 'settings/add_account_page.dart'; @@ -25,9 +29,14 @@ class HomeTab extends HookWidget { @override Widget build(BuildContext context) { + final loggedInAction = useAnyLoggedInAction(); + final accStore = useAccountsStore(); final defaultListingType = useStore((ConfigStore store) => store.defaultListingType); + final showEverythingFeed = + useStore((ConfigStore store) => store.showEverythingFeed); + final selectedList = useState(_SelectedList( listingType: accStore.hasNoAccount && defaultListingType == PostListingType.subscribed @@ -79,44 +88,47 @@ class HomeTab extends HookWidget { builder: (context) { pop(_SelectedList thing) => Navigator.of(context).pop(thing); - return Column( - children: [ - const SizedBox(height: 5), - const ListTile( - title: Text('EVERYTHING'), - dense: true, - contentPadding: EdgeInsets.zero, - visualDensity: - VisualDensity(vertical: VisualDensity.minimumDensity), - leading: SizedBox.shrink(), - ), - ListTile( - title: Text( - L10n.of(context).subscribed, - style: TextStyle( - color: accStore.hasNoAccount - ? theme.textTheme.bodyLarge?.color?.withOpacity(0.4) - : null, - ), + final everythingChoices = [ + const ListTile( + title: Text('EVERYTHING'), + dense: true, + contentPadding: EdgeInsets.zero, + visualDensity: + VisualDensity(vertical: VisualDensity.minimumDensity), + leading: SizedBox.shrink(), + ), + ListTile( + title: Text( + L10n.of(context).subscribed, + style: TextStyle( + color: accStore.hasNoAccount + ? theme.textTheme.bodyLarge?.color?.withOpacity(0.4) + : null, ), - onTap: accStore.hasNoAccount - ? null - : () => pop( - const _SelectedList( - listingType: PostListingType.subscribed, - ), + ), + onTap: accStore.hasNoAccount + ? null + : () => pop( + const _SelectedList( + listingType: PostListingType.subscribed, ), - leading: const SizedBox(width: 20), + ), + leading: const SizedBox(width: 20), + ), + for (final listingType in [ + PostListingType.local, + PostListingType.all, + ]) + ListTile( + title: Text(listingType.value), + leading: const SizedBox(width: 20, height: 20), + onTap: () => pop(_SelectedList(listingType: listingType)), ), - for (final listingType in [ - PostListingType.local, - PostListingType.all, - ]) - ListTile( - title: Text(listingType.value), - leading: const SizedBox(width: 20, height: 20), - onTap: () => pop(_SelectedList(listingType: listingType)), - ), + ]; + return Column( + children: [ + const SizedBox(height: 5), + if (showEverythingFeed) ...everythingChoices, for (final instance in accStore.instances) ...[ const Padding( padding: EdgeInsets.symmetric(horizontal: 10), @@ -220,6 +232,20 @@ class HomeTab extends HookWidget { // TODO: make appbar autohide when scrolling down appBar: AppBar( actions: [ + if (!Platform.isAndroid) // Replaces FAB + IconButton( + icon: const Icon(Icons.add_box_outlined), + onPressed: loggedInAction((_) async { + final postView = await Navigator.of(context).push( + CreatePostPage.route(), + ); + + if (postView != null) { + await Navigator.of(context) + .push(FullPostPage.fromPostViewRoute(postView)); + } + }), + ), IconButton( icon: const Icon(Icons.notifications), onPressed: () => goTo(context, (_) => const InboxPage()), diff --git a/lib/pages/inbox.dart b/lib/pages/inbox.dart index 9b89d7e3..2c8ba2a0 100644 --- a/lib/pages/inbox.dart +++ b/lib/pages/inbox.dart @@ -99,66 +99,77 @@ class InboxPage extends HookWidget { ], ), ), - body: TabBarView( - children: [ - SortableInfiniteList( - noItems: const Text('no replies'), - controller: isc, - defaultSort: SortType.new_, - fetcher: (page, batchSize, sortType) => - LemmyApiV3(selectedInstance).run(GetReplies( - auth: accStore.defaultUserDataFor(selectedInstance)!.jwt.raw, - sort: sortType, - limit: batchSize, - page: page, - unreadOnly: unreadOnly.value, - )), - itemBuilder: (cv) => CommentWidget.fromCommentView( - cv, - canBeMarkedAsRead: true, - hideOnRead: unreadOnly.value, - ), - uniqueProp: (item) => item.comment.apId, - ), - SortableInfiniteList( - noItems: const Text('no mentions'), - controller: isc, - defaultSort: SortType.new_, - fetcher: (page, batchSize, sortType) => - LemmyApiV3(selectedInstance).run(GetPersonMentions( - auth: accStore.defaultUserDataFor(selectedInstance)!.jwt.raw, - sort: sortType, - limit: batchSize, - page: page, - unreadOnly: unreadOnly.value, - )), - itemBuilder: (umv) => CommentWidget.fromPersonMentionView( - umv, - hideOnRead: unreadOnly.value, - ), - uniqueProp: (item) => item.personMention.id, - ), - InfiniteScroll( - noItems: const Padding( - padding: EdgeInsets.only(top: 60), - child: Text('no messages'), - ), - controller: isc, - fetcher: (page, batchSize) => LemmyApiV3(selectedInstance).run( - GetPrivateMessages( - auth: accStore.defaultUserDataFor(selectedInstance)!.jwt.raw, - limit: batchSize, - page: page, - unreadOnly: unreadOnly.value, + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: TabBarView( + children: [ + SortableInfiniteList( + noItems: const Text('no replies'), + controller: isc, + defaultSort: SortType.new_, + fetcher: (page, batchSize, sortType) => + LemmyApiV3(selectedInstance).run(GetReplies( + auth: + accStore.defaultUserDataFor(selectedInstance)!.jwt.raw, + sort: sortType, + limit: batchSize, + page: page, + unreadOnly: unreadOnly.value, + )), + itemBuilder: (cv) => CommentWidget.fromCommentView( + cv, + canBeMarkedAsRead: true, + hideOnRead: unreadOnly.value, + ), + uniqueProp: (item) => item.comment.apId, ), - ), - itemBuilder: (mv) => PrivateMessageTile( - privateMessageView: mv, - hideOnRead: unreadOnly.value, - ), - uniqueProp: (item) => item.privateMessage.apId, + SortableInfiniteList( + noItems: const Text('no mentions'), + controller: isc, + defaultSort: SortType.new_, + fetcher: (page, batchSize, sortType) => + LemmyApiV3(selectedInstance).run(GetPersonMentions( + auth: + accStore.defaultUserDataFor(selectedInstance)!.jwt.raw, + sort: sortType, + limit: batchSize, + page: page, + unreadOnly: unreadOnly.value, + )), + itemBuilder: (umv) => CommentWidget.fromPersonMentionView( + umv, + hideOnRead: unreadOnly.value, + ), + uniqueProp: (item) => item.personMention.id, + ), + InfiniteScroll( + noItems: const Padding( + padding: EdgeInsets.only(top: 60), + child: Text('no messages'), + ), + controller: isc, + fetcher: (page, batchSize) => + LemmyApiV3(selectedInstance).run( + GetPrivateMessages( + auth: accStore + .defaultUserDataFor(selectedInstance)! + .jwt + .raw, + limit: batchSize, + page: page, + unreadOnly: unreadOnly.value, + ), + ), + itemBuilder: (mv) => PrivateMessageTile( + privateMessageView: mv, + hideOnRead: unreadOnly.value, + ), + uniqueProp: (item) => item.privateMessage.apId, + ), + ], ), - ], + ), ), ), ); diff --git a/lib/pages/manage_account.dart b/lib/pages/manage_account.dart index 13d5aab2..c91e078e 100644 --- a/lib/pages/manage_account.dart +++ b/lib/pages/manage_account.dart @@ -35,7 +35,7 @@ class ManageAccountPage extends HookWidget { return site.myUser!.localUserView; }); - void openMoreMenu() { + void privateOpenMoreMenu() { showBottomModal( context: context, builder: (context) => Column( @@ -65,7 +65,7 @@ class ManageAccountPage extends HookWidget { appBar: AppBar( title: Text('$username@$instanceHost'), actions: [ - IconButton(icon: Icon(moreIcon), onPressed: openMoreMenu), + IconButton(icon: Icon(moreIcon), onPressed: privateOpenMoreMenu), ], ), body: FutureBuilder( @@ -95,6 +95,7 @@ class _ManageAccount extends HookWidget { final accountsStore = useAccountsStore(); final theme = Theme.of(context); final saveDelayedLoading = useDelayedLoading(); + final removeDelayedLoading = useDelayedLoading(); final deleteDelayedLoading = useDelayedLoading(); final displayNameController = @@ -119,6 +120,7 @@ class _ManageAccount extends HookWidget { final informAcceptedAvatarRef = useRef(null); final informAcceptedBannerRef = useRef(null); + final removeAccountPasswordController = useTextEditingController(); final deleteAccountPasswordController = useTextEditingController(); final emailFocusNode = useFocusNode(); @@ -131,7 +133,7 @@ class _ManageAccount extends HookWidget { instanceHost: user.instanceHost, text: user.person.bio); final token = - accountsStore.userDataFor(user.instanceHost, user.person.name)!.jwt; + accountsStore.userDataFor(user.instanceHost, user.person.name)?.jwt; handleSubmit() async { saveDelayedLoading.start(); @@ -148,7 +150,7 @@ class _ManageAccount extends HookWidget { showBotAccounts: showBotAccounts.value, showReadPosts: showReadPosts.value, sendNotificationsToEmail: sendNotificationsToEmail.value, - auth: token.raw, + auth: token!.raw, avatar: avatar.value, banner: banner.value, matrixUserId: matrixUserController.text.isEmpty @@ -178,6 +180,62 @@ class _ManageAccount extends HookWidget { } } + removeAccountDialog() async { + final confirmRemove = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text( + '${L10n.of(context).remove_account} @${user.instanceHost}@${user.person.name}'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(L10n.of(context).remove_account_confirm), + const SizedBox(height: 10), + // TextField( + // controller: removeAccountPasswordController, + // autofillHints: const [AutofillHints.password], + // keyboardType: TextInputType.visiblePassword, + // obscureText: true, + // decoration: + // InputDecoration(hintText: L10n.of(context).password), + // ) + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: Text(L10n.of(context).no), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: Text(L10n.of(context).yes), + ), + ], + ), + ) ?? + false; + + if (confirmRemove) { + removeDelayedLoading.start(); + + try { + await accountsStore.removeAccount( + user.instanceHost, user.person.name); + Navigator.of(context).pop(); + } on Exception catch (err) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(err.toString()), + )); + } + + removeDelayedLoading.cancel(); + } else { + removeAccountPasswordController.clear(); + } + } + + // MYKL TODO: decide if we really want this functionality in a client. + // ignore: unused_element deleteAccountDialog() async { final confirmDelete = await showDialog( context: context, @@ -217,10 +275,11 @@ class _ManageAccount extends HookWidget { deleteDelayedLoading.start(); try { - await LemmyApiV3(user.instanceHost).run(DeleteAccount( - password: deleteAccountPasswordController.text, - auth: token.raw, - )); + // MYKL - let's not do this just yet, even though we've warned the user that we will.... + // await LemmyApiV3(user.instanceHost).run(DeleteAccount( + // password: deleteAccountPasswordController.text, + // auth: token.raw, + // )); await accountsStore.removeAccount( user.instanceHost, user.person.name); @@ -237,6 +296,17 @@ class _ManageAccount extends HookWidget { } } + if (token == null) { + // WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + // ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + // content: Text('Account does not exist'), + // )); + // }); + return const Column( + children: [Text('Account does not exist')], + ); + } + return Stack( children: [ ListView( @@ -289,7 +359,7 @@ class _ManageAccount extends HookWidget { onSubmitted: (_) => newPasswordFocusNode.requestFocus(), ), const SizedBox(height: 8), - // Text(L10n.of(context)!.new_password, style: theme.textTheme.titleLarge), + // Text(L10n.of(context)!.new_password, style: theme.textTheme.headline6), // TextField( // focusNode: newPasswordFocusNode, // controller: newPasswordController, @@ -300,7 +370,7 @@ class _ManageAccount extends HookWidget { // ), // const SizedBox(height: 8), // Text(L10n.of(context)!.verify_password, - // style: theme.textTheme.titleLarge), + // style: theme.textTheme.headline6), // TextField( // focusNode: verifyPasswordFocusNode, // controller: newPasswordVerifyController, @@ -310,7 +380,7 @@ class _ManageAccount extends HookWidget { // onSubmitted: (_) => oldPasswordFocusNode.requestFocus(), // ), // const SizedBox(height: 8), - // Text(L10n.of(context)!.old_password, style: theme.textTheme.titleLarge), + // Text(L10n.of(context)!.old_password, style: theme.textTheme.headline6), // TextField( // focusNode: oldPasswordFocusNode, // controller: oldPasswordController, @@ -376,12 +446,20 @@ class _ManageAccount extends HookWidget { ), const SizedBox(height: 8), ElevatedButton( - onPressed: deleteAccountDialog, + onPressed: removeAccountDialog, style: ElevatedButton.styleFrom( backgroundColor: Colors.red, ), - child: Text(L10n.of(context).delete_account.toUpperCase()), + child: Text(L10n.of(context).remove_account.toUpperCase()), ), + // MYKL - Hide the DELETE button for now... + // ElevatedButton( + // onPressed: deleteAccountDialog, + // style: ElevatedButton.styleFrom( + // backgroundColor: Colors.red, + // ), + // child: Text(L10n.of(context).delete_account.toUpperCase()), + // ), const BottomSafe(), ], ), diff --git a/lib/pages/settings/add_account_page.dart b/lib/pages/settings/add_account_page.dart index 636fadc1..d4957080 100644 --- a/lib/pages/settings/add_account_page.dart +++ b/lib/pages/settings/add_account_page.dart @@ -44,6 +44,14 @@ class AddAccountPage extends HookWidget { try { final isFirstAccount = accountsStore.hasNoAccount; + // MYKL HACK - let the addAccount() run and then check to see if it + // succeeded. This means that a users' very first account creation will + // run properly. + if (isFirstAccount) { + await accountsStore.setDefaultAccount( + selectedInstance.value, usernameController.text); + } + loading.start(); await accountsStore.addAccount( selectedInstance.value, @@ -51,6 +59,11 @@ class AddAccountPage extends HookWidget { passwordController.text, ); + // MYKL recover from HACK - it failed, so clear the account. + if (isFirstAccount && accountsStore.hasNoAccount) { + await accountsStore.clearDefaultAccount(); + } + // if first account try to import settings if (isFirstAccount) { try { diff --git a/lib/pages/settings/add_instance_page.dart b/lib/pages/settings/add_instance_page.dart index a96da2dc..6622348f 100644 --- a/lib/pages/settings/add_instance_page.dart +++ b/lib/pages/settings/add_instance_page.dart @@ -67,66 +67,77 @@ class AddInstancePage extends HookWidget { leading: const CloseButton(), title: const Text('Add instance'), ), - body: ListView( + body: Column( children: [ - if (isSite.value == true && icon.value != null) - SizedBox( - height: 150, - child: FullscreenableImage( - url: icon.value!, - child: CachedNetworkImage( - imageUrl: icon.value!, - errorBuilder: (_, ___) => const SizedBox.shrink(), - ), - )) - else if (isSite.value == false) - const SizedBox( - height: 150, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.close, color: Colors.red), - Text('instance not found') - ], - ), - ) - else - const SizedBox(height: 150), - const SizedBox(height: 15), - SizedBox( - height: 40, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: TextField( - autofocus: true, - controller: instanceController, - autofillHints: const [AutofillHints.url], - keyboardType: TextInputType.url, - onSubmitted: (_) => handleAdd?.call(), - autocorrect: false, - decoration: const InputDecoration(labelText: 'instance url'), - ), - ), + const Padding( + padding: EdgeInsets.all(8), + child: Text('Please note that kbin instances are ' + 'not supported at present.'), ), - const SizedBox(height: 5), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: SizedBox( - height: 40, - child: ElevatedButton( - onPressed: handleAdd, - child: !debounce.loading - ? const Text('Add') - : SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator.adaptive( - valueColor: - AlwaysStoppedAnimation(theme.canvasColor), - ), + ListView( + shrinkWrap: true, + children: [ + if (isSite.value == true && icon.value != null) + SizedBox( + height: 150, + child: FullscreenableImage( + url: icon.value!, + child: CachedNetworkImage( + imageUrl: icon.value!, + errorBuilder: (_, ___) => const SizedBox.shrink(), ), + )) + else if (isSite.value == false) + const SizedBox( + height: 150, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.close, color: Colors.red), + Text('instance not found') + ], + ), + ) + else + const SizedBox(height: 150), + const SizedBox(height: 15), + SizedBox( + height: 40, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: TextField( + autofocus: true, + controller: instanceController, + autofillHints: const [AutofillHints.url], + keyboardType: TextInputType.url, + onSubmitted: (_) => handleAdd?.call(), + autocorrect: false, + decoration: + const InputDecoration(labelText: 'instance url'), + ), + ), + ), + const SizedBox(height: 5), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: SizedBox( + height: 40, + child: ElevatedButton( + onPressed: handleAdd, + child: !debounce.loading + ? const Text('Add') + : SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator.adaptive( + valueColor: AlwaysStoppedAnimation( + theme.canvasColor), + ), + ), + ), + ), ), - ), + ], ), ], ), diff --git a/lib/pages/settings/settings.dart b/lib/pages/settings/settings.dart index 373ab099..f71b4155 100644 --- a/lib/pages/settings/settings.dart +++ b/lib/pages/settings/settings.dart @@ -134,6 +134,14 @@ class AppearanceConfigPage extends StatelessWidget { store.showScores = checked; }, ), + SwitchListTile.adaptive( + title: const Text('Blur NSFW'), + subtitle: const Text('Images in NSFW posts will be hidden.'), + value: store.blurNsfw, + onChanged: (checked) { + store.blurNsfw = checked; + }, + ), ], ), ), @@ -195,6 +203,17 @@ class GeneralConfigPage extends StatelessWidget { ), ), ), + SwitchListTile.adaptive( + title: const Text('Show EVERYTHING feed'), + subtitle: + const Text('This will combine content from all instances, ' + "even those you're not signed into, so you may " + "see posts you can't vote on or reply to."), + value: store.showEverythingFeed, + onChanged: (checked) { + store.showEverythingFeed = checked; + }, + ), ], ), ), @@ -269,7 +288,7 @@ class _AccountOptions extends HookWidget { child: CircularProgressIndicator.adaptive(), ) : const Icon(Icons.cloud_download), - title: const Text('Import settings to liftoff'), + title: const Text('Import settings to Liftoff'), onTap: () async { await context.read().importLemmyUserSettings( accountsStore.userDataFor(instanceHost, username)!.jwt, diff --git a/lib/pages/user.dart b/lib/pages/user.dart index b84a9f85..9c1d822e 100644 --- a/lib/pages/user.dart +++ b/lib/pages/user.dart @@ -26,6 +26,7 @@ class UserPage extends HookWidget { @override Widget build(BuildContext context) { final userDetailsSnap = useFuture(_userDetails); + final shareButtonKey = GlobalKey(); final body = () { if (userDetailsSnap.hasData) { @@ -36,6 +37,16 @@ class UserPage extends HookWidget { return const Center(child: CircularProgressIndicator.adaptive()); } }(); + shareUserSnap() { + final renderbox = + shareButtonKey.currentContext!.findRenderObject()! as RenderBox; + final position = renderbox.localToGlobal(Offset.zero); + + return share(userDetailsSnap.data!.personView.person.actorId, + context: context, + sharePositionOrigin: Rect.fromPoints(position, + position.translate(renderbox.size.width, renderbox.size.height))); + } return Scaffold( extendBodyBehindAppBar: true, @@ -44,11 +55,9 @@ class UserPage extends HookWidget { if (userDetailsSnap.hasData) ...[ SendMessageButton(userDetailsSnap.data!.personView.person), IconButton( + key: shareButtonKey, icon: Icon(shareIcon), - onPressed: () => share( - userDetailsSnap.data!.personView.person.actorId, - context: context, - ), + onPressed: shareUserSnap, ), ] ], diff --git a/lib/stores/accounts_store.dart b/lib/stores/accounts_store.dart index 4c96c715..44138321 100644 --- a/lib/stores/accounts_store.dart +++ b/lib/stores/accounts_store.dart @@ -19,7 +19,7 @@ class AccountsStore extends ChangeNotifier { /// for that account. /// `accounts['instanceHost']['username']` @protected - @JsonKey(defaultValue: {'lemmy.world': {}}) + @JsonKey(defaultValue: {'lemmy.world': {}, 'lemmy.ml': {}, 'beehaw.org': {}}) late Map> accounts; /// default account for a given instance @@ -142,6 +142,14 @@ class AccountsStore extends ChangeNotifier { return save(); } + /// clear the globally default account + Future clearDefaultAccount() { + defaultAccount = null; + + notifyListeners(); + return save(); + } + /// sets default account for given instance Future setDefaultAccountFor(String instanceHost, String username) { defaultAccounts[instanceHost] = username; diff --git a/lib/stores/accounts_store.g.dart b/lib/stores/accounts_store.g.dart index abac4988..4d587444 100644 --- a/lib/stores/accounts_store.g.dart +++ b/lib/stores/accounts_store.g.dart @@ -16,7 +16,7 @@ AccountsStore _$AccountsStoreFromJson(Map json) => MapEntry(k, UserData.fromJson(e as Map)), )), ) ?? - {'lemmy.world': {}} + {'lemmy.world': {}, 'lemmy.ml': {}, 'beehaw.org': {}} ..defaultAccounts = (json['defaultAccounts'] as Map?)?.map( (k, e) => MapEntry(k, e as String), diff --git a/lib/stores/config_store.dart b/lib/stores/config_store.dart index 6a43d513..80401d5b 100644 --- a/lib/stores/config_store.dart +++ b/lib/stores/config_store.dart @@ -74,6 +74,16 @@ abstract class _ConfigStore with Store { @JsonKey(defaultValue: true) bool showScores = true; + @observable + @JsonKey(defaultValue: true) + bool blurNsfw = true; + + /// Allows the user to see the combined EVERYTHING feed, which can be + /// confusing, so default it off. + @observable + @JsonKey(defaultValue: false) + bool showEverythingFeed = false; + // default is set in fromJson @observable @JsonKey(fromJson: _sortTypeFromJson) diff --git a/lib/stores/config_store.g.dart b/lib/stores/config_store.g.dart index c9912432..077c240e 100644 --- a/lib/stores/config_store.g.dart +++ b/lib/stores/config_store.g.dart @@ -16,6 +16,8 @@ ConfigStore _$ConfigStoreFromJson(Map json) => ConfigStore() ..postCardShadow = json['postCardShadow'] as bool? ?? true ..showAvatars = json['showAvatars'] as bool? ?? true ..showScores = json['showScores'] as bool? ?? true + ..blurNsfw = json['blurNsfw'] as bool? ?? true + ..showEverythingFeed = json['showEverythingFeed'] as bool? ?? false ..defaultSortType = _sortTypeFromJson(json['defaultSortType'] as String?) ..defaultListingType = _postListingTypeFromJson(json['defaultListingType'] as String?); @@ -30,6 +32,8 @@ Map _$ConfigStoreToJson(ConfigStore instance) => 'postCardShadow': instance.postCardShadow, 'showAvatars': instance.showAvatars, 'showScores': instance.showScores, + 'blurNsfw': instance.blurNsfw, + 'showEverythingFeed': instance.showEverythingFeed, 'defaultSortType': instance.defaultSortType, 'defaultListingType': instance.defaultListingType, }; @@ -173,6 +177,38 @@ mixin _$ConfigStore on _ConfigStore, Store { }); } + late final _$blurNsfwAtom = + Atom(name: '_ConfigStore.blurNsfw', context: context); + + @override + bool get blurNsfw { + _$blurNsfwAtom.reportRead(); + return super.blurNsfw; + } + + @override + set blurNsfw(bool value) { + _$blurNsfwAtom.reportWrite(value, super.blurNsfw, () { + super.blurNsfw = value; + }); + } + + late final _$showEverythingFeedAtom = + Atom(name: '_ConfigStore.showEverythingFeed', context: context); + + @override + bool get showEverythingFeed { + _$showEverythingFeedAtom.reportRead(); + return super.showEverythingFeed; + } + + @override + set showEverythingFeed(bool value) { + _$showEverythingFeedAtom.reportWrite(value, super.showEverythingFeed, () { + super.showEverythingFeed = value; + }); + } + late final _$defaultSortTypeAtom = Atom(name: '_ConfigStore.defaultSortType', context: context); @@ -239,6 +275,8 @@ postRoundedCorners: ${postRoundedCorners}, postCardShadow: ${postCardShadow}, showAvatars: ${showAvatars}, showScores: ${showScores}, +blurNsfw: ${blurNsfw}, +showEverythingFeed: ${showEverythingFeed}, defaultSortType: ${defaultSortType}, defaultListingType: ${defaultListingType} '''; diff --git a/lib/widgets/about_tile.dart b/lib/widgets/about_tile.dart index a23590ff..753b2af2 100644 --- a/lib/widgets/about_tile.dart +++ b/lib/widgets/about_tile.dart @@ -10,7 +10,7 @@ import '../resources/links.dart'; import '../url_launcher.dart'; import 'bottom_safe.dart'; -/// Title that opens a dialog with information about Lemmur. +/// Title that opens a dialog with information about Liftoff. /// Licenses, changelog, version etc. class AboutTile extends HookWidget { const AboutTile(); @@ -35,6 +35,9 @@ class AboutTile extends HookWidget { return AboutListTile( icon: const Icon(Icons.info), aboutBoxChildren: [ + const Text( + 'A client for Lemmy, written in Flutter.\n\nBased on the Lemmur project.'), + const SizedBox(height: 40), TextButton.icon( icon: const Icon(Icons.subject), label: const Text('changelog'), diff --git a/lib/widgets/comment/comment.dart b/lib/widgets/comment/comment.dart index 3f215f5c..13f7dd34 100644 --- a/lib/widgets/comment/comment.dart +++ b/lib/widgets/comment/comment.dart @@ -126,8 +126,8 @@ class _CommentWidget extends StatelessWidget { Colors.indigo, ]; - static const indentWidth = 5.0; - + static const indentWidth = 6.0; + static const barWidth = 2.0; const _CommentWidget(); @override @@ -194,7 +194,7 @@ class _CommentWidget extends StatelessWidget { left: store.depth > 0 ? BorderSide( color: colors[store.depth % colors.length], - width: indentWidth, + width: barWidth, ) : BorderSide.none, top: const BorderSide(width: 0.2), @@ -217,12 +217,17 @@ class _CommentWidget extends StatelessWidget { ), ), ), - InkWell( - onTap: () => goToUser.fromPersonSafe(context, creator), - child: Text( - creator.originPreferredName, - style: TextStyle( - color: theme.colorScheme.secondary, + Expanded( + child: InkWell( + onTap: () => + goToUser.fromPersonSafe(context, creator), + child: Text( + creator.originPreferredName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: theme.colorScheme.secondary, + ), ), ), ), diff --git a/lib/widgets/comment/comment_more_menu_button.dart b/lib/widgets/comment/comment_more_menu_button.dart index 7e41ffdd..10527e60 100644 --- a/lib/widgets/comment/comment_more_menu_button.dart +++ b/lib/widgets/comment/comment_more_menu_button.dart @@ -22,14 +22,19 @@ class CommentMoreMenuButton extends HookWidget { @override Widget build(BuildContext context) { + final moreButtonKey = GlobalKey(); return ObserverBuilder( builder: (context, store) { return TileAction( + key: moreButtonKey, icon: Icons.more_horiz, onPressed: () { showBottomModal( context: context, - builder: (context) => _CommentMoreMenuPopup(store: store), + builder: (context) => _CommentMoreMenuPopup( + store: store, + moreButtonKey: moreButtonKey, + ), ); }, loading: store.deletingState.isLoading, @@ -42,9 +47,11 @@ class CommentMoreMenuButton extends HookWidget { class _CommentMoreMenuPopup extends HookWidget { final CommentStore store; + final GlobalKey moreButtonKey; const _CommentMoreMenuPopup({ required this.store, + required this.moreButtonKey, }); @override @@ -76,6 +83,12 @@ class _CommentMoreMenuPopup extends HookWidget { } } + final renderbox = + moreButtonKey.currentContext!.findRenderObject()! as RenderBox; + final position = renderbox.localToGlobal(Offset.zero); + final targetRect = Rect.fromPoints(position, + position.translate(renderbox.size.width, renderbox.size.height)); + return Column( children: [ ListTile( @@ -91,7 +104,11 @@ class _CommentMoreMenuPopup extends HookWidget { leading: Icon(shareIcon), title: const Text('Share url'), onTap: () { - share(comment.link, context: context); + share( + comment.link, + sharePositionOrigin: targetRect, + context: context, + ); Navigator.of(context).pop(); }, ), @@ -99,7 +116,11 @@ class _CommentMoreMenuPopup extends HookWidget { leading: Icon(shareIcon), title: const Text('Share text'), onTap: () { - share(comment.content, context: context); + share( + comment.content, + sharePositionOrigin: targetRect, + context: context, + ); Navigator.of(context).pop(); }, ), diff --git a/lib/widgets/nsfw_hider.dart b/lib/widgets/nsfw_hider.dart new file mode 100644 index 00000000..5e65e2d6 --- /dev/null +++ b/lib/widgets/nsfw_hider.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import '../hooks/stores.dart'; +import '../stores/config_store.dart'; + +/// Hides NSFW content until clicked. +class NSFWHider extends HookWidget { + final Widget child; + + const NSFWHider({ + required this.child, + }); + + @override + Widget build(BuildContext context) { + final hideContent = useState(true); + final blurNsfW = useStore((ConfigStore store) => store.blurNsfw); + + if (!blurNsfW || !hideContent.value) return child; + return Container( + alignment: Alignment.center, + child: TextButton.icon( + icon: const Icon(Icons.warning), + label: const Text('NSFW content: click to view'), + onPressed: () { + hideContent.value = false; + }), + ); + } +} diff --git a/lib/widgets/post/post.dart b/lib/widgets/post/post.dart index d08effd1..ad1fc2ca 100644 --- a/lib/widgets/post/post.dart +++ b/lib/widgets/post/post.dart @@ -8,6 +8,7 @@ import '../../util/async_store_listener.dart'; import '../../util/extensions/api.dart'; import '../../util/mobx_provider.dart'; import '../../util/observer_consumers.dart'; +import '../nsfw_hider.dart'; import 'post_actions.dart'; import 'post_body.dart'; import 'post_info_section.dart'; @@ -58,6 +59,19 @@ class _Post extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final isFullPost = context.read(); + + final postStore = context.read(); + const sensitiveContent = Column( + children: [ + PostMedia(), + PostLinkPreview(), + PostBody(), + ], + ); + final possiblyBlurred = postStore.postView.post.nsfw + ? const NSFWHider(child: sensitiveContent) + : sensitiveContent; + return GestureDetector( onTap: isFullPost ? null @@ -82,21 +96,35 @@ class _Post extends StatelessWidget { child: Column( children: [ if (isFullPost) ...[ + const PostInfoSection(), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Column(children: [ + const PostTitle(), + possiblyBlurred, + const PostActions(), + ]), + ), + ), + ] else if (store.compactPostView) ...[ const PostInfoSection(), const PostTitle(), - const PostMedia(), - const PostLinkPreview(), - const PostBody(), const PostActions(), ] else ...[ const PostInfoSection(), - const PostTitle(), - if (!store.compactPostView) ...[ - const PostMedia(), - const PostLinkPreview(), - const PostBody(), - ], - const PostActions(), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Column( + children: [ + const PostTitle(), + if (!store.compactPostView) possiblyBlurred, + const PostActions(), + ], + ), + ), + ), ] ], ), diff --git a/lib/widgets/post/post_actions.dart b/lib/widgets/post/post_actions.dart index de077a93..1f89e1ad 100644 --- a/lib/widgets/post/post_actions.dart +++ b/lib/widgets/post/post_actions.dart @@ -16,14 +16,14 @@ class PostActions extends HookWidget { @override Widget build(BuildContext context) { final fullPost = context.read(); - + final shareButtonKey = GlobalKey(); // assemble actions section return ObserverBuilder(builder: (context, store) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), child: Row( children: [ - const Icon(Icons.comment), + const Icon(Icons.comment_rounded), const SizedBox(width: 6), Expanded( child: Text( @@ -36,9 +36,19 @@ class PostActions extends HookWidget { ), if (!fullPost) IconButton( + key: shareButtonKey, icon: Icon(shareIcon), - onPressed: () => - share(store.postView.post.apId, context: context), + onPressed: () { + final renderbox = shareButtonKey.currentContext! + .findRenderObject()! as RenderBox; + final position = renderbox.localToGlobal(Offset.zero); + share(store.postView.post.apId, + context: context, + sharePositionOrigin: Rect.fromPoints( + position, + position.translate( + renderbox.size.width, renderbox.size.height))); + }, ), if (!fullPost) const SavePostButton(), const PostVoting(), diff --git a/lib/widgets/post/post_body.dart b/lib/widgets/post/post_body.dart index e765450c..deed6f0e 100644 --- a/lib/widgets/post/post_body.dart +++ b/lib/widgets/post/post_body.dart @@ -72,9 +72,14 @@ class PostBody extends StatelessWidget { } else { return Padding( padding: const EdgeInsets.all(10), - child: MarkdownText( - body, - instanceHost: store.postView.instanceHost, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: SingleChildScrollView( + child: MarkdownText( + body, + instanceHost: store.postView.instanceHost, + ), + ), ), ); } diff --git a/lib/widgets/post/post_info_section.dart b/lib/widgets/post/post_info_section.dart index 21935fe6..faef4ea5 100644 --- a/lib/widgets/post/post_info_section.dart +++ b/lib/widgets/post/post_info_section.dart @@ -74,6 +74,12 @@ class PostInfoSection extends StatelessWidget { ), ), ), + if (post.post.originInstanceHost != + post.post.instanceHost) + TextSpan( + style: const TextStyle(fontSize: 13), + text: ' ยท via ${post.post.instanceHost}', + ), ], ), ), diff --git a/lib/widgets/post/post_more_menu.dart b/lib/widgets/post/post_more_menu.dart index 1638a22d..ceff68cd 100644 --- a/lib/widgets/post/post_more_menu.dart +++ b/lib/widgets/post/post_more_menu.dart @@ -72,7 +72,10 @@ class PostMoreMenu extends HookWidget { ListTile( leading: const Icon(Icons.open_in_browser), title: const Text('Open in browser'), - onTap: () => launchLink(link: post.post.apId, context: context), + onTap: () { + launchLink(link: post.post.apId, context: context); + Navigator.of(context).pop(); + }, ), if (isMine) ListTile( diff --git a/lib/widgets/post/post_title.dart b/lib/widgets/post/post_title.dart index c72f3e29..4e624840 100644 --- a/lib/widgets/post/post_title.dart +++ b/lib/widgets/post/post_title.dart @@ -1,16 +1,21 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import '../../hooks/stores.dart'; +import '../../stores/config_store.dart'; import '../../url_launcher.dart'; import '../../util/observer_consumers.dart'; import '../cached_network_image.dart'; import '../fullscreenable_image.dart'; import 'post_store.dart'; -class PostTitle extends StatelessWidget { +class PostTitle extends HookWidget { const PostTitle(); @override Widget build(BuildContext context) { + final blurNsfw = useStore((ConfigStore store) => store.blurNsfw); + return ObserverBuilder( builder: (context, store) { final post = store.postView.post; @@ -30,7 +35,10 @@ class PostTitle extends StatelessWidget { fontSize: 18, fontWeight: FontWeight.w600), ), ), - if (!store.hasMedia && thumbnailUrl != null && url != null) ...[ + if (!store.hasMedia && + !(post.nsfw && blurNsfw) && + thumbnailUrl != null && + url != null) ...[ InkWell( borderRadius: BorderRadius.circular(20), onTap: () => linkLauncher( @@ -62,7 +70,9 @@ class PostTitle extends StatelessWidget { ), ), ], - if (store.hasMedia && url != null) ...[ + if (store.hasMedia && + !(post.nsfw && blurNsfw) && + url != null) ...[ FullscreenableImage( url: url, child: CachedNetworkImage( diff --git a/lib/widgets/post/post_voting.dart b/lib/widgets/post/post_voting.dart index b8f8725e..399310fd 100644 --- a/lib/widgets/post/post_voting.dart +++ b/lib/widgets/post/post_voting.dart @@ -32,13 +32,23 @@ class PostVoting extends HookWidget { onPressed: loggedInAction(store.upVote), ), if (store.votingState.isLoading) - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator.adaptive(), + SizedBox( + width: showScores ? 30 : 20, + height: 15, + child: const CircularProgressIndicator.adaptive(), ) else if (showScores) - Text(store.postView.counts.score.compact(context)), + SizedBox( + width: 30, + height: 15, + child: Center( + child: Text(store.postView.counts.score.compact(context))), + ) + else + const SizedBox( + width: 20, + height: 15, + ), IconButton( icon: Icon( Icons.arrow_downward, diff --git a/lib/widgets/user_profile.dart b/lib/widgets/user_profile.dart index c06aedfb..9c217d89 100644 --- a/lib/widgets/user_profile.dart +++ b/lib/widgets/user_profile.dart @@ -104,17 +104,25 @@ class UserProfile extends HookWidget { )) .then((val) => val.posts), ), - InfiniteCommentList( - fetcher: (page, batchSize, sort) => LemmyApiV3(instanceHost) - .run(GetPersonDetails( - personId: userView.person.id, - savedOnly: false, - sort: SortType.active, - page: page, - limit: batchSize, - auth: accountsStore.defaultUserDataFor(instanceHost)?.jwt.raw, - )) - .then((val) => val.comments), + Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: InfiniteCommentList( + fetcher: (page, batchSize, sort) => LemmyApiV3(instanceHost) + .run(GetPersonDetails( + personId: userView.person.id, + savedOnly: false, + sort: SortType.active, + page: page, + limit: batchSize, + auth: accountsStore + .defaultUserDataFor(instanceHost) + ?.jwt + .raw, + )) + .then((val) => val.comments), + ), + ), ), _AboutTab(fullPersonView), ]), @@ -125,7 +133,7 @@ class UserProfile extends HookWidget { /// Content in the sliver flexible space /// Renders general info about the given user. -/// Such as his nickname, no. of posts, no. of posts, +/// Such as their nickname, no. of posts, no. of posts, /// banner, avatar etc. class _UserOverview extends HookWidget { final PersonViewSafe userView; diff --git a/lib/widgets/write_comment.dart b/lib/widgets/write_comment.dart index 960a95b7..f37bece9 100644 --- a/lib/widgets/write_comment.dart +++ b/lib/widgets/write_comment.dart @@ -54,7 +54,7 @@ class WriteComment extends HookWidget { return Column( children: [ SelectableText( - post.name, + '${post.instanceHost} > "${post.name}"', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), ), const SizedBox(height: 4), @@ -92,8 +92,13 @@ class WriteComment extends HookWidget { delayed.cancel(); } + final titleText = _isEdit + ? 'Editing comment' + : ('Replying to ${comment == null ? 'post' : 'comment'}'); + return Scaffold( appBar: AppBar( + title: Text(titleText), leading: const CloseButton(), actions: [ IconButton( @@ -102,45 +107,51 @@ class WriteComment extends HookWidget { ), ], ), - body: Stack( - children: [ - ListView( + body: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Stack( children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * .35), - child: SingleChildScrollView( - padding: const EdgeInsets.all(8), - child: preview, - ), - ), - const Divider(), - Editor( - controller: editorController, - autofocus: true, - fancy: showFancy.value, - ), - Row( - mainAxisAlignment: MainAxisAlignment.end, + ListView( children: [ - TextButton( - onPressed: - delayed.pending ? () {} : loggedInAction(handleSubmit), - child: delayed.loading - ? const CircularProgressIndicator.adaptive() - : Text(_isEdit - ? L10n.of(context).edit - : L10n.of(context).post), - ) + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * .35), + child: SingleChildScrollView( + padding: const EdgeInsets.all(8), + child: preview, + ), + ), + const Divider(), + Editor( + controller: editorController, + autofocus: true, + fancy: showFancy.value, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: delayed.pending + ? () {} + : loggedInAction(handleSubmit), + child: delayed.loading + ? const CircularProgressIndicator.adaptive() + : Text(_isEdit + ? L10n.of(context).edit + : L10n.of(context).post), + ) + ], + ), + EditorToolbar.safeArea, ], ), - EditorToolbar.safeArea, + BottomSticky( + child: EditorToolbar(editorController), + ), ], ), - BottomSticky( - child: EditorToolbar(editorController), - ), - ], + ), ), ); } diff --git a/pubspec.lock b/pubspec.lock index cf320dff..8aa74fc6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -619,7 +619,7 @@ packages: path: "." ref: master resolved-ref: a041bc24ce0459f1bd0cbce27dba3128e08c45c3 - url: "https://github.com/zachatrocity/lemmy_api_client.git" + url: "https://github.com/liftoff-app/lemmy_api_client.git" source: git version: "0.21.0" logging: diff --git a/pubspec.yaml b/pubspec.yaml index 8df27d1f..f796d701 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,7 +15,8 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. # Read more about iOS versioning at # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 0.9.11+12 +version: 0.9.11+13 +# TestFlight version: 0.0.1+9 environment: sdk: ">=2.17.0 <3.0.0"