From 9e7793f9491bd33abde5a83dd8114c7b22a5c9f5 Mon Sep 17 00:00:00 2001 From: shilangyu Date: Sun, 16 Jan 2022 16:37:25 +0100 Subject: [PATCH] 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 '''