Merge pull request #320 from LemmurOrg/feature/create-post-redesign

This commit is contained in:
Marcin Wojnarowski 2022-05-09 08:31:04 +02:00 committed by GitHub
commit 28be50a89e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 941 additions and 396 deletions

View File

@ -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:

View File

@ -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

View File

@ -345,5 +345,11 @@
"add_instance": "Add instance",
"@add_instance": {},
"instance_added": "Instance successfully added",
"@instance_added": {}
"@instance_added": {},
"required_field": "required field",
"@required_field": {},
"no_communities_found": "No communities found",
"@no_communities_found": {},
"network_error": "Network error",
"@network_error": {}
}

View File

@ -5,7 +5,6 @@ import 'util/hot_rank.dart';
enum CommentSortType {
hot,
top,
// ignore: constant_identifier_names
new_,
old,
chat,

View File

@ -1,4 +0,0 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:image_picker/image_picker.dart';
ImagePicker useImagePicker() => useMemoized(ImagePicker.new);

View File

@ -123,6 +123,7 @@ abstract class L10nStrings {
static const number_of_posts = 'number_of_posts';
static const number_of_subscribers = 'number_of_subscribers';
static const number_of_users = 'number_of_users';
static const number_of_communities = 'number_of_communities';
static const unsubscribe = 'unsubscribe';
static const subscribe = 'subscribe';
static const messages = 'messages';
@ -145,6 +146,24 @@ abstract class L10nStrings {
static const bot_account = 'bot_account';
static const show_bot_accounts = 'show_bot_accounts';
static const show_read_posts = 'show_read_posts';
static const site_not_set_up = 'site_not_set_up';
static const nerd_stuff = 'nerd_stuff';
static const open_in_browser = 'open_in_browser';
static const cannot_open_in_browser = 'cannot_open_in_browser';
static const about = 'about';
static const see_all = 'see_all';
static const admins = 'admins';
static const trending_communities = 'trending_communities';
static const communities_of_instance = 'communities_of_instance';
static const day = 'day';
static const week = 'week';
static const month = 'month';
static const six_months = 'six_months';
static const add_instance = 'add_instance';
static const instance_added = 'instance_added';
static const required_field = 'required_field';
static const no_communities_found = 'no_communities_found';
static const network_error = 'network_error';
}
extension L10nFromString on String {
@ -406,6 +425,40 @@ extension L10nFromString on String {
return L10n.of(context).show_bot_accounts;
case L10nStrings.show_read_posts:
return L10n.of(context).show_read_posts;
case L10nStrings.site_not_set_up:
return L10n.of(context).site_not_set_up;
case L10nStrings.nerd_stuff:
return L10n.of(context).nerd_stuff;
case L10nStrings.open_in_browser:
return L10n.of(context).open_in_browser;
case L10nStrings.cannot_open_in_browser:
return L10n.of(context).cannot_open_in_browser;
case L10nStrings.about:
return L10n.of(context).about;
case L10nStrings.see_all:
return L10n.of(context).see_all;
case L10nStrings.admins:
return L10n.of(context).admins;
case L10nStrings.trending_communities:
return L10n.of(context).trending_communities;
case L10nStrings.day:
return L10n.of(context).day;
case L10nStrings.week:
return L10n.of(context).week;
case L10nStrings.month:
return L10n.of(context).month;
case L10nStrings.six_months:
return L10n.of(context).six_months;
case L10nStrings.add_instance:
return L10n.of(context).add_instance;
case L10nStrings.instance_added:
return L10n.of(context).instance_added;
case L10nStrings.required_field:
return L10n.of(context).required_field;
case L10nStrings.no_communities_found:
return L10n.of(context).no_communities_found;
case L10nStrings.network_error:
return L10n.of(context).network_error;
default:
return this;

View File

@ -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';

View File

@ -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<PictrsUploadFile?>(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<String>(
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<int> communityDropDownItem(CommunityView e) =>
DropdownMenuItem(
value: e.community.id,
child: Text(e.community.local
? e.community.name
: '${e.community.originInstanceHost}/${e.community.name}'),
);
List<DropdownMenuItem<int>> 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<int>(
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<PostView> route() => MaterialPageRoute(
builder: (context) => const CreatePostPage(),
fullscreenDialog: true,
);
static Route<PostView> toCommunityRoute(CommunityView community) =>
MaterialPageRoute(
builder: (context) => CreatePostPage.toCommunity(community),
fullscreenDialog: true,
);
static Route<PostView> editRoute(Post post) => MaterialPageRoute(
builder: (context) => CreatePostPage.edit(post),
fullscreenDialog: true,
);
}

View File

@ -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<FormState>.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<CreatePostStore>().submit(token);
}
}
final title = ObserverBuilder<CreatePostStore>(
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<CreatePostStore>(
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<PostView>(
asyncStore: context.read<CreatePostStore>().submitState,
onSuccess: (context, data) {
Navigator.of(context).pop(data);
},
child: Scaffold(
appBar: AppBar(
actions: [
ObserverBuilder<CreatePostStore>(
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<CreatePostStore>().isEdit) ...const [
CreatePostInstancePicker(),
CreatePostCommunityPicker(),
],
CreatePostUrlField(titleFocusNode),
title,
body,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ObserverBuilder<CreatePostStore>(
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<CreatePostStore>(
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<PostView> route() => MaterialPageRoute(
builder: (context) => MobxProvider(
create: (context) => CreatePostStore(
instanceHost: context.read<AccountsStore>().loggedInInstances.first,
),
child: const CreatePostPage(),
),
fullscreenDialog: true,
);
static Route<PostView> toCommunityRoute(CommunityView community) =>
MaterialPageRoute(
builder: (context) => MobxProvider(
create: (context) => CreatePostStore(
instanceHost: community.instanceHost,
selectedCommunity: community,
),
child: const CreatePostPage(),
),
fullscreenDialog: true,
);
static Route<PostView> editRoute(Post post) => MaterialPageRoute(
builder: (context) => MobxProvider(
create: (context) => CreatePostStore(
instanceHost: post.instanceHost,
postToEdit: post,
),
child: const CreatePostPage(),
),
fullscreenDialog: true,
);
}

View File

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:wc_form_validators/wc_form_validators.dart';
import '../../l10n/l10n.dart';
import '../../util/async_store_listener.dart';
import '../../util/extensions/api.dart';
import '../../util/extensions/context.dart';
import '../../util/observer_consumers.dart';
import '../../widgets/avatar.dart';
import 'create_post_store.dart';
class CreatePostCommunityPicker extends HookWidget {
const CreatePostCommunityPicker({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final store = context.read<CreatePostStore>();
final controller = useTextEditingController(
text: store.selectedCommunity != null
? _communityString(store.selectedCommunity!)
: '',
);
return AsyncStoreListener(
asyncStore: context.read<CreatePostStore>().searchCommunitiesState,
child: Focus(
onFocusChange: (hasFocus) {
if (!hasFocus && store.selectedCommunity == null) {
controller.text = '';
}
},
child: ObserverBuilder<CreatePostStore>(builder: (context, store) {
return TypeAheadFormField<CommunityView>(
textFieldConfiguration: TextFieldConfiguration(
controller: controller,
enabled: !store.isEdit,
decoration: InputDecoration(
hintText: L10n.of(context).community,
contentPadding: const EdgeInsets.symmetric(
vertical: 16,
horizontal: 20,
),
suffixIcon:
store.selectedCommunity == null && controller.text.isEmpty
? const Icon(Icons.arrow_drop_down)
: IconButton(
onPressed: () {
store.selectedCommunity = null;
controller.clear();
},
icon: const Icon(Icons.close),
),
),
onChanged: (_) => store.selectedCommunity = null,
),
validator: Validators.required(L10n.of(context).required_field),
suggestionsCallback: (pattern) async {
final communities = await store.searchCommunities(
pattern,
context.defaultJwt(store.instanceHost),
);
return communities ?? [];
},
itemBuilder: (context, community) {
return ListTile(
leading: Avatar(
url: community.community.icon,
radius: 20,
),
title: Text(_communityString(community)),
);
},
onSuggestionSelected: (community) {
store.selectedCommunity = community;
controller.text = _communityString(community);
},
noItemsFoundBuilder: (context) => SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.all(8),
child: Text(
L10n.of(context).no_communities_found,
textAlign: TextAlign.center,
),
),
),
loadingBuilder: (context) => Row(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator.adaptive(),
),
],
),
debounceDuration: const Duration(milliseconds: 400),
);
}),
),
);
}
}
String _communityString(CommunityView communityView) {
if (communityView.community.local) {
return communityView.community.title;
} else {
return '${communityView.community.originInstanceHost}/${communityView.community.title}';
}
}

View File

@ -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),
);
}
}

View File

@ -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<AccountsStore>().loggedInInstances.toList();
return ObserverBuilder<CreatePostStore>(
builder: (context, store) => RadioPicker<String>(
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),
],
),
),
),
);
}
}

View File

@ -0,0 +1,170 @@
import 'package:lemmy_api_client/pictrs.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:mobx/mobx.dart';
import '../../util/async_store.dart';
import '../../util/pictrs.dart';
part 'create_post_store.g.dart';
class CreatePostStore = _CreatePostStore with _$CreatePostStore;
abstract class _CreatePostStore with Store {
final Post? postToEdit;
bool get isEdit => postToEdit != null;
_CreatePostStore({
required this.instanceHost,
this.postToEdit,
this.selectedCommunity,
}) : title = postToEdit?.name ?? '',
nsfw = postToEdit?.nsfw ?? false,
body = postToEdit?.body ?? '',
url = postToEdit?.url ?? '';
@observable
bool showFancy = false;
@observable
String instanceHost;
@observable
CommunityView? selectedCommunity;
@observable
String url;
@observable
String title;
@observable
String body;
@observable
bool nsfw;
final submitState = AsyncStore<PostView>();
final searchCommunitiesState = AsyncStore<List<CommunityView>>();
final imageUploadState = AsyncStore<PictrsUploadFile>();
@computed
bool get hasUploadedImage => imageUploadState.map(
loading: () => false,
error: (_) => false,
data: (_) => true,
);
@action
Future<List<CommunityView>?> searchCommunities(
String searchTerm,
Jwt? token,
) {
if (searchTerm.isEmpty) {
return searchCommunitiesState.runLemmy(
instanceHost,
ListCommunities(
type: PostListingType.all,
sort: SortType.topAll,
limit: 10,
auth: token?.raw,
),
);
} else {
return searchCommunitiesState.runLemmy(
instanceHost,
SearchCommunities(
q: searchTerm,
sort: SortType.topAll,
listingType: PostListingType.all,
limit: 10,
auth: token?.raw,
),
);
}
}
@action
Future<void> submit(Jwt token) async {
await submitState.runLemmy(
instanceHost,
isEdit
? EditPost(
url: url.isEmpty ? null : url,
body: body.isEmpty ? null : body,
nsfw: nsfw,
name: title,
postId: postToEdit!.id,
auth: token.raw,
)
: CreatePost(
url: url.isEmpty ? null : url,
body: body.isEmpty ? null : body,
nsfw: nsfw,
name: title,
communityId: selectedCommunity!.community.id,
auth: token.raw,
),
);
}
@action
Future<void> 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<PictrsUploadFile?>(
data: (data) => data,
loading: () => null,
error: (_) => null,
);
if (pictrsFile == null) return;
PictrsApi(instanceHost).delete(pictrsFile).catchError((_) {});
imageUploadState.reset();
url = '';
}
}
class SearchCommunities implements LemmyApiQuery<List<CommunityView>> {
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<CommunityView> responseFactory(Map<String, dynamic> json) =>
base.responseFactory(json).communities;
@override
Map<String, dynamic> toJson() => base.toJson();
}

View File

@ -0,0 +1,180 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_post_store.dart';
// **************************************************************************
// StoreGenerator
// **************************************************************************
// ignore_for_file: non_constant_identifier_names, unnecessary_brace_in_string_interps, unnecessary_lambdas, prefer_expression_function_bodies, lines_longer_than_80_chars, avoid_as, avoid_annotating_with_dynamic
mixin _$CreatePostStore on _CreatePostStore, Store {
Computed<bool>? _$hasUploadedImageComputed;
@override
bool get hasUploadedImage => (_$hasUploadedImageComputed ??= Computed<bool>(
() => super.hasUploadedImage,
name: '_CreatePostStore.hasUploadedImage'))
.value;
final _$showFancyAtom = Atom(name: '_CreatePostStore.showFancy');
@override
bool get showFancy {
_$showFancyAtom.reportRead();
return super.showFancy;
}
@override
set showFancy(bool value) {
_$showFancyAtom.reportWrite(value, super.showFancy, () {
super.showFancy = value;
});
}
final _$instanceHostAtom = Atom(name: '_CreatePostStore.instanceHost');
@override
String get instanceHost {
_$instanceHostAtom.reportRead();
return super.instanceHost;
}
@override
set instanceHost(String value) {
_$instanceHostAtom.reportWrite(value, super.instanceHost, () {
super.instanceHost = value;
});
}
final _$selectedCommunityAtom =
Atom(name: '_CreatePostStore.selectedCommunity');
@override
CommunityView? get selectedCommunity {
_$selectedCommunityAtom.reportRead();
return super.selectedCommunity;
}
@override
set selectedCommunity(CommunityView? value) {
_$selectedCommunityAtom.reportWrite(value, super.selectedCommunity, () {
super.selectedCommunity = value;
});
}
final _$urlAtom = Atom(name: '_CreatePostStore.url');
@override
String get url {
_$urlAtom.reportRead();
return super.url;
}
@override
set url(String value) {
_$urlAtom.reportWrite(value, super.url, () {
super.url = value;
});
}
final _$titleAtom = Atom(name: '_CreatePostStore.title');
@override
String get title {
_$titleAtom.reportRead();
return super.title;
}
@override
set title(String value) {
_$titleAtom.reportWrite(value, super.title, () {
super.title = value;
});
}
final _$bodyAtom = Atom(name: '_CreatePostStore.body');
@override
String get body {
_$bodyAtom.reportRead();
return super.body;
}
@override
set body(String value) {
_$bodyAtom.reportWrite(value, super.body, () {
super.body = value;
});
}
final _$nsfwAtom = Atom(name: '_CreatePostStore.nsfw');
@override
bool get nsfw {
_$nsfwAtom.reportRead();
return super.nsfw;
}
@override
set nsfw(bool value) {
_$nsfwAtom.reportWrite(value, super.nsfw, () {
super.nsfw = value;
});
}
final _$submitAsyncAction = AsyncAction('_CreatePostStore.submit');
@override
Future<void> submit(Jwt token) {
return _$submitAsyncAction.run(() => super.submit(token));
}
final _$uploadImageAsyncAction = AsyncAction('_CreatePostStore.uploadImage');
@override
Future<void> uploadImage(String filePath, Jwt token) {
return _$uploadImageAsyncAction
.run(() => super.uploadImage(filePath, token));
}
final _$_CreatePostStoreActionController =
ActionController(name: '_CreatePostStore');
@override
Future<List<CommunityView>?> searchCommunities(
String searchTerm, Jwt? token) {
final _$actionInfo = _$_CreatePostStoreActionController.startAction(
name: '_CreatePostStore.searchCommunities');
try {
return super.searchCommunities(searchTerm, token);
} finally {
_$_CreatePostStoreActionController.endAction(_$actionInfo);
}
}
@override
void removeImage() {
final _$actionInfo = _$_CreatePostStoreActionController.startAction(
name: '_CreatePostStore.removeImage');
try {
return super.removeImage();
} finally {
_$_CreatePostStoreActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''
showFancy: ${showFancy},
instanceHost: ${instanceHost},
selectedCommunity: ${selectedCommunity},
url: ${url},
title: ${title},
body: ${body},
nsfw: ${nsfw},
hasUploadedImage: ${hasUploadedImage}
''';
}
}

View File

@ -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<CreatePostStore>().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<CreatePostStore>().uploadImage(pic.path, token);
}
}
return ObserverConsumer<CreatePostStore>(
listener: (context, store) {
// needed to keep the controller and store data in sync
if (controller.text != store.url) {
controller.text = store.url;
}
},
builder: (context, store) => Row(
children: [
Expanded(
child: TextFormField(
controller: controller,
enabled: !store.hasUploadedImage,
autofillHints:
!store.hasUploadedImage ? const [AutofillHints.url] : null,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => titleFocusNode.requestFocus(),
onChanged: (url) => store.url = url,
decoration: InputDecoration(
labelText: L10n.of(context).url,
suffixIcon: const Icon(Icons.link),
),
),
),
const SizedBox(width: 5),
IconButton(
icon: store.imageUploadState.isLoading
? const CircularProgressIndicator.adaptive()
: Icon(
store.hasUploadedImage
? Icons.close
: Icons.add_photo_alternate,
),
onPressed: store.hasUploadedImage
? () => store.removeImage()
: loggedInAction(uploadImage),
),
],
),
);
}
}

View File

@ -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';

View File

@ -1,14 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lemmy_api_client/pictrs.dart';
import 'package:lemmy_api_client/v3.dart';
import '../hooks/delayed_loading.dart';
import '../hooks/image_picker.dart';
import '../hooks/stores.dart';
import '../l10n/l10n.dart';
import '../url_launcher.dart';
import '../util/files.dart';
import '../util/icons.dart';
import '../util/pictrs.dart';
import '../widgets/bottom_modal.dart';
@ -414,13 +413,12 @@ class _ImagePicker extends HookWidget {
final url = useState(initialUrl.value);
final pictrsDeleteToken = useState<PictrsUploadFile?>(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();

View File

@ -5,6 +5,8 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:lemmy_api_client/v3.dart';
import 'package:mobx/mobx.dart';
import '../l10n/l10n_from_string.dart';
part 'async_store.freezed.dart';
part 'async_store.g.dart';
@ -28,6 +30,10 @@ abstract class _AsyncStore<T> with Store {
@action
void setData(T data) => asyncState = AsyncState.data(data);
/// reset an asyncState to its initial one
@action
void reset() => asyncState = AsyncState<T>.initial();
/// runs some async action and reflects the progress in [asyncState].
/// If successful, the result is returned, otherwise null is returned.
/// If this [AsyncStore] is already running some action, it will exit immediately and do nothing
@ -51,11 +57,10 @@ abstract class _AsyncStore<T> 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) {

View File

@ -71,6 +71,17 @@ mixin _$AsyncStore<T> on _AsyncStore<T>, Store {
}
}
@override
void reset() {
final _$actionInfo =
_$_AsyncStoreActionController.startAction(name: '_AsyncStore.reset');
try {
return super.reset();
} finally {
_$_AsyncStoreActionController.endAction(_$actionInfo);
}
}
@override
String toString() {
return '''

View File

@ -12,10 +12,16 @@ class AsyncStoreListener<T> 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<T> extends SingleChildStatelessWidget {
return ObserverListener<AsyncStore<T>>(
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(),
);

18
lib/util/files.dart Normal file
View File

@ -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<XFile?> 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!);
}
}

View File

@ -8,9 +8,11 @@ class Editor extends HookWidget {
final TextEditingController? controller;
final FocusNode? focusNode;
final ValueChanged<String>? onSubmitted;
final ValueChanged<String>? onChanged;
final int? minLines;
final int? maxLines;
final String? labelText;
final String? initialValue;
final bool autofocus;
/// Whether the editor should be preview the contents
@ -22,9 +24,11 @@ class Editor extends HookWidget {
this.controller,
this.focusNode,
this.onSubmitted,
this.onChanged,
this.minLines = 5,
this.maxLines,
this.labelText,
this.initialValue,
this.fancy = false,
required this.instanceHost,
this.autofocus = false,
@ -32,7 +36,7 @@ class Editor extends HookWidget {
@override
Widget build(BuildContext context) {
final defaultController = useTextEditingController();
final defaultController = useTextEditingController(text: initialValue);
final actualController = controller ?? defaultController;
if (fancy) {
@ -51,6 +55,7 @@ class Editor extends HookWidget {
autofocus: autofocus,
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
onChanged: onChanged,
onSubmitted: onSubmitted,
maxLines: maxLines,
minLines: minLines,

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../../hooks/logged_in_action.dart';
import '../../pages/create_post.dart';
import '../../pages/create_post/create_post.dart';
import '../../pages/full_post/full_post_store.dart';
import '../../stores/accounts_store.dart';
import '../../url_launcher.dart';

View File

@ -211,6 +211,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "6.1.2"
file_picker:
dependency: "direct main"
description:
name: file_picker
url: "https://pub.dartlang.org"
source: hosted
version: "4.3.1"
fixnum:
dependency: transitive
description:
@ -992,6 +999,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.1"
wc_form_validators:
dependency: "direct main"
description:
name: wc_form_validators
url: "https://pub.dartlang.org"
source: hosted
version: "1.0.0"
web_socket_channel:
dependency: transitive
description:

View File

@ -60,6 +60,8 @@ dependencies:
sdk: flutter
flutter_localizations:
sdk: flutter
wc_form_validators: ^1.0.0
file_picker: ^4.3.1
dev_dependencies:
flutter_test: