From 7a13a94e51e85609d40f2d1d7e46a03ba19d0997 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sat, 15 Jan 2022 16:15:55 +0100 Subject: [PATCH 01/24] Initial refactor --- assets/l10n/intl_en.arb | 4 +- lib/pages/community/community.dart | 2 +- lib/pages/create_post/create_post_fab.dart | 36 +++++ .../create_post_instance_picker.dart | 34 +++++ lib/pages/create_post/create_post_store.dart | 63 ++++++++ .../create_post/create_post_store.g.dart | 137 ++++++++++++++++++ lib/pages/home_page.dart | 2 +- lib/util/async_store_listener.dart | 39 +++-- lib/widgets/editor.dart | 3 + lib/widgets/post/post_more_menu.dart | 2 +- pubspec.lock | 7 + pubspec.yaml | 1 + 12 files changed, 313 insertions(+), 17 deletions(-) create mode 100644 lib/pages/create_post/create_post_fab.dart create mode 100644 lib/pages/create_post/create_post_instance_picker.dart create mode 100644 lib/pages/create_post/create_post_store.dart create mode 100644 lib/pages/create_post/create_post_store.g.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index eb54d4e..6f51827 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -345,5 +345,7 @@ "add_instance": "Add instance", "@add_instance": {}, "instance_added": "Instance successfully added", - "@instance_added": {} + "@instance_added": {}, + "required_field": "required field", + "@required_field": {} } 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/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..a386d18 --- /dev/null +++ b/lib/pages/create_post/create_post_store.dart @@ -0,0 +1,63 @@ +import 'package:lemmy_api_client/v3.dart'; +import 'package:mobx/mobx.dart'; + +import '../../util/async_store.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(); + + @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, + ), + ); + } +} 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..3c4c81e --- /dev/null +++ b/lib/pages/create_post/create_post_store.g.dart @@ -0,0 +1,137 @@ +// 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 { + 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)); + } + + @override + String toString() { + return ''' +showFancy: ${showFancy}, +instanceHost: ${instanceHost}, +selectedCommunity: ${selectedCommunity}, +url: ${url}, +title: ${title}, +body: ${body}, +nsfw: ${nsfw} + '''; + } +} 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/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/widgets/editor.dart b/lib/widgets/editor.dart index af6e29b..401161e 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -8,6 +8,7 @@ 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; @@ -22,6 +23,7 @@ class Editor extends HookWidget { this.controller, this.focusNode, this.onSubmitted, + this.onChanged, this.minLines = 5, this.maxLines, this.labelText, @@ -51,6 +53,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 5a3720a..1743f3d 100644 --- a/lib/widgets/post/post_more_menu.dart +++ b/lib/widgets/post/post_more_menu.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:url_launcher/url_launcher.dart' as ul; 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 '../../util/icons.dart'; diff --git a/pubspec.lock b/pubspec.lock index 675dcba..06d0900 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -978,6 +978,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 917f70a..82af52b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,7 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + wc_form_validators: ^1.0.0 dev_dependencies: flutter_test: From 8eb4672bcdf29cd396d41e70992ebc4c486c8257 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 14:11:08 +0100 Subject: [PATCH 02/24] Regenerate weblate strings on PR --- .github/workflows/weblate.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/weblate.yml b/.github/workflows/weblate.yml index 14a8259..9466fca 100644 --- a/.github/workflows/weblate.yml +++ b/.github/workflows/weblate.yml @@ -18,6 +18,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: From 6a814ab12843565c0f13d97be4ec351136a044d4 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 14:16:24 +0100 Subject: [PATCH 03/24] Add searching to CreatePostStore --- assets/l10n/intl_en.arb | 6 +- lib/l10n/l10n_from_string.dart | 47 ++++++++++++++ lib/pages/create_post/create_post_store.dart | 64 ++++++++++++++++++++ lib/util/async_store.dart | 7 ++- lib/widgets/editor.dart | 4 +- 5 files changed, 123 insertions(+), 5 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 6f51827..989d925 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -347,5 +347,9 @@ "instance_added": "Instance successfully added", "@instance_added": {}, "required_field": "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/l10n/l10n_from_string.dart b/lib/l10n/l10n_from_string.dart index d982ee0..13920ee 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,22 @@ 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 required_field = 'required_field'; + static const no_communities_found = 'no_communities_found'; + static const network_error = 'network_error'; } extension L10nFromString on String { @@ -406,6 +423,36 @@ 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.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/create_post/create_post_store.dart b/lib/pages/create_post/create_post_store.dart index a386d18..93e7e50 100644 --- a/lib/pages/create_post/create_post_store.dart +++ b/lib/pages/create_post/create_post_store.dart @@ -36,6 +36,36 @@ abstract class _CreatePostStore with Store { bool nsfw; final submitState = AsyncStore(); + final searchCommunitiesState = AsyncStore>(); + + @action + Future?> searchCommunities( + String searchTerm, + Jwt? token, + ) { + if (searchTerm.isEmpty) { + return searchCommunitiesState.runLemmy( + instanceHost, + ListCommunities( + type: PostListingType.all, + sort: SortType.topAll, + limit: 20, + auth: token?.raw, + ), + ); + } else { + return searchCommunitiesState.runLemmy( + instanceHost, + SearchCommunities( + q: searchTerm, + sort: SortType.topAll, + listingType: PostListingType.all, + limit: 20, + auth: token?.raw, + ), + ); + } + } @action Future submit(Jwt token) async { @@ -61,3 +91,37 @@ abstract class _CreatePostStore with Store { ); } } + +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/util/async_store.dart b/lib/util/async_store.dart index 8c59d4d..01b2a54 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'; @@ -51,11 +53,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/widgets/editor.dart b/lib/widgets/editor.dart index 401161e..f4d8a1f 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -12,6 +12,7 @@ class Editor extends HookWidget { final int? minLines; final int? maxLines; final String? labelText; + final String? initialValue; final bool autofocus; /// Whether the editor should be preview the contents @@ -27,6 +28,7 @@ class Editor extends HookWidget { this.minLines = 5, this.maxLines, this.labelText, + this.initialValue, this.fancy = false, required this.instanceHost, this.autofocus = false, @@ -34,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) { From 7aad355b21e4d902d16be4660bd1e5aaf01793ff Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 15:03:22 +0100 Subject: [PATCH 04/24] Add community picker --- .../create_post_community_picker.dart | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 lib/pages/create_post/create_post_community_picker.dart 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..8e9b3ed --- /dev/null +++ b/lib/pages/create_post/create_post_community_picker.dart @@ -0,0 +1,102 @@ +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 '../../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 controller = useTextEditingController(); + final store = context.read(); + + return AsyncStoreListener( + asyncStore: context.read().searchCommunitiesState, + child: Focus( + onFocusChange: (hasFocus) { + if (!hasFocus && store.selectedCommunity == null) { + controller.text = ''; + } + }, + child: TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: controller, + enabled: !store.isEdit, + decoration: InputDecoration( + hintText: L10n.of(context).community, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 20, + ), + suffixIcon: const Icon(Icons.arrow_drop_down), + ), + onChanged: (_) => store.selectedCommunity = null, + ), + validator: (choice) { + if (choice?.isEmpty ?? false) { + return 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}'; + } +} From eeb9a84b6b0f6d09f0f671fded1db1e5467cffdc Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 15:11:27 +0100 Subject: [PATCH 05/24] Add create post page --- lib/pages/create_post.dart | 366 ------------------ lib/pages/create_post/create_post.dart | 175 +++++++++ .../create_post_community_picker.dart | 7 +- 3 files changed, 177 insertions(+), 371 deletions(-) delete mode 100644 lib/pages/create_post.dart create mode 100644 lib/pages/create_post/create_post.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 index 8e9b3ed..66b33d9 100644 --- a/lib/pages/create_post/create_post_community_picker.dart +++ b/lib/pages/create_post/create_post_community_picker.dart @@ -2,6 +2,7 @@ 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'; @@ -41,11 +42,7 @@ class CreatePostCommunityPicker extends HookWidget { ), onChanged: (_) => store.selectedCommunity = null, ), - validator: (choice) { - if (choice?.isEmpty ?? false) { - return L10n.of(context).required_field; - } - }, + validator: Validators.required(L10n.of(context).required_field), suggestionsCallback: (pattern) async { final communities = await store.searchCommunities( pattern, From 2b6ce0e6b2b790d982bb4bda084c25ceadfeb000 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 16:34:03 +0100 Subject: [PATCH 06/24] Add cross platform file picker --- lib/hooks/image_picker.dart | 4 ---- lib/pages/manage_account.dart | 6 ++---- lib/util/files.dart | 18 ++++++++++++++++++ pubspec.lock | 7 +++++++ pubspec.yaml | 1 + 5 files changed, 28 insertions(+), 8 deletions(-) delete mode 100644 lib/hooks/image_picker.dart create mode 100644 lib/util/files.dart 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/pages/manage_account.dart b/lib/pages/manage_account.dart index 2a0ba71..6cd017d 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 'package:url_launcher/url_launcher.dart' as ul; import '../hooks/delayed_loading.dart'; -import '../hooks/image_picker.dart'; import '../hooks/stores.dart'; import '../l10n/l10n.dart'; +import '../util/files.dart'; import '../util/icons.dart'; import '../util/pictrs.dart'; import '../widgets/bottom_modal.dart'; @@ -415,13 +414,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/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/pubspec.lock b/pubspec.lock index 06d0900..798f9f7 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: diff --git a/pubspec.yaml b/pubspec.yaml index 82af52b..2d7a9fd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: flutter_localizations: sdk: flutter wc_form_validators: ^1.0.0 + file_picker: ^4.3.1 dev_dependencies: flutter_test: From 9e7793f9491bd33abde5a83dd8114c7b22a5c9f5 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 16:37:25 +0100 Subject: [PATCH 07/24] Add image upload --- lib/pages/create_post/create_post_store.dart | 43 +++++++++++ .../create_post/create_post_store.g.dart | 45 ++++++++++- .../create_post/create_post_url_field.dart | 77 +++++++++++++++++++ lib/util/async_store.dart | 4 + lib/util/async_store.g.dart | 11 +++ 5 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 lib/pages/create_post/create_post_url_field.dart diff --git a/lib/pages/create_post/create_post_store.dart b/lib/pages/create_post/create_post_store.dart index 93e7e50..687360b 100644 --- a/lib/pages/create_post/create_post_store.dart +++ b/lib/pages/create_post/create_post_store.dart @@ -1,7 +1,9 @@ +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'; @@ -37,6 +39,14 @@ abstract class _CreatePostStore with Store { final submitState = AsyncStore(); final searchCommunitiesState = AsyncStore>(); + final imageUploadState = AsyncStore(); + + @computed + bool get hasUploadedImage => imageUploadState.map( + loading: () => false, + error: (_) => false, + data: (_) => true, + ); @action Future?> searchCommunities( @@ -90,6 +100,39 @@ abstract class _CreatePostStore with Store { ), ); } + + @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> { diff --git a/lib/pages/create_post/create_post_store.g.dart b/lib/pages/create_post/create_post_store.g.dart index 3c4c81e..6c26729 100644 --- a/lib/pages/create_post/create_post_store.g.dart +++ b/lib/pages/create_post/create_post_store.g.dart @@ -9,6 +9,14 @@ part of 'create_post_store.dart'; // 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 @@ -122,6 +130,40 @@ mixin _$CreatePostStore on _CreatePostStore, Store { 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 ''' @@ -131,7 +173,8 @@ selectedCommunity: ${selectedCommunity}, url: ${url}, title: ${title}, body: ${body}, -nsfw: ${nsfw} +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..ef5bf0f --- /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 since flutter's TextFields cannot work as dumb widgets + 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/util/async_store.dart b/lib/util/async_store.dart index 01b2a54..fd69e22 100644 --- a/lib/util/async_store.dart +++ b/lib/util/async_store.dart @@ -30,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 diff --git a/lib/util/async_store.g.dart b/lib/util/async_store.g.dart index 12e373e..b99b9a5 100644 --- a/lib/util/async_store.g.dart +++ b/lib/util/async_store.g.dart @@ -67,6 +67,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 ''' From 766762078cf3cab40c613def2f1c9e93733cb67b Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 16:48:14 +0100 Subject: [PATCH 08/24] Add missing community default --- lib/pages/create_post/create_post_community_picker.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pages/create_post/create_post_community_picker.dart b/lib/pages/create_post/create_post_community_picker.dart index 66b33d9..9db2925 100644 --- a/lib/pages/create_post/create_post_community_picker.dart +++ b/lib/pages/create_post/create_post_community_picker.dart @@ -17,8 +17,12 @@ class CreatePostCommunityPicker extends HookWidget { @override Widget build(BuildContext context) { - final controller = useTextEditingController(); final store = context.read(); + final controller = useTextEditingController( + text: store.selectedCommunity != null + ? _communityString(store.selectedCommunity!) + : '', + ); return AsyncStoreListener( asyncStore: context.read().searchCommunitiesState, From a615b27d64a48f7a84681eb39c8b84d9fa87e9c9 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 22:21:14 +0100 Subject: [PATCH 09/24] Update ios files --- ios/Podfile.lock | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 87527db..736aaf3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,38 @@ PODS: + - DKImagePickerController/Core (4.3.2): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.2) + - DKImagePickerController/PhotoGallery (4.3.2): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.2) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter - Flutter (1.0.0) - flutter_keyboard_visibility (0.0.1): - Flutter @@ -8,14 +42,19 @@ PODS: - Flutter - path_provider_ios (0.0.1): - Flutter + - SDWebImage (5.12.2): + - SDWebImage/Core (= 5.12.2) + - SDWebImage/Core (5.12.2) - share_plus (0.0.1): - Flutter - shared_preferences_ios (0.0.1): - Flutter + - SwiftyGif (5.4.2) - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - image_picker (from `.symlinks/plugins/image_picker/ios`) @@ -25,7 +64,16 @@ DEPENDENCIES: - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - SDWebImage + - SwiftyGif + EXTERNAL SOURCES: + file_picker: + :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter flutter_keyboard_visibility: @@ -44,13 +92,18 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: + DKImagePickerController: b5eb7f7a388e4643264105d648d01f727110fc3d + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 image_picker: 9aa50e1d8cdacdbed739e925b7eea16d014367e6 package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 + SDWebImage: 240e5c12b592fb1268c1d03b8c90d90e8c2ffe82 share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 shared_preferences_ios: aef470a42dc4675a1cdd50e3158b42e3d1232b32 + SwiftyGif: dec758a9dd3d278e5a855dbf279bf062c923c387 url_launcher_ios: 02f1989d4e14e998335b02b67a7590fa34f971af PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c From 652e912950043acf29f013daa5a54f38da583b2a Mon Sep 17 00:00:00 2001 From: shilangyu Date: Thu, 20 Jan 2022 11:57:14 +0100 Subject: [PATCH 10/24] Update l10n_from_string --- lib/l10n/l10n_from_string.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/l10n/l10n_from_string.dart b/lib/l10n/l10n_from_string.dart index 13920ee..36e2586 100644 --- a/lib/l10n/l10n_from_string.dart +++ b/lib/l10n/l10n_from_string.dart @@ -159,6 +159,8 @@ abstract class L10nStrings { 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'; @@ -447,6 +449,10 @@ extension L10nFromString on String { 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: From 389d1381b49153eed6343ffc9d6b425413237f00 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Thu, 20 Jan 2022 12:03:04 +0100 Subject: [PATCH 11/24] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From a8216819037b6c58d207fe5ee18c45e8d50029b2 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sat, 15 Jan 2022 16:15:55 +0100 Subject: [PATCH 12/24] Initial refactor --- assets/l10n/intl_en.arb | 4 +- lib/pages/community/community.dart | 2 +- lib/pages/create_post/create_post_fab.dart | 36 +++++ .../create_post_instance_picker.dart | 34 +++++ lib/pages/create_post/create_post_store.dart | 63 ++++++++ .../create_post/create_post_store.g.dart | 137 ++++++++++++++++++ lib/pages/home_page.dart | 2 +- lib/util/async_store_listener.dart | 39 +++-- lib/widgets/editor.dart | 3 + lib/widgets/post/post_more_menu.dart | 2 +- pubspec.lock | 7 + pubspec.yaml | 1 + 12 files changed, 313 insertions(+), 17 deletions(-) create mode 100644 lib/pages/create_post/create_post_fab.dart create mode 100644 lib/pages/create_post/create_post_instance_picker.dart create mode 100644 lib/pages/create_post/create_post_store.dart create mode 100644 lib/pages/create_post/create_post_store.g.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index eb54d4e..6f51827 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -345,5 +345,7 @@ "add_instance": "Add instance", "@add_instance": {}, "instance_added": "Instance successfully added", - "@instance_added": {} + "@instance_added": {}, + "required_field": "required field", + "@required_field": {} } 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/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..a386d18 --- /dev/null +++ b/lib/pages/create_post/create_post_store.dart @@ -0,0 +1,63 @@ +import 'package:lemmy_api_client/v3.dart'; +import 'package:mobx/mobx.dart'; + +import '../../util/async_store.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(); + + @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, + ), + ); + } +} 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..3c4c81e --- /dev/null +++ b/lib/pages/create_post/create_post_store.g.dart @@ -0,0 +1,137 @@ +// 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 { + 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)); + } + + @override + String toString() { + return ''' +showFancy: ${showFancy}, +instanceHost: ${instanceHost}, +selectedCommunity: ${selectedCommunity}, +url: ${url}, +title: ${title}, +body: ${body}, +nsfw: ${nsfw} + '''; + } +} 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/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/widgets/editor.dart b/lib/widgets/editor.dart index af6e29b..401161e 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -8,6 +8,7 @@ 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; @@ -22,6 +23,7 @@ class Editor extends HookWidget { this.controller, this.focusNode, this.onSubmitted, + this.onChanged, this.minLines = 5, this.maxLines, this.labelText, @@ -51,6 +53,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 5a3720a..1743f3d 100644 --- a/lib/widgets/post/post_more_menu.dart +++ b/lib/widgets/post/post_more_menu.dart @@ -3,7 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:url_launcher/url_launcher.dart' as ul; 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 '../../util/icons.dart'; diff --git a/pubspec.lock b/pubspec.lock index f16fa8f..b216c05 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -985,6 +985,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 33cb380..85d77d2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -60,6 +60,7 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter + wc_form_validators: ^1.0.0 dev_dependencies: flutter_test: From 8c54e38e99a0dd2bff8d3210bf574579f9193c42 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 14:11:08 +0100 Subject: [PATCH 13/24] Regenerate weblate strings on PR --- .github/workflows/weblate.yml | 4 ++++ 1 file changed, 4 insertions(+) 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: From c320585810a7b38a6c7772ed1d2db2066ea9b39b Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 14:16:24 +0100 Subject: [PATCH 14/24] Add searching to CreatePostStore --- assets/l10n/intl_en.arb | 6 +- lib/l10n/l10n_from_string.dart | 47 ++++++++++++++ lib/pages/create_post/create_post_store.dart | 64 ++++++++++++++++++++ lib/util/async_store.dart | 7 ++- lib/widgets/editor.dart | 4 +- 5 files changed, 123 insertions(+), 5 deletions(-) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 6f51827..989d925 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -347,5 +347,9 @@ "instance_added": "Instance successfully added", "@instance_added": {}, "required_field": "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/l10n/l10n_from_string.dart b/lib/l10n/l10n_from_string.dart index d982ee0..13920ee 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,22 @@ 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 required_field = 'required_field'; + static const no_communities_found = 'no_communities_found'; + static const network_error = 'network_error'; } extension L10nFromString on String { @@ -406,6 +423,36 @@ 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.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/create_post/create_post_store.dart b/lib/pages/create_post/create_post_store.dart index a386d18..93e7e50 100644 --- a/lib/pages/create_post/create_post_store.dart +++ b/lib/pages/create_post/create_post_store.dart @@ -36,6 +36,36 @@ abstract class _CreatePostStore with Store { bool nsfw; final submitState = AsyncStore(); + final searchCommunitiesState = AsyncStore>(); + + @action + Future?> searchCommunities( + String searchTerm, + Jwt? token, + ) { + if (searchTerm.isEmpty) { + return searchCommunitiesState.runLemmy( + instanceHost, + ListCommunities( + type: PostListingType.all, + sort: SortType.topAll, + limit: 20, + auth: token?.raw, + ), + ); + } else { + return searchCommunitiesState.runLemmy( + instanceHost, + SearchCommunities( + q: searchTerm, + sort: SortType.topAll, + listingType: PostListingType.all, + limit: 20, + auth: token?.raw, + ), + ); + } + } @action Future submit(Jwt token) async { @@ -61,3 +91,37 @@ abstract class _CreatePostStore with Store { ); } } + +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/util/async_store.dart b/lib/util/async_store.dart index 2420bb3..0d62611 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'; @@ -51,11 +53,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/widgets/editor.dart b/lib/widgets/editor.dart index 401161e..f4d8a1f 100644 --- a/lib/widgets/editor.dart +++ b/lib/widgets/editor.dart @@ -12,6 +12,7 @@ class Editor extends HookWidget { final int? minLines; final int? maxLines; final String? labelText; + final String? initialValue; final bool autofocus; /// Whether the editor should be preview the contents @@ -27,6 +28,7 @@ class Editor extends HookWidget { this.minLines = 5, this.maxLines, this.labelText, + this.initialValue, this.fancy = false, required this.instanceHost, this.autofocus = false, @@ -34,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) { From f6191936e61a221a246966b4c1b202bbc989e616 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 15:03:22 +0100 Subject: [PATCH 15/24] Add community picker --- .../create_post_community_picker.dart | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 lib/pages/create_post/create_post_community_picker.dart 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..8e9b3ed --- /dev/null +++ b/lib/pages/create_post/create_post_community_picker.dart @@ -0,0 +1,102 @@ +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 '../../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 controller = useTextEditingController(); + final store = context.read(); + + return AsyncStoreListener( + asyncStore: context.read().searchCommunitiesState, + child: Focus( + onFocusChange: (hasFocus) { + if (!hasFocus && store.selectedCommunity == null) { + controller.text = ''; + } + }, + child: TypeAheadFormField( + textFieldConfiguration: TextFieldConfiguration( + controller: controller, + enabled: !store.isEdit, + decoration: InputDecoration( + hintText: L10n.of(context).community, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 20, + ), + suffixIcon: const Icon(Icons.arrow_drop_down), + ), + onChanged: (_) => store.selectedCommunity = null, + ), + validator: (choice) { + if (choice?.isEmpty ?? false) { + return 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}'; + } +} From 8838efc0738be1bc19ae84ae7b5ebaec7c726984 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 15:11:27 +0100 Subject: [PATCH 16/24] Add create post page --- lib/pages/create_post.dart | 366 ------------------ lib/pages/create_post/create_post.dart | 175 +++++++++ .../create_post_community_picker.dart | 7 +- 3 files changed, 177 insertions(+), 371 deletions(-) delete mode 100644 lib/pages/create_post.dart create mode 100644 lib/pages/create_post/create_post.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 index 8e9b3ed..66b33d9 100644 --- a/lib/pages/create_post/create_post_community_picker.dart +++ b/lib/pages/create_post/create_post_community_picker.dart @@ -2,6 +2,7 @@ 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'; @@ -41,11 +42,7 @@ class CreatePostCommunityPicker extends HookWidget { ), onChanged: (_) => store.selectedCommunity = null, ), - validator: (choice) { - if (choice?.isEmpty ?? false) { - return L10n.of(context).required_field; - } - }, + validator: Validators.required(L10n.of(context).required_field), suggestionsCallback: (pattern) async { final communities = await store.searchCommunities( pattern, From 85f9d3fd0ea80a151964ff42f677885fc8b41693 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 16:34:03 +0100 Subject: [PATCH 17/24] Add cross platform file picker --- lib/hooks/image_picker.dart | 4 ---- lib/pages/manage_account.dart | 6 ++---- lib/util/files.dart | 18 ++++++++++++++++++ pubspec.lock | 7 +++++++ pubspec.yaml | 1 + 5 files changed, 28 insertions(+), 8 deletions(-) delete mode 100644 lib/hooks/image_picker.dart create mode 100644 lib/util/files.dart 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/pages/manage_account.dart b/lib/pages/manage_account.dart index 2a0ba71..6cd017d 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 'package:url_launcher/url_launcher.dart' as ul; import '../hooks/delayed_loading.dart'; -import '../hooks/image_picker.dart'; import '../hooks/stores.dart'; import '../l10n/l10n.dart'; +import '../util/files.dart'; import '../util/icons.dart'; import '../util/pictrs.dart'; import '../widgets/bottom_modal.dart'; @@ -415,13 +414,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/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/pubspec.lock b/pubspec.lock index b216c05..984edbc 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: diff --git a/pubspec.yaml b/pubspec.yaml index 85d77d2..8aba46e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -61,6 +61,7 @@ dependencies: flutter_localizations: sdk: flutter wc_form_validators: ^1.0.0 + file_picker: ^4.3.1 dev_dependencies: flutter_test: From d64fe4832868677191db3c4a2c329460581371ce Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 16:37:25 +0100 Subject: [PATCH 18/24] Add image upload --- lib/pages/create_post/create_post_store.dart | 43 +++++++++++ .../create_post/create_post_store.g.dart | 45 ++++++++++- .../create_post/create_post_url_field.dart | 77 +++++++++++++++++++ lib/util/async_store.dart | 4 + lib/util/async_store.g.dart | 11 +++ 5 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 lib/pages/create_post/create_post_url_field.dart diff --git a/lib/pages/create_post/create_post_store.dart b/lib/pages/create_post/create_post_store.dart index 93e7e50..687360b 100644 --- a/lib/pages/create_post/create_post_store.dart +++ b/lib/pages/create_post/create_post_store.dart @@ -1,7 +1,9 @@ +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'; @@ -37,6 +39,14 @@ abstract class _CreatePostStore with Store { final submitState = AsyncStore(); final searchCommunitiesState = AsyncStore>(); + final imageUploadState = AsyncStore(); + + @computed + bool get hasUploadedImage => imageUploadState.map( + loading: () => false, + error: (_) => false, + data: (_) => true, + ); @action Future?> searchCommunities( @@ -90,6 +100,39 @@ abstract class _CreatePostStore with Store { ), ); } + + @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> { diff --git a/lib/pages/create_post/create_post_store.g.dart b/lib/pages/create_post/create_post_store.g.dart index 3c4c81e..6c26729 100644 --- a/lib/pages/create_post/create_post_store.g.dart +++ b/lib/pages/create_post/create_post_store.g.dart @@ -9,6 +9,14 @@ part of 'create_post_store.dart'; // 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 @@ -122,6 +130,40 @@ mixin _$CreatePostStore on _CreatePostStore, Store { 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 ''' @@ -131,7 +173,8 @@ selectedCommunity: ${selectedCommunity}, url: ${url}, title: ${title}, body: ${body}, -nsfw: ${nsfw} +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..ef5bf0f --- /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 since flutter's TextFields cannot work as dumb widgets + 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/util/async_store.dart b/lib/util/async_store.dart index 0d62611..2e31ad4 100644 --- a/lib/util/async_store.dart +++ b/lib/util/async_store.dart @@ -30,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 diff --git a/lib/util/async_store.g.dart b/lib/util/async_store.g.dart index 12e373e..b99b9a5 100644 --- a/lib/util/async_store.g.dart +++ b/lib/util/async_store.g.dart @@ -67,6 +67,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 ''' From 23f64387a7d222fe7fcb0607b7c097aaa6e8a8c2 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 16:48:14 +0100 Subject: [PATCH 19/24] Add missing community default --- lib/pages/create_post/create_post_community_picker.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/pages/create_post/create_post_community_picker.dart b/lib/pages/create_post/create_post_community_picker.dart index 66b33d9..9db2925 100644 --- a/lib/pages/create_post/create_post_community_picker.dart +++ b/lib/pages/create_post/create_post_community_picker.dart @@ -17,8 +17,12 @@ class CreatePostCommunityPicker extends HookWidget { @override Widget build(BuildContext context) { - final controller = useTextEditingController(); final store = context.read(); + final controller = useTextEditingController( + text: store.selectedCommunity != null + ? _communityString(store.selectedCommunity!) + : '', + ); return AsyncStoreListener( asyncStore: context.read().searchCommunitiesState, From 44162f282cef252edd394b979eb249ee34ba153f Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 22:21:14 +0100 Subject: [PATCH 20/24] Update ios files --- ios/Podfile.lock | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 87527db..736aaf3 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,4 +1,38 @@ PODS: + - DKImagePickerController/Core (4.3.2): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.2) + - DKImagePickerController/PhotoGallery (4.3.2): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.2) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter - Flutter (1.0.0) - flutter_keyboard_visibility (0.0.1): - Flutter @@ -8,14 +42,19 @@ PODS: - Flutter - path_provider_ios (0.0.1): - Flutter + - SDWebImage (5.12.2): + - SDWebImage/Core (= 5.12.2) + - SDWebImage/Core (5.12.2) - share_plus (0.0.1): - Flutter - shared_preferences_ios (0.0.1): - Flutter + - SwiftyGif (5.4.2) - url_launcher_ios (0.0.1): - Flutter DEPENDENCIES: + - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_keyboard_visibility (from `.symlinks/plugins/flutter_keyboard_visibility/ios`) - image_picker (from `.symlinks/plugins/image_picker/ios`) @@ -25,7 +64,16 @@ DEPENDENCIES: - shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - SDWebImage + - SwiftyGif + EXTERNAL SOURCES: + file_picker: + :path: ".symlinks/plugins/file_picker/ios" Flutter: :path: Flutter flutter_keyboard_visibility: @@ -44,13 +92,18 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: + DKImagePickerController: b5eb7f7a388e4643264105d648d01f727110fc3d + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1 Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a flutter_keyboard_visibility: 0339d06371254c3eb25eeb90ba8d17dca8f9c069 image_picker: 9aa50e1d8cdacdbed739e925b7eea16d014367e6 package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 + SDWebImage: 240e5c12b592fb1268c1d03b8c90d90e8c2ffe82 share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 shared_preferences_ios: aef470a42dc4675a1cdd50e3158b42e3d1232b32 + SwiftyGif: dec758a9dd3d278e5a855dbf279bf062c923c387 url_launcher_ios: 02f1989d4e14e998335b02b67a7590fa34f971af PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c From d0b7c0776f439f6fe1b4c1aa7e617cfb7f17290c Mon Sep 17 00:00:00 2001 From: shilangyu Date: Thu, 20 Jan 2022 11:57:14 +0100 Subject: [PATCH 21/24] Update l10n_from_string --- lib/l10n/l10n_from_string.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/l10n/l10n_from_string.dart b/lib/l10n/l10n_from_string.dart index 13920ee..36e2586 100644 --- a/lib/l10n/l10n_from_string.dart +++ b/lib/l10n/l10n_from_string.dart @@ -159,6 +159,8 @@ abstract class L10nStrings { 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'; @@ -447,6 +449,10 @@ extension L10nFromString on String { 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: From c730216e134b440ed963f5d7c959bab3df26fe67 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Thu, 20 Jan 2022 12:03:04 +0100 Subject: [PATCH 22/24] Add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) 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 From 7bb8d2b33fac87465e49e4885225b0b036aa3963 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Tue, 3 May 2022 09:44:07 +0200 Subject: [PATCH 23/24] Fix code review comments --- lib/comment_tree.dart | 1 - .../create_post_community_picker.dart | 114 ++++++++++-------- lib/pages/create_post/create_post_store.dart | 4 +- .../create_post/create_post_url_field.dart | 2 +- 4 files changed, 65 insertions(+), 56 deletions(-) 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/pages/create_post/create_post_community_picker.dart b/lib/pages/create_post/create_post_community_picker.dart index 9db2925..33ddd31 100644 --- a/lib/pages/create_post/create_post_community_picker.dart +++ b/lib/pages/create_post/create_post_community_picker.dart @@ -32,63 +32,73 @@ class CreatePostCommunityPicker extends HookWidget { controller.text = ''; } }, - child: TypeAheadFormField( - textFieldConfiguration: TextFieldConfiguration( - controller: controller, - enabled: !store.isEdit, - decoration: InputDecoration( - hintText: L10n.of(context).community, - contentPadding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 20, + 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 + ? const Icon(Icons.arrow_drop_down) + : IconButton( + onPressed: () { + store.selectedCommunity = null; + controller.clear(); + }, + icon: const Icon(Icons.close), + ), ), - suffixIcon: const Icon(Icons.arrow_drop_down), + onChanged: (_) => store.selectedCommunity = null, ), - 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), - ); + 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, + 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), - ), + loadingBuilder: (context) => Row( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + Padding( + padding: EdgeInsets.all(8), + child: CircularProgressIndicator.adaptive(), + ), + ], + ), + debounceDuration: const Duration(milliseconds: 400), + ); + }), ), ); } diff --git a/lib/pages/create_post/create_post_store.dart b/lib/pages/create_post/create_post_store.dart index 687360b..b0f2e06 100644 --- a/lib/pages/create_post/create_post_store.dart +++ b/lib/pages/create_post/create_post_store.dart @@ -59,7 +59,7 @@ abstract class _CreatePostStore with Store { ListCommunities( type: PostListingType.all, sort: SortType.topAll, - limit: 20, + limit: 10, auth: token?.raw, ), ); @@ -70,7 +70,7 @@ abstract class _CreatePostStore with Store { q: searchTerm, sort: SortType.topAll, listingType: PostListingType.all, - limit: 20, + limit: 10, auth: token?.raw, ), ); diff --git a/lib/pages/create_post/create_post_url_field.dart b/lib/pages/create_post/create_post_url_field.dart index ef5bf0f..5f8ce2b 100644 --- a/lib/pages/create_post/create_post_url_field.dart +++ b/lib/pages/create_post/create_post_url_field.dart @@ -34,7 +34,7 @@ class CreatePostUrlField extends HookWidget { return ObserverConsumer( listener: (context, store) { - // needed since flutter's TextFields cannot work as dumb widgets + // needed to keep the controller and store data in sync if (controller.text != store.url) { controller.text = store.url; } From 926c0afe0a4f7bbf0fc0c402dfaaeb05f5cc0bfc Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 8 May 2022 10:43:12 +0200 Subject: [PATCH 24/24] Add extra controller condition --- .../create_post_community_picker.dart | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/lib/pages/create_post/create_post_community_picker.dart b/lib/pages/create_post/create_post_community_picker.dart index 33ddd31..4efc418 100644 --- a/lib/pages/create_post/create_post_community_picker.dart +++ b/lib/pages/create_post/create_post_community_picker.dart @@ -43,15 +43,16 @@ class CreatePostCommunityPicker extends HookWidget { vertical: 16, horizontal: 20, ), - suffixIcon: store.selectedCommunity == null - ? const Icon(Icons.arrow_drop_down) - : IconButton( - onPressed: () { - store.selectedCommunity = null; - controller.clear(); - }, - icon: const Icon(Icons.close), - ), + 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, ),