diff --git a/.github/workflows/weblate.yml b/.github/workflows/weblate.yml index a43eb00..ef5b43b 100644 --- a/.github/workflows/weblate.yml +++ b/.github/workflows/weblate.yml @@ -21,6 +21,10 @@ jobs: git fetch weblate git merge weblate/master + - name: Regenerate l10n_from_string + run: | + dart run scripts/gen_l10n_from_string.dart + - name: Create Pull Request uses: peter-evans/create-pull-request@v3.12.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6483f01..0f6a7a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Added +- Create post community picker now has autocomplete - You can now add an instance from the three dots menu on the instance page ## v0.8.0 - 2022-01-14 diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index eb54d4e..989d925 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -345,5 +345,11 @@ "add_instance": "Add instance", "@add_instance": {}, "instance_added": "Instance successfully added", - "@instance_added": {} + "@instance_added": {}, + "required_field": "required field", + "@required_field": {}, + "no_communities_found": "No communities found", + "@no_communities_found": {}, + "network_error": "Network error", + "@network_error": {} } diff --git a/lib/comment_tree.dart b/lib/comment_tree.dart index 2de4053..f935dac 100644 --- a/lib/comment_tree.dart +++ b/lib/comment_tree.dart @@ -5,7 +5,6 @@ import 'util/hot_rank.dart'; enum CommentSortType { hot, top, - // ignore: constant_identifier_names new_, old, chat, diff --git a/lib/hooks/image_picker.dart b/lib/hooks/image_picker.dart deleted file mode 100644 index 709d480..0000000 --- a/lib/hooks/image_picker.dart +++ /dev/null @@ -1,4 +0,0 @@ -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:image_picker/image_picker.dart'; - -ImagePicker useImagePicker() => useMemoized(ImagePicker.new); diff --git a/lib/l10n/l10n_from_string.dart b/lib/l10n/l10n_from_string.dart index d982ee0..36e2586 100644 --- a/lib/l10n/l10n_from_string.dart +++ b/lib/l10n/l10n_from_string.dart @@ -123,6 +123,7 @@ abstract class L10nStrings { static const number_of_posts = 'number_of_posts'; static const number_of_subscribers = 'number_of_subscribers'; static const number_of_users = 'number_of_users'; + static const number_of_communities = 'number_of_communities'; static const unsubscribe = 'unsubscribe'; static const subscribe = 'subscribe'; static const messages = 'messages'; @@ -145,6 +146,24 @@ abstract class L10nStrings { static const bot_account = 'bot_account'; static const show_bot_accounts = 'show_bot_accounts'; static const show_read_posts = 'show_read_posts'; + static const site_not_set_up = 'site_not_set_up'; + static const nerd_stuff = 'nerd_stuff'; + static const open_in_browser = 'open_in_browser'; + static const cannot_open_in_browser = 'cannot_open_in_browser'; + static const about = 'about'; + static const see_all = 'see_all'; + static const admins = 'admins'; + static const trending_communities = 'trending_communities'; + static const communities_of_instance = 'communities_of_instance'; + static const day = 'day'; + static const week = 'week'; + static const month = 'month'; + static const six_months = 'six_months'; + static const add_instance = 'add_instance'; + static const instance_added = 'instance_added'; + static const required_field = 'required_field'; + static const no_communities_found = 'no_communities_found'; + static const network_error = 'network_error'; } extension L10nFromString on String { @@ -406,6 +425,40 @@ extension L10nFromString on String { return L10n.of(context).show_bot_accounts; case L10nStrings.show_read_posts: return L10n.of(context).show_read_posts; + case L10nStrings.site_not_set_up: + return L10n.of(context).site_not_set_up; + case L10nStrings.nerd_stuff: + return L10n.of(context).nerd_stuff; + case L10nStrings.open_in_browser: + return L10n.of(context).open_in_browser; + case L10nStrings.cannot_open_in_browser: + return L10n.of(context).cannot_open_in_browser; + case L10nStrings.about: + return L10n.of(context).about; + case L10nStrings.see_all: + return L10n.of(context).see_all; + case L10nStrings.admins: + return L10n.of(context).admins; + case L10nStrings.trending_communities: + return L10n.of(context).trending_communities; + case L10nStrings.day: + return L10n.of(context).day; + case L10nStrings.week: + return L10n.of(context).week; + case L10nStrings.month: + return L10n.of(context).month; + case L10nStrings.six_months: + return L10n.of(context).six_months; + case L10nStrings.add_instance: + return L10n.of(context).add_instance; + case L10nStrings.instance_added: + return L10n.of(context).instance_added; + case L10nStrings.required_field: + return L10n.of(context).required_field; + case L10nStrings.no_communities_found: + return L10n.of(context).no_communities_found; + case L10nStrings.network_error: + return L10n.of(context).network_error; default: return this; diff --git a/lib/pages/community/community.dart b/lib/pages/community/community.dart index a648763..b6d63d7 100644 --- a/lib/pages/community/community.dart +++ b/lib/pages/community/community.dart @@ -16,7 +16,7 @@ import '../../util/share.dart'; import '../../widgets/failed_to_load.dart'; import '../../widgets/reveal_after_scroll.dart'; import '../../widgets/sortable_infinite_list.dart'; -import '../create_post.dart'; +import '../create_post/create_post_fab.dart'; import 'community_about_tab.dart'; import 'community_more_menu.dart'; import 'community_overview.dart'; diff --git a/lib/pages/create_post.dart b/lib/pages/create_post.dart deleted file mode 100644 index 39b8c18..0000000 --- a/lib/pages/create_post.dart +++ /dev/null @@ -1,366 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:lemmy_api_client/pictrs.dart'; -import 'package:lemmy_api_client/v3.dart'; - -import '../hooks/delayed_loading.dart'; -import '../hooks/image_picker.dart'; -import '../hooks/logged_in_action.dart'; -import '../hooks/memo_future.dart'; -import '../hooks/stores.dart'; -import '../l10n/l10n.dart'; -import '../util/extensions/api.dart'; -import '../util/extensions/spaced.dart'; -import '../util/pictrs.dart'; -import '../widgets/editor.dart'; -import '../widgets/markdown_mode_icon.dart'; -import '../widgets/radio_picker.dart'; -import 'full_post/full_post.dart'; - -/// Fab that triggers the [CreatePost] modal -/// After creation it will navigate to the newly created post -class CreatePostFab extends HookWidget { - final CommunityView? community; - - const CreatePostFab({this.community}); - - @override - Widget build(BuildContext context) { - final loggedInAction = useAnyLoggedInAction(); - - return FloatingActionButton( - onPressed: loggedInAction((_) async { - final postView = await Navigator.of(context).push( - community == null - ? CreatePostPage.route() - : CreatePostPage.toCommunityRoute(community!), - ); - - if (postView != null) { - await Navigator.of(context) - .push(FullPostPage.fromPostViewRoute(postView)); - } - }), - child: const Icon(Icons.add), - ); - } -} - -/// Modal for creating a post to some community in some instance -/// Pops the navigator stack with a [PostView] -class CreatePostPage extends HookWidget { - final CommunityView? community; - - final bool _isEdit; - final Post? post; - - const CreatePostPage() - : community = null, - _isEdit = false, - post = null; - const CreatePostPage.toCommunity(CommunityView this.community) - : _isEdit = false, - post = null; - const CreatePostPage.edit(Post this.post) - : _isEdit = true, - community = null; - - @override - Widget build(BuildContext context) { - final urlController = - useTextEditingController(text: _isEdit ? post?.url : null); - final titleController = - useTextEditingController(text: _isEdit ? post?.name : null); - final bodyController = - useTextEditingController(text: _isEdit ? post?.body : null); - final accStore = useAccountsStore(); - final selectedInstance = useState(_isEdit - ? post!.instanceHost - : community?.instanceHost ?? accStore.loggedInInstances.first); - final selectedCommunity = useState(community); - final showFancy = useState(false); - final nsfw = useState(_isEdit && post!.nsfw); - final delayed = useDelayedLoading(); - final imagePicker = useImagePicker(); - final imageUploadLoading = useState(false); - final pictrsDeleteToken = useState(null); - final loggedInAction = useLoggedInAction(selectedInstance.value); - - final titleFocusNode = useFocusNode(); - final bodyFocusNode = useFocusNode(); - - final allCommunitiesSnap = useMemoFuture( - () => LemmyApiV3(selectedInstance.value) - .run(ListCommunities( - type: PostListingType.all, - sort: SortType.hot, - limit: 9999, - auth: accStore.defaultUserDataFor(selectedInstance.value)?.jwt.raw, - )) - .then( - (value) { - value.sort((a, b) => a.community.name.compareTo(b.community.name)); - return value; - }, - ), - [selectedInstance.value], - ); - - uploadPicture(Jwt token) async { - try { - final pic = await imagePicker.pickImage(source: ImageSource.gallery); - // pic is null when the picker was cancelled - if (pic != null) { - imageUploadLoading.value = true; - - final pictrs = PictrsApi(selectedInstance.value); - final upload = - await pictrs.upload(filePath: pic.path, auth: token.raw); - pictrsDeleteToken.value = upload.files[0]; - urlController.text = - pathToPictrs(selectedInstance.value, upload.files[0].file); - } - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Failed to upload image'))); - } finally { - imageUploadLoading.value = false; - } - } - - removePicture(PictrsUploadFile deleteToken) { - PictrsApi(selectedInstance.value).delete(deleteToken).catchError((_) {}); - - pictrsDeleteToken.value = null; - urlController.text = ''; - } - - final instanceDropdown = RadioPicker( - values: accStore.loggedInInstances.toList(), - groupValue: selectedInstance.value, - onChanged: _isEdit ? null : (value) => selectedInstance.value = value, - buttonBuilder: (context, displayValue, onPressed) => TextButton( - onPressed: onPressed, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(displayValue), - const Icon(Icons.arrow_drop_down), - ], - ), - ), - ); - - DropdownMenuItem communityDropDownItem(CommunityView e) => - DropdownMenuItem( - value: e.community.id, - child: Text(e.community.local - ? e.community.name - : '${e.community.originInstanceHost}/${e.community.name}'), - ); - - List> communitiesList() { - if (allCommunitiesSnap.hasData) { - return allCommunitiesSnap.data!.map(communityDropDownItem).toList(); - } else { - if (selectedCommunity.value != null) { - return [communityDropDownItem(selectedCommunity.value!)]; - } else { - return const [ - DropdownMenuItem( - value: -1, - child: CircularProgressIndicator.adaptive(), - ) - ]; - } - } - } - - handleSubmit(Jwt token) async { - if ((!_isEdit && selectedCommunity.value == null) || - titleController.text.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('Choosing a community and a title is required'), - )); - return; - } - - final api = LemmyApiV3(selectedInstance.value); - - delayed.start(); - try { - final res = await () { - if (_isEdit) { - return api.run(EditPost( - url: urlController.text.isEmpty ? null : urlController.text, - body: bodyController.text.isEmpty ? null : bodyController.text, - nsfw: nsfw.value, - name: titleController.text, - postId: post!.id, - auth: token.raw, - )); - } else { - return api.run(CreatePost( - url: urlController.text.isEmpty ? null : urlController.text, - body: bodyController.text.isEmpty ? null : bodyController.text, - nsfw: nsfw.value, - name: titleController.text, - communityId: selectedCommunity.value!.community.id, - auth: token.raw, - )); - } - }(); - Navigator.of(context).pop(res); - return; - } catch (e) { - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar(content: Text('Failed to post'))); - } - delayed.cancel(); - } - - // TODO: use lazy autocomplete - final communitiesDropdown = InputDecorator( - decoration: const InputDecoration( - contentPadding: EdgeInsets.symmetric(vertical: 1, horizontal: 20), - border: OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(10)), - ), - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - value: selectedCommunity.value?.community.id, - hint: Text(L10n.of(context).community), - onChanged: _isEdit - ? null - : (communityId) { - selectedCommunity.value = allCommunitiesSnap.data - ?.firstWhere((e) => e.community.id == communityId); - }, - items: communitiesList(), - ), - ), - ); - - final enabledUrlField = pictrsDeleteToken.value == null; - - final url = Row(children: [ - Expanded( - child: TextField( - enabled: enabledUrlField, - controller: urlController, - autofillHints: enabledUrlField ? const [AutofillHints.url] : null, - keyboardType: TextInputType.url, - onSubmitted: (_) => titleFocusNode.requestFocus(), - decoration: InputDecoration( - labelText: L10n.of(context).url, - suffixIcon: const Icon(Icons.link), - ), - ), - ), - const SizedBox(width: 5), - IconButton( - icon: imageUploadLoading.value - ? const CircularProgressIndicator.adaptive() - : Icon(pictrsDeleteToken.value == null - ? Icons.add_photo_alternate - : Icons.close), - onPressed: pictrsDeleteToken.value == null - ? loggedInAction(uploadPicture) - : () => removePicture(pictrsDeleteToken.value!), - tooltip: - pictrsDeleteToken.value == null ? 'Add picture' : 'Delete picture', - ) - ]); - - final title = TextField( - controller: titleController, - focusNode: titleFocusNode, - keyboardType: TextInputType.text, - textCapitalization: TextCapitalization.sentences, - onSubmitted: (_) => bodyFocusNode.requestFocus(), - minLines: 1, - maxLines: 2, - decoration: InputDecoration(labelText: L10n.of(context).title), - ); - - final body = Editor( - controller: bodyController, - focusNode: bodyFocusNode, - onSubmitted: (_) => - delayed.pending ? () {} : loggedInAction(handleSubmit), - labelText: L10n.of(context).body, - instanceHost: selectedInstance.value, - fancy: showFancy.value, - ); - - return Scaffold( - appBar: AppBar( - leading: const CloseButton(), - actions: [ - IconButton( - icon: markdownModeIcon(fancy: showFancy.value), - onPressed: () => showFancy.value = !showFancy.value, - ), - ], - ), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.all(5), - children: [ - instanceDropdown, - if (!_isEdit) communitiesDropdown, - url, - title, - body, - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - GestureDetector( - onTap: () => nsfw.value = !nsfw.value, - child: Row( - children: [ - Checkbox( - value: nsfw.value, - onChanged: (val) { - if (val != null) nsfw.value = val; - }, - ), - Text(L10n.of(context).nsfw) - ], - ), - ), - TextButton( - onPressed: - delayed.pending ? () {} : loggedInAction(handleSubmit), - child: delayed.loading - ? const CircularProgressIndicator.adaptive() - : Text(_isEdit - ? L10n.of(context).edit - : L10n.of(context).post), - ) - ], - ), - ].spaced(6), - ), - ), - ); - } - - static Route route() => MaterialPageRoute( - builder: (context) => const CreatePostPage(), - fullscreenDialog: true, - ); - - static Route toCommunityRoute(CommunityView community) => - MaterialPageRoute( - builder: (context) => CreatePostPage.toCommunity(community), - fullscreenDialog: true, - ); - - static Route editRoute(Post post) => MaterialPageRoute( - builder: (context) => CreatePostPage.edit(post), - fullscreenDialog: true, - ); -} diff --git a/lib/pages/create_post/create_post.dart b/lib/pages/create_post/create_post.dart new file mode 100644 index 0000000..eb016ec --- /dev/null +++ b/lib/pages/create_post/create_post.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:wc_form_validators/wc_form_validators.dart'; + +import '../../hooks/logged_in_action.dart'; +import '../../hooks/stores.dart'; +import '../../l10n/l10n.dart'; +import '../../stores/accounts_store.dart'; +import '../../util/async_store_listener.dart'; +import '../../util/extensions/spaced.dart'; +import '../../util/mobx_provider.dart'; +import '../../util/observer_consumers.dart'; +import '../../widgets/editor.dart'; +import '../../widgets/markdown_mode_icon.dart'; +import 'create_post_community_picker.dart'; +import 'create_post_instance_picker.dart'; +import 'create_post_store.dart'; +import 'create_post_url_field.dart'; + +/// Modal for creating a post to some community in some instance +/// Pops the navigator stack with a [PostView] +class CreatePostPage extends HookWidget { + const CreatePostPage(); + + @override + Widget build(BuildContext context) { + final formKey = useMemoized(GlobalKey.new); + final loggedInAction = useLoggedInAction( + useStore((CreatePostStore store) => store.instanceHost), + ); + + final titleFocusNode = useFocusNode(); + final bodyFocusNode = useFocusNode(); + + handleSubmit(Jwt token) async { + if (formKey.currentState!.validate()) { + await context.read().submit(token); + } + } + + final title = ObserverBuilder( + builder: (context, store) => TextFormField( + initialValue: store.title, + focusNode: titleFocusNode, + keyboardType: TextInputType.text, + textCapitalization: TextCapitalization.sentences, + textInputAction: TextInputAction.next, + validator: Validators.required(L10n.of(context).required_field), + onFieldSubmitted: (_) => bodyFocusNode.requestFocus(), + onChanged: (title) => store.title = title, + minLines: 1, + maxLines: 2, + decoration: InputDecoration(labelText: L10n.of(context).title), + ), + ); + + final body = ObserverBuilder( + builder: (context, store) => Editor( + initialValue: store.body, + focusNode: bodyFocusNode, + onChanged: (body) => store.body = body, + labelText: L10n.of(context).body, + instanceHost: store.instanceHost, + fancy: store.showFancy, + ), + ); + + return AsyncStoreListener( + asyncStore: context.read().submitState, + onSuccess: (context, data) { + Navigator.of(context).pop(data); + }, + child: Scaffold( + appBar: AppBar( + actions: [ + ObserverBuilder( + builder: (context, store) => IconButton( + icon: markdownModeIcon(fancy: store.showFancy), + onPressed: () => store.showFancy = !store.showFancy, + ), + ), + ], + ), + body: SafeArea( + child: SingleChildScrollView( + 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, + 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) + ], + ), + ), + ), + 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, + ), + ), + ) + ], + ), + ].spaced(6), + ), + ), + ), + ), + ), + ); + } + + static Route route() => MaterialPageRoute( + builder: (context) => MobxProvider( + create: (context) => CreatePostStore( + instanceHost: context.read().loggedInInstances.first, + ), + child: const CreatePostPage(), + ), + fullscreenDialog: true, + ); + + static Route toCommunityRoute(CommunityView community) => + MaterialPageRoute( + builder: (context) => MobxProvider( + create: (context) => CreatePostStore( + instanceHost: community.instanceHost, + selectedCommunity: community, + ), + child: const CreatePostPage(), + ), + fullscreenDialog: true, + ); + + static Route editRoute(Post post) => MaterialPageRoute( + builder: (context) => MobxProvider( + create: (context) => CreatePostStore( + instanceHost: post.instanceHost, + postToEdit: post, + ), + child: const CreatePostPage(), + ), + fullscreenDialog: true, + ); +} diff --git a/lib/pages/create_post/create_post_community_picker.dart b/lib/pages/create_post/create_post_community_picker.dart new file mode 100644 index 0000000..4efc418 --- /dev/null +++ b/lib/pages/create_post/create_post_community_picker.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_typeahead/flutter_typeahead.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:wc_form_validators/wc_form_validators.dart'; + +import '../../l10n/l10n.dart'; +import '../../util/async_store_listener.dart'; +import '../../util/extensions/api.dart'; +import '../../util/extensions/context.dart'; +import '../../util/observer_consumers.dart'; +import '../../widgets/avatar.dart'; +import 'create_post_store.dart'; + +class CreatePostCommunityPicker extends HookWidget { + const CreatePostCommunityPicker({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final store = context.read(); + final controller = useTextEditingController( + text: store.selectedCommunity != null + ? _communityString(store.selectedCommunity!) + : '', + ); + + return AsyncStoreListener( + asyncStore: context.read().searchCommunitiesState, + child: Focus( + onFocusChange: (hasFocus) { + if (!hasFocus && store.selectedCommunity == null) { + controller.text = ''; + } + }, + child: ObserverBuilder(builder: (context, store) { + return TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: controller, + enabled: !store.isEdit, + decoration: InputDecoration( + hintText: L10n.of(context).community, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 20, + ), + suffixIcon: + store.selectedCommunity == null && controller.text.isEmpty + ? const Icon(Icons.arrow_drop_down) + : IconButton( + onPressed: () { + store.selectedCommunity = null; + controller.clear(); + }, + icon: const Icon(Icons.close), + ), + ), + onChanged: (_) => store.selectedCommunity = null, + ), + validator: Validators.required(L10n.of(context).required_field), + suggestionsCallback: (pattern) async { + final communities = await store.searchCommunities( + pattern, + context.defaultJwt(store.instanceHost), + ); + + return communities ?? []; + }, + itemBuilder: (context, community) { + return ListTile( + leading: Avatar( + url: community.community.icon, + radius: 20, + ), + title: Text(_communityString(community)), + ); + }, + onSuggestionSelected: (community) { + store.selectedCommunity = community; + controller.text = _communityString(community); + }, + noItemsFoundBuilder: (context) => SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + L10n.of(context).no_communities_found, + textAlign: TextAlign.center, + ), + ), + ), + loadingBuilder: (context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator.adaptive(), + ), + ], + ), + debounceDuration: const Duration(milliseconds: 400), + ); + }), + ), + ); + } +} + +String _communityString(CommunityView communityView) { + if (communityView.community.local) { + return communityView.community.title; + } else { + return '${communityView.community.originInstanceHost}/${communityView.community.title}'; + } +} diff --git a/lib/pages/create_post/create_post_fab.dart b/lib/pages/create_post/create_post_fab.dart new file mode 100644 index 0000000..26c190f --- /dev/null +++ b/lib/pages/create_post/create_post_fab.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lemmy_api_client/v3.dart'; + +import '../../hooks/logged_in_action.dart'; +import '../full_post/full_post.dart'; +import 'create_post.dart'; + +/// Fab that triggers the [CreatePost] modal +/// After creation it will navigate to the newly created post +class CreatePostFab extends HookWidget { + final CommunityView? community; + + const CreatePostFab({this.community}); + + @override + Widget build(BuildContext context) { + final loggedInAction = useAnyLoggedInAction(); + + return FloatingActionButton( + onPressed: loggedInAction((_) async { + final postView = await Navigator.of(context).push( + community == null + ? CreatePostPage.route() + : CreatePostPage.toCommunityRoute(community!), + ); + + if (postView != null) { + await Navigator.of(context) + .push(FullPostPage.fromPostViewRoute(postView)); + } + }), + child: const Icon(Icons.add), + ); + } +} diff --git a/lib/pages/create_post/create_post_instance_picker.dart b/lib/pages/create_post/create_post_instance_picker.dart new file mode 100644 index 0000000..641aa50 --- /dev/null +++ b/lib/pages/create_post/create_post_instance_picker.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +import '../../stores/accounts_store.dart'; +import '../../util/observer_consumers.dart'; +import '../../widgets/radio_picker.dart'; +import 'create_post_store.dart'; + +class CreatePostInstancePicker extends StatelessWidget { + const CreatePostInstancePicker({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final loggedInInstances = + context.watch().loggedInInstances.toList(); + + return ObserverBuilder( + builder: (context, store) => RadioPicker( + values: loggedInInstances, + groupValue: store.instanceHost, + onChanged: store.isEdit ? null : (value) => store.instanceHost = value, + buttonBuilder: (context, displayValue, onPressed) => TextButton( + onPressed: onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(displayValue), + const Icon(Icons.arrow_drop_down), + ], + ), + ), + ), + ); + } +} diff --git a/lib/pages/create_post/create_post_store.dart b/lib/pages/create_post/create_post_store.dart new file mode 100644 index 0000000..b0f2e06 --- /dev/null +++ b/lib/pages/create_post/create_post_store.dart @@ -0,0 +1,170 @@ +import 'package:lemmy_api_client/pictrs.dart'; +import 'package:lemmy_api_client/v3.dart'; +import 'package:mobx/mobx.dart'; + +import '../../util/async_store.dart'; +import '../../util/pictrs.dart'; + +part 'create_post_store.g.dart'; + +class CreatePostStore = _CreatePostStore with _$CreatePostStore; + +abstract class _CreatePostStore with Store { + final Post? postToEdit; + bool get isEdit => postToEdit != null; + + _CreatePostStore({ + required this.instanceHost, + this.postToEdit, + this.selectedCommunity, + }) : title = postToEdit?.name ?? '', + nsfw = postToEdit?.nsfw ?? false, + body = postToEdit?.body ?? '', + url = postToEdit?.url ?? ''; + + @observable + bool showFancy = false; + @observable + String instanceHost; + @observable + CommunityView? selectedCommunity; + @observable + String url; + @observable + String title; + @observable + String body; + @observable + bool nsfw; + + final submitState = AsyncStore(); + final searchCommunitiesState = AsyncStore>(); + final imageUploadState = AsyncStore(); + + @computed + bool get hasUploadedImage => imageUploadState.map( + loading: () => false, + error: (_) => false, + data: (_) => true, + ); + + @action + Future?> searchCommunities( + String searchTerm, + Jwt? token, + ) { + if (searchTerm.isEmpty) { + return searchCommunitiesState.runLemmy( + instanceHost, + ListCommunities( + type: PostListingType.all, + sort: SortType.topAll, + limit: 10, + auth: token?.raw, + ), + ); + } else { + return searchCommunitiesState.runLemmy( + instanceHost, + SearchCommunities( + q: searchTerm, + sort: SortType.topAll, + listingType: PostListingType.all, + limit: 10, + auth: token?.raw, + ), + ); + } + } + + @action + Future submit(Jwt token) async { + await submitState.runLemmy( + instanceHost, + isEdit + ? EditPost( + url: url.isEmpty ? null : url, + body: body.isEmpty ? null : body, + nsfw: nsfw, + name: title, + postId: postToEdit!.id, + auth: token.raw, + ) + : CreatePost( + url: url.isEmpty ? null : url, + body: body.isEmpty ? null : body, + nsfw: nsfw, + name: title, + communityId: selectedCommunity!.community.id, + auth: token.raw, + ), + ); + } + + @action + Future uploadImage(String filePath, Jwt token) async { + final instanceHost = this.instanceHost; + + final upload = await imageUploadState.run( + () => PictrsApi(instanceHost) + .upload( + filePath: filePath, + auth: token.raw, + ) + .then((value) => value.files.single), + ); + + if (upload != null) { + url = pathToPictrs(instanceHost, upload.file); + } + } + + @action + void removeImage() { + final pictrsFile = imageUploadState.map( + data: (data) => data, + loading: () => null, + error: (_) => null, + ); + if (pictrsFile == null) return; + + PictrsApi(instanceHost).delete(pictrsFile).catchError((_) {}); + + imageUploadState.reset(); + url = ''; + } +} + +class SearchCommunities implements LemmyApiQuery> { + final Search base; + + SearchCommunities({ + required String q, + PostListingType? listingType, + SortType? sort, + int? page, + int? limit, + String? auth, + }) : base = Search( + q: q, + type: SearchType.communities, + listingType: listingType, + sort: sort, + page: page, + limit: limit, + auth: auth, + ); + + @override + String get path => base.path; + + @override + HttpMethod get httpMethod => base.httpMethod; + + @override + List responseFactory(Map json) => + base.responseFactory(json).communities; + + @override + Map toJson() => base.toJson(); +} diff --git a/lib/pages/create_post/create_post_store.g.dart b/lib/pages/create_post/create_post_store.g.dart new file mode 100644 index 0000000..6c26729 --- /dev/null +++ b/lib/pages/create_post/create_post_store.g.dart @@ -0,0 +1,180 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'create_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 _$CreatePostStore on _CreatePostStore, Store { + Computed? _$hasUploadedImageComputed; + + @override + bool get hasUploadedImage => (_$hasUploadedImageComputed ??= Computed( + () => super.hasUploadedImage, + name: '_CreatePostStore.hasUploadedImage')) + .value; + + final _$showFancyAtom = Atom(name: '_CreatePostStore.showFancy'); + + @override + bool get showFancy { + _$showFancyAtom.reportRead(); + return super.showFancy; + } + + @override + set showFancy(bool value) { + _$showFancyAtom.reportWrite(value, super.showFancy, () { + super.showFancy = value; + }); + } + + final _$instanceHostAtom = Atom(name: '_CreatePostStore.instanceHost'); + + @override + String get instanceHost { + _$instanceHostAtom.reportRead(); + return super.instanceHost; + } + + @override + set instanceHost(String value) { + _$instanceHostAtom.reportWrite(value, super.instanceHost, () { + super.instanceHost = value; + }); + } + + final _$selectedCommunityAtom = + Atom(name: '_CreatePostStore.selectedCommunity'); + + @override + CommunityView? get selectedCommunity { + _$selectedCommunityAtom.reportRead(); + return super.selectedCommunity; + } + + @override + set selectedCommunity(CommunityView? value) { + _$selectedCommunityAtom.reportWrite(value, super.selectedCommunity, () { + super.selectedCommunity = value; + }); + } + + final _$urlAtom = Atom(name: '_CreatePostStore.url'); + + @override + String get url { + _$urlAtom.reportRead(); + return super.url; + } + + @override + set url(String value) { + _$urlAtom.reportWrite(value, super.url, () { + super.url = value; + }); + } + + final _$titleAtom = Atom(name: '_CreatePostStore.title'); + + @override + String get title { + _$titleAtom.reportRead(); + return super.title; + } + + @override + set title(String value) { + _$titleAtom.reportWrite(value, super.title, () { + super.title = value; + }); + } + + final _$bodyAtom = Atom(name: '_CreatePostStore.body'); + + @override + String get body { + _$bodyAtom.reportRead(); + return super.body; + } + + @override + set body(String value) { + _$bodyAtom.reportWrite(value, super.body, () { + super.body = value; + }); + } + + final _$nsfwAtom = Atom(name: '_CreatePostStore.nsfw'); + + @override + bool get nsfw { + _$nsfwAtom.reportRead(); + return super.nsfw; + } + + @override + set nsfw(bool value) { + _$nsfwAtom.reportWrite(value, super.nsfw, () { + super.nsfw = value; + }); + } + + final _$submitAsyncAction = AsyncAction('_CreatePostStore.submit'); + + @override + Future submit(Jwt token) { + return _$submitAsyncAction.run(() => super.submit(token)); + } + + final _$uploadImageAsyncAction = AsyncAction('_CreatePostStore.uploadImage'); + + @override + Future uploadImage(String filePath, Jwt token) { + return _$uploadImageAsyncAction + .run(() => super.uploadImage(filePath, token)); + } + + final _$_CreatePostStoreActionController = + ActionController(name: '_CreatePostStore'); + + @override + Future?> searchCommunities( + String searchTerm, Jwt? token) { + final _$actionInfo = _$_CreatePostStoreActionController.startAction( + name: '_CreatePostStore.searchCommunities'); + try { + return super.searchCommunities(searchTerm, token); + } finally { + _$_CreatePostStoreActionController.endAction(_$actionInfo); + } + } + + @override + void removeImage() { + final _$actionInfo = _$_CreatePostStoreActionController.startAction( + name: '_CreatePostStore.removeImage'); + try { + return super.removeImage(); + } finally { + _$_CreatePostStoreActionController.endAction(_$actionInfo); + } + } + + @override + String toString() { + return ''' +showFancy: ${showFancy}, +instanceHost: ${instanceHost}, +selectedCommunity: ${selectedCommunity}, +url: ${url}, +title: ${title}, +body: ${body}, +nsfw: ${nsfw}, +hasUploadedImage: ${hasUploadedImage} + '''; + } +} diff --git a/lib/pages/create_post/create_post_url_field.dart b/lib/pages/create_post/create_post_url_field.dart new file mode 100644 index 0000000..5f8ce2b --- /dev/null +++ b/lib/pages/create_post/create_post_url_field.dart @@ -0,0 +1,77 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lemmy_api_client/v3.dart'; + +import '../../hooks/logged_in_action.dart'; +import '../../hooks/stores.dart'; +import '../../l10n/l10n.dart'; +import '../../util/files.dart'; +import '../../util/observer_consumers.dart'; +import 'create_post_store.dart'; + +class CreatePostUrlField extends HookWidget { + final FocusNode titleFocusNode; + + const CreatePostUrlField(this.titleFocusNode); + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController( + text: context.read().url, + ); + final loggedInAction = useLoggedInAction( + useStore((CreatePostStore store) => store.instanceHost), + ); + + uploadImage(Jwt token) async { + final pic = await pickImage(); + + // pic is null when the picker was cancelled + if (pic != null) { + await context.read().uploadImage(pic.path, token); + } + } + + return ObserverConsumer( + listener: (context, store) { + // needed to keep the controller and store data in sync + if (controller.text != store.url) { + controller.text = store.url; + } + }, + builder: (context, store) => Row( + children: [ + Expanded( + child: TextFormField( + controller: controller, + enabled: !store.hasUploadedImage, + autofillHints: + !store.hasUploadedImage ? const [AutofillHints.url] : null, + keyboardType: TextInputType.url, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => titleFocusNode.requestFocus(), + onChanged: (url) => store.url = url, + decoration: InputDecoration( + labelText: L10n.of(context).url, + suffixIcon: const Icon(Icons.link), + ), + ), + ), + const SizedBox(width: 5), + IconButton( + icon: store.imageUploadState.isLoading + ? const CircularProgressIndicator.adaptive() + : Icon( + store.hasUploadedImage + ? Icons.close + : Icons.add_photo_alternate, + ), + onPressed: store.hasUploadedImage + ? () => store.removeImage() + : loggedInAction(uploadImage), + ), + ], + ), + ); + } +} diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 79794dd..6b3ecb5 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import '../util/extensions/brightness.dart'; import 'communities_tab.dart'; -import 'create_post.dart'; +import 'create_post/create_post_fab.dart'; import 'home_tab.dart'; import 'profile_tab.dart'; import 'search_tab.dart'; diff --git a/lib/pages/manage_account.dart b/lib/pages/manage_account.dart index 05459a2..60871eb 100644 --- a/lib/pages/manage_account.dart +++ b/lib/pages/manage_account.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:image_picker/image_picker.dart'; import 'package:lemmy_api_client/pictrs.dart'; import 'package:lemmy_api_client/v3.dart'; import '../hooks/delayed_loading.dart'; -import '../hooks/image_picker.dart'; import '../hooks/stores.dart'; import '../l10n/l10n.dart'; import '../url_launcher.dart'; +import '../util/files.dart'; import '../util/icons.dart'; import '../util/pictrs.dart'; import '../widgets/bottom_modal.dart'; @@ -414,13 +413,12 @@ class _ImagePicker extends HookWidget { final url = useState(initialUrl.value); final pictrsDeleteToken = useState(null); - final imagePicker = useImagePicker(); final accountsStore = useAccountsStore(); final delayedLoading = useDelayedLoading(); uploadImage() async { try { - final pic = await imagePicker.pickImage(source: ImageSource.gallery); + final pic = await pickImage(); // pic is null when the picker was cancelled if (pic != null) { delayedLoading.start(); diff --git a/lib/util/async_store.dart b/lib/util/async_store.dart index 2420bb3..2e31ad4 100644 --- a/lib/util/async_store.dart +++ b/lib/util/async_store.dart @@ -5,6 +5,8 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:lemmy_api_client/v3.dart'; import 'package:mobx/mobx.dart'; +import '../l10n/l10n_from_string.dart'; + part 'async_store.freezed.dart'; part 'async_store.g.dart'; @@ -28,6 +30,10 @@ abstract class _AsyncStore with Store { @action void setData(T data) => asyncState = AsyncState.data(data); + /// reset an asyncState to its initial one + @action + void reset() => asyncState = AsyncState.initial(); + /// runs some async action and reflects the progress in [asyncState]. /// If successful, the result is returned, otherwise null is returned. /// If this [AsyncStore] is already running some action, it will exit immediately and do nothing @@ -51,11 +57,10 @@ abstract class _AsyncStore with Store { return result; } on SocketException { - // TODO: use an existing l10n key if (data != null) { - asyncState = data.copyWith(errorTerm: 'network_error'); + asyncState = data.copyWith(errorTerm: L10nStrings.network_error); } else { - asyncState = const AsyncState.error('network_error'); + asyncState = const AsyncState.error(L10nStrings.network_error); } } catch (err) { if (data != null) { diff --git a/lib/util/async_store.g.dart b/lib/util/async_store.g.dart index 64eb8d5..185e317 100644 --- a/lib/util/async_store.g.dart +++ b/lib/util/async_store.g.dart @@ -71,6 +71,17 @@ mixin _$AsyncStore on _AsyncStore, Store { } } + @override + void reset() { + final _$actionInfo = + _$_AsyncStoreActionController.startAction(name: '_AsyncStore.reset'); + try { + return super.reset(); + } finally { + _$_AsyncStoreActionController.endAction(_$actionInfo); + } + } + @override String toString() { return ''' diff --git a/lib/util/async_store_listener.dart b/lib/util/async_store_listener.dart index a087710..88e4286 100644 --- a/lib/util/async_store_listener.dart +++ b/lib/util/async_store_listener.dart @@ -12,10 +12,16 @@ class AsyncStoreListener extends SingleChildStatelessWidget { T data, )? successMessageBuilder; + final void Function( + BuildContext context, + T data, + )? onSuccess; + const AsyncStoreListener({ Key? key, required this.asyncStore, this.successMessageBuilder, + this.onSuccess, Widget? child, }) : super(key: key, child: child); @@ -24,20 +30,27 @@ class AsyncStoreListener extends SingleChildStatelessWidget { return ObserverListener>( store: asyncStore, listener: (context, store) { - final errorTerm = store.errorTerm; + store.map( + loading: () {}, + error: (errorTerm) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(errorTerm.tr(context)))); + }, + data: (data) { + onSuccess?.call(context, data); - if (errorTerm != null) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(errorTerm.tr(context)))); - } else if (store.asyncState is AsyncStateData && - (successMessageBuilder != null)) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar( - content: Text(successMessageBuilder!( - context, (store.asyncState as AsyncStateData).data)))); - } + if (successMessageBuilder != null) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(successMessageBuilder!(context, data)), + ), + ); + } + }, + ); }, child: child ?? const SizedBox(), ); diff --git a/lib/util/files.dart b/lib/util/files.dart new file mode 100644 index 0000000..21d7bf8 --- /dev/null +++ b/lib/util/files.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:image_picker/image_picker.dart'; + +/// Picks a single image from the system +Future pickImage() async { + if (kIsWeb || Platform.isIOS || Platform.isAndroid) { + return ImagePicker().pickImage(source: ImageSource.gallery); + } else { + final result = await FilePicker.platform.pickFiles(type: FileType.image); + + if (result == null) return null; + + return XFile(result.files.single.path!); + } +} diff --git a/lib/widgets/editor.dart b/lib/widgets/editor.dart index af6e29b..f4d8a1f 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -8,9 +8,11 @@ class Editor extends HookWidget { final TextEditingController? controller; final FocusNode? focusNode; final ValueChanged? onSubmitted; + final ValueChanged? onChanged; final int? minLines; final int? maxLines; final String? labelText; + final String? initialValue; final bool autofocus; /// Whether the editor should be preview the contents @@ -22,9 +24,11 @@ class Editor extends HookWidget { this.controller, this.focusNode, this.onSubmitted, + this.onChanged, this.minLines = 5, this.maxLines, this.labelText, + this.initialValue, this.fancy = false, required this.instanceHost, this.autofocus = false, @@ -32,7 +36,7 @@ class Editor extends HookWidget { @override Widget build(BuildContext context) { - final defaultController = useTextEditingController(); + final defaultController = useTextEditingController(text: initialValue); final actualController = controller ?? defaultController; if (fancy) { @@ -51,6 +55,7 @@ class Editor extends HookWidget { autofocus: autofocus, keyboardType: TextInputType.multiline, textCapitalization: TextCapitalization.sentences, + onChanged: onChanged, onSubmitted: onSubmitted, maxLines: maxLines, minLines: minLines, diff --git a/lib/widgets/post/post_more_menu.dart b/lib/widgets/post/post_more_menu.dart index 5cc7138..1638a22 100644 --- a/lib/widgets/post/post_more_menu.dart +++ b/lib/widgets/post/post_more_menu.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import '../../hooks/logged_in_action.dart'; -import '../../pages/create_post.dart'; +import '../../pages/create_post/create_post.dart'; import '../../pages/full_post/full_post_store.dart'; import '../../stores/accounts_store.dart'; import '../../url_launcher.dart'; diff --git a/pubspec.lock b/pubspec.lock index f55b2dd..c83feb4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -211,6 +211,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "6.1.2" + file_picker: + dependency: "direct main" + description: + name: file_picker + url: "https://pub.dartlang.org" + source: hosted + version: "4.3.1" fixnum: dependency: transitive description: @@ -992,6 +999,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "1.0.1" + wc_form_validators: + dependency: "direct main" + description: + name: wc_form_validators + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.0" web_socket_channel: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 90ed4d8..2eed916 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,8 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + wc_form_validators: ^1.0.0 + file_picker: ^4.3.1 dev_dependencies: flutter_test: