diff --git a/assets/images/icons/1.5x/icon_block_checkbox_on_white.png b/assets/images/icons/1.5x/icon_block_checkbox_on_white.png new file mode 100644 index 000000000..87604cf9a Binary files /dev/null and b/assets/images/icons/1.5x/icon_block_checkbox_on_white.png differ diff --git a/assets/images/icons/2.0x/icon_block_checkbox_on_white.png b/assets/images/icons/2.0x/icon_block_checkbox_on_white.png new file mode 100644 index 000000000..941f46277 Binary files /dev/null and b/assets/images/icons/2.0x/icon_block_checkbox_on_white.png differ diff --git a/assets/images/icons/3.0x/icon_block_checkbox_on_white.png b/assets/images/icons/3.0x/icon_block_checkbox_on_white.png new file mode 100644 index 000000000..16c906cb9 Binary files /dev/null and b/assets/images/icons/3.0x/icon_block_checkbox_on_white.png differ diff --git a/assets/images/icons/4.0x/icon_block_checkbox_on_white.png b/assets/images/icons/4.0x/icon_block_checkbox_on_white.png new file mode 100644 index 000000000..71f767d19 Binary files /dev/null and b/assets/images/icons/4.0x/icon_block_checkbox_on_white.png differ diff --git a/assets/images/icons/icon_block_checkbox_on_white.png b/assets/images/icons/icon_block_checkbox_on_white.png new file mode 100644 index 000000000..29d602732 Binary files /dev/null and b/assets/images/icons/icon_block_checkbox_on_white.png differ diff --git a/lib/app/extensions/async_value_listener.dart b/lib/app/extensions/async_value_listener.dart new file mode 100644 index 000000000..85a1cb1a3 --- /dev/null +++ b/lib/app/extensions/async_value_listener.dart @@ -0,0 +1,24 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +extension AsyncValueListener on WidgetRef { + void listenAsyncValue( + ProviderListenable> provider, { + void Function()? onLoading, + void Function(TSuccess? response)? onSuccess, + void Function(Object erorr, StackTrace stackTrace)? onFailure, + bool skipLoadingOnReload = false, + bool skipLoadingOnRefresh = true, + bool skipError = false, + }) { + listen(provider, (previous, next) { + next.whenOrNull( + loading: onLoading, + data: onSuccess, + error: onFailure, + skipError: skipError, + skipLoadingOnRefresh: skipLoadingOnRefresh, + skipLoadingOnReload: skipLoadingOnReload, + ); + }); + } +} diff --git a/lib/app/features/feed/model/post_reply/post_reply_data.dart b/lib/app/features/feed/model/post_reply/post_reply_data.dart new file mode 100644 index 000000000..bced6bab9 --- /dev/null +++ b/lib/app/features/feed/model/post_reply/post_reply_data.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'post_reply_data.freezed.dart'; + +@Freezed(copyWith: true) +class PostReplyData with _$PostReplyData { + const factory PostReplyData({ + required String text, + }) = _PostReplyData; + + factory PostReplyData.empty() => PostReplyData( + text: '', + ); +} diff --git a/lib/app/features/feed/providers/post_by_id_provider.dart b/lib/app/features/feed/providers/post_by_id_provider.dart new file mode 100644 index 000000000..2fe8141b6 --- /dev/null +++ b/lib/app/features/feed/providers/post_by_id_provider.dart @@ -0,0 +1,20 @@ +import 'dart:convert'; + +import 'package:ice/app/features/feed/model/post/post_data.dart'; +import 'package:nostr_dart/nostr_dart.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'post_by_id_provider.g.dart'; + +@riverpod +PostData? postById(PostByIdRef ref, { + required String id, +}) { + return PostData.fromEventMessage( + EventMessage.fromJson( + json.decode( + r'["EVENT","5f6556d1-9a5e-4092-a7e3-a202857b445f",{"content":"GM https://image.nostr.build/d84c3d3a7abfa358106cad5a3ec0cc0888733f4cacda2b49cf3d7f9519003698.jpg","created_at":1720428050,"id":"0454657a5edeedf3db10b37dd5a3ca387f5714a2675f7e51539475e8fcb331de","kind":1,"pubkey":"d0c01dd5931409d2bc7e58ee4908e6366ff0fd722d20e9c709fde6846f3ceabb","sig":"263b97d9157f602b944859ebd3fe56851a5a786379bf38f7e81f6a58ec7acc5bca412f1e3a488ada033e240977bbc134f2f5047e4a9df32dcd19d96126c8a9ed","tags":[["e","6f2f2e100c8075d5ebae5866544ae243aad9e56916c3cb33e7a69c69004858e6","","root"],["p","6e468422dfb74a5738702a8823b9b28168abab8655faacb6853cd0ee15deee93"],["r","https://image.nostr.build/d84c3d3a7abfa358106cad5a3ec0cc0888733f4cacda2b49cf3d7f9519003698.jpg"],["imeta","url https://image.nostr.build/d84c3d3a7abfa358106cad5a3ec0cc0888733f4cacda2b49cf3d7f9519003698.jpg","m image/jpeg","alt Verifiable file url","x 1dc63792be80c939b207f089f69221df8755c0f6e38503f666e4619f1ccf9a12","size 340088","dim 864x1920","blurhash [RC@NqR.awadX;W?nzbdEFo$WBj]NPahjqj]o}bIofWUt7oHWEj=IVocjYa}Mwn$WYfRsiW=a#oI","ox d84c3d3a7abfa358106cad5a3ec0cc0888733f4cacda2b49cf3d7f9519003698"]]}]', + ) as List, + ), + ); +} diff --git a/lib/app/features/feed/providers/post_reply/reply_data_notifier.dart b/lib/app/features/feed/providers/post_reply/reply_data_notifier.dart new file mode 100644 index 000000000..150859f42 --- /dev/null +++ b/lib/app/features/feed/providers/post_reply/reply_data_notifier.dart @@ -0,0 +1,20 @@ +import 'package:ice/app/features/feed/model/post_reply/post_reply_data.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'reply_data_notifier.g.dart'; + +@riverpod +class ReplyDataNotifier extends _$ReplyDataNotifier { + @override + PostReplyData build() { + return PostReplyData.empty(); + } + + void onTextChanged(String newValue) { + state = state.copyWith(text: newValue); + } + + void clear() { + state = PostReplyData.empty(); + } +} diff --git a/lib/app/features/feed/providers/post_reply/send_reply_request_notifier.dart b/lib/app/features/feed/providers/post_reply/send_reply_request_notifier.dart new file mode 100644 index 000000000..d79d1c865 --- /dev/null +++ b/lib/app/features/feed/providers/post_reply/send_reply_request_notifier.dart @@ -0,0 +1,15 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'send_reply_request_notifier.g.dart'; + +@riverpod +class SendReplyRequestNotifier extends _$SendReplyRequestNotifier { + @override + FutureOr build() {} + + Future sendReply() async { + state = AsyncValue.loading(); + await Future.delayed(Duration(seconds: 1)); + state = AsyncValue.data(null); + } +} diff --git a/lib/app/features/feed/views/components/post/post.dart b/lib/app/features/feed/views/components/post/post.dart index 156342ae4..7e6c1edfc 100644 --- a/lib/app/features/feed/views/components/post/post.dart +++ b/lib/app/features/feed/views/components/post/post.dart @@ -24,7 +24,7 @@ class Post extends StatelessWidget { @override Widget build(BuildContext context) { return GestureDetector( - onTap: () => PostDetailsRoute($extra: postData).push(context), + onTap: () => PostDetailsRoute($extra: postData.id).push(context), child: ScreenSideOffset.small( child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/app/features/feed/views/components/post_replies/post_replies_action_bar.dart b/lib/app/features/feed/views/components/post_replies/post_replies_action_bar.dart index 8fb713df7..f9d3ebfd7 100644 --- a/lib/app/features/feed/views/components/post_replies/post_replies_action_bar.dart +++ b/lib/app/features/feed/views/components/post_replies/post_replies_action_bar.dart @@ -9,9 +9,12 @@ class PostRepliesActionBar extends StatelessWidget { const PostRepliesActionBar({ this.padding, this.shadowBuilder, + this.onSendPressed, super.key, }); + final VoidCallback? onSendPressed; + factory PostRepliesActionBar.withShadow({EdgeInsets? padding}) => PostRepliesActionBar( padding: padding ?? EdgeInsets.symmetric(horizontal: 16.0.s), shadowBuilder: _defaultShadowBuilder, @@ -57,7 +60,7 @@ class PostRepliesActionBar extends StatelessWidget { Button( minimumSize: Size(48.0.s, 28.0.s), borderRadius: BorderRadius.circular(100.0.s), - onPressed: () {}, + onPressed: onSendPressed ?? () {}, leadingIcon: Padding( padding: EdgeInsets.symmetric(vertical: 4.0.s), child: Assets.images.icons.iconFeedSendbutton.icon(size: 20.0.s), diff --git a/lib/app/features/feed/views/pages/post_details_page/components/post_not_found/post_not_found.dart b/lib/app/features/feed/views/pages/post_details_page/components/post_not_found/post_not_found.dart new file mode 100644 index 000000000..a8481ec7d --- /dev/null +++ b/lib/app/features/feed/views/pages/post_details_page/components/post_not_found/post_not_found.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:ice/app/extensions/extensions.dart'; +import 'package:ice/app/router/components/navigation_app_bar/navigation_app_bar.dart'; + +class PostNotFound extends StatelessWidget { + const PostNotFound({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: NavigationAppBar.screen( + title: Text(context.i18n.post_page_title), + ), + body: Center( + child: Text('Post not found'), + ), + ); + } +} diff --git a/lib/app/features/feed/views/pages/post_details_page/components/reply_input_field/reply_input_field.dart b/lib/app/features/feed/views/pages/post_details_page/components/reply_input_field/reply_input_field.dart index edbdb2358..2749e1b01 100644 --- a/lib/app/features/feed/views/pages/post_details_page/components/reply_input_field/reply_input_field.dart +++ b/lib/app/features/feed/views/pages/post_details_page/components/reply_input_field/reply_input_field.dart @@ -1,16 +1,19 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ice/app/extensions/asset_gen_image.dart'; import 'package:ice/app/extensions/build_context.dart'; import 'package:ice/app/extensions/num.dart'; import 'package:ice/app/extensions/theme_data.dart'; import 'package:ice/app/features/feed/model/post/post_data.dart'; +import 'package:ice/app/features/feed/providers/post_reply/reply_data_notifier.dart'; +import 'package:ice/app/features/feed/providers/post_reply/send_reply_request_notifier.dart'; import 'package:ice/app/features/feed/views/pages/post_details_page/components/reply_input_field/components/reply_author_header.dart'; import 'package:ice/app/features/feed/views/components/post_replies/post_replies_action_bar.dart'; import 'package:ice/app/router/app_routes.dart'; import 'package:ice/generated/assets.gen.dart'; -class ReplyInputField extends HookWidget { +class ReplyInputField extends HookConsumerWidget { const ReplyInputField({ required this.postData, super.key, @@ -19,11 +22,14 @@ class ReplyInputField extends HookWidget { final PostData postData; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final colors = context.theme.appColors; final textThemes = context.theme.appTextThemes; final isFocused = useState(false); + final textController = useTextEditingController( + text: ref.watch(replyDataNotifierProvider.select((data) => data.text)), + ); return Padding( padding: EdgeInsets.symmetric( @@ -52,6 +58,9 @@ class ReplyInputField extends HookWidget { child: Focus( onFocusChange: (value) => isFocused.value = value, child: TextField( + controller: textController, + onChanged: (value) => + ref.read(replyDataNotifierProvider.notifier).onTextChanged(value), style: textThemes.body2, decoration: InputDecoration( hintText: context.i18n.post_reply_hint, @@ -66,7 +75,10 @@ class ReplyInputField extends HookWidget { ), if (isFocused.value) GestureDetector( - onTap: () => ReplyExpandedRoute($extra: postData).push(context), + onTap: () async { + await ReplyExpandedRoute($extra: postData.id).push(context); + textController.text = ref.read(replyDataNotifierProvider).text; + }, child: Assets.images.icons.iconReplysearchScale.icon(size: 20.0.s), ), ], @@ -75,7 +87,10 @@ class ReplyInputField extends HookWidget { ), ), SizedBox(height: 12.0.s), - if (isFocused.value) const PostRepliesActionBar(), + if (isFocused.value) + PostRepliesActionBar( + onSendPressed: () => ref.read(sendReplyRequestNotifierProvider.notifier).sendReply(), + ), ], ), ); diff --git a/lib/app/features/feed/views/pages/post_details_page/components/reply_sent_notification/reply_sent_notification.dart b/lib/app/features/feed/views/pages/post_details_page/components/reply_sent_notification/reply_sent_notification.dart new file mode 100644 index 000000000..13a3d0c5e --- /dev/null +++ b/lib/app/features/feed/views/pages/post_details_page/components/reply_sent_notification/reply_sent_notification.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:ice/app/extensions/extensions.dart'; +import 'package:ice/generated/assets.gen.dart'; + +class ReplySentNotification extends StatelessWidget { + const ReplySentNotification({super.key}); + + @override + Widget build(BuildContext context) { + final colors = context.theme.appColors; + final textStyles = context.theme.appTextThemes; + + return SizedBox( + height: 54.0.s, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0.s), + color: colors.primaryAccent, + ), + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 15.0.s), + child: Row( + children: [ + Assets.images.icons.iconBlockCheckboxOnWhite.icon(), + SizedBox(width: 8.0.s), + Text( + context.i18n.post_reply_sent, + style: textStyles.subtitle2.copyWith(color: colors.onPrimaryAccent), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/app/features/feed/views/pages/post_details_page/post_details_page.dart b/lib/app/features/feed/views/pages/post_details_page/post_details_page.dart index e60be43ff..f04af81df 100644 --- a/lib/app/features/feed/views/pages/post_details_page/post_details_page.dart +++ b/lib/app/features/feed/views/pages/post_details_page/post_details_page.dart @@ -1,24 +1,40 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ice/app/components/screen_offset/screen_side_offset.dart'; import 'package:ice/app/extensions/extensions.dart'; -import 'package:ice/app/features/feed/model/post/post_data.dart'; +import 'package:ice/app/features/feed/providers/post_by_id_provider.dart'; +import 'package:ice/app/features/feed/providers/post_reply/send_reply_request_notifier.dart'; import 'package:ice/app/features/feed/views/components/list_separator/list_separator.dart'; import 'package:ice/app/features/feed/views/components/post/components/post_footer/post_details_footer.dart'; import 'package:ice/app/features/feed/views/components/post/post.dart'; import 'package:ice/app/features/feed/views/components/post_list/components/post_list.dart'; +import 'package:ice/app/features/feed/views/pages/post_details_page/components/post_not_found/post_not_found.dart'; import 'package:ice/app/features/feed/views/pages/post_details_page/components/reply_input_field/reply_input_field.dart'; +import 'package:ice/app/features/feed/views/pages/post_details_page/components/reply_sent_notification/reply_sent_notification.dart'; import 'package:ice/app/router/components/navigation_app_bar/navigation_app_bar.dart'; +import 'package:ice/app/extensions/async_value_listener.dart'; import 'package:ice/generated/assets.gen.dart'; -class PostDetailsPage extends StatelessWidget { +class PostDetailsPage extends HookConsumerWidget { const PostDetailsPage({ - required this.postData, + required this.postId, super.key, }); - final PostData postData; + final String postId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final postData = ref.watch(postByIdProvider(id: postId)); + + if (postData == null) { + return PostNotFound(); + } + + final showReplySentNotification = useState(false); + _listenReplySentNotification(ref, showReplySentNotification); + return Scaffold( appBar: NavigationAppBar.screen( title: Text(context.i18n.post_page_title), @@ -38,6 +54,18 @@ class PostDetailsPage extends StatelessWidget { Flexible( child: CustomScrollView( slivers: [ + SliverToBoxAdapter( + child: AnimatedCrossFade( + crossFadeState: showReplySentNotification.value + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + duration: Duration(milliseconds: 300), + firstChild: SizedBox.shrink(), + secondChild: ScreenSideOffset.small( + child: ReplySentNotification(), + ), + ), + ), SliverToBoxAdapter( child: Post( postData: postData, @@ -60,4 +88,18 @@ class PostDetailsPage extends StatelessWidget { ), ); } + + void _listenReplySentNotification( + WidgetRef ref, + ValueNotifier showReplySentNotification, + ) { + ref.listenAsyncValue( + sendReplyRequestNotifierProvider, + onSuccess: (response) async { + showReplySentNotification.value = true; + await Future.delayed(Duration(seconds: 2)); + showReplySentNotification.value = false; + }, + ); + } } diff --git a/lib/app/features/feed/views/pages/reply_expanded_page/components/expanded_reply_input_field.dart b/lib/app/features/feed/views/pages/reply_expanded_page/components/expanded_reply_input_field.dart index 42787fa99..e73eaea3d 100644 --- a/lib/app/features/feed/views/pages/reply_expanded_page/components/expanded_reply_input_field.dart +++ b/lib/app/features/feed/views/pages/reply_expanded_page/components/expanded_reply_input_field.dart @@ -1,20 +1,26 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ice/app/components/avatar/avatar.dart'; import 'package:ice/app/extensions/extensions.dart'; +import 'package:ice/app/features/feed/providers/post_reply/reply_data_notifier.dart'; import 'package:ice/app/hooks/use_on_init.dart'; -class ExpandedReplyInputField extends HookWidget { +class ExpandedReplyInputField extends HookConsumerWidget { const ExpandedReplyInputField({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final colors = context.theme.appColors; final textStyles = context.theme.appTextThemes; final focusNode = useFocusNode(); useOnInit(focusNode.requestFocus); + final textController = useTextEditingController( + text: ref.watch(replyDataNotifierProvider.select((data) => data.text)), + ); + return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -27,6 +33,9 @@ class ExpandedReplyInputField extends HookWidget { child: Padding( padding: EdgeInsets.only(top: 4.0.s), child: TextField( + controller: textController, + onChanged: (value) => + ref.read(replyDataNotifierProvider.notifier).onTextChanged(value), focusNode: focusNode, maxLines: null, minLines: 4, diff --git a/lib/app/features/feed/views/pages/reply_expanded_page/reply_expanded_page.dart b/lib/app/features/feed/views/pages/reply_expanded_page/reply_expanded_page.dart index 124960829..463f3cfee 100644 --- a/lib/app/features/feed/views/pages/reply_expanded_page/reply_expanded_page.dart +++ b/lib/app/features/feed/views/pages/reply_expanded_page/reply_expanded_page.dart @@ -1,8 +1,11 @@ import 'package:dotted_border/dotted_border.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:ice/app/extensions/extensions.dart'; import 'package:ice/app/features/feed/model/post/post_data.dart'; +import 'package:ice/app/features/feed/providers/post_by_id_provider.dart'; +import 'package:ice/app/features/feed/providers/post_reply/send_reply_request_notifier.dart'; import 'package:ice/app/features/feed/views/components/post/components/post_body/post_body.dart'; import 'package:ice/app/features/feed/views/components/post/components/post_header/post_header.dart'; import 'package:ice/app/features/feed/views/components/post_replies/post_replies_action_bar.dart'; @@ -12,32 +15,47 @@ import 'package:ice/app/router/components/navigation_app_bar/navigation_app_bar. import 'package:ice/app/router/components/sheet_content/sheet_content.dart'; import 'package:ice/generated/assets.gen.dart'; -class ReplyExpandedPage extends StatelessWidget { +class ReplyExpandedPage extends ConsumerWidget { const ReplyExpandedPage({ - required this.postData, + required this.postId, super.key, }); - final PostData postData; + final String postId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final postData = ref.watch(postByIdProvider(id: postId)); + + if (postData == null) { + return SizedBox.shrink(); + } + return SheetContent( bottomPadding: 0, body: Column( mainAxisSize: MainAxisSize.min, children: [ _DialogTitle(), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16.0.s), - child: Column( - children: [ - const PostHeader(), - _PostBody(postData: postData), - SizedBox(height: 12.0.s), - ExpandedReplyInputField(), - PostRepliesActionBar(), - ], + Flexible( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 16.0.s), + child: SingleChildScrollView( + child: Column( + children: [ + const PostHeader(), + _PostBody(postData: postData), + SizedBox(height: 12.0.s), + ExpandedReplyInputField(), + PostRepliesActionBar( + onSendPressed: () { + ref.read(sendReplyRequestNotifierProvider.notifier).sendReply(); + context.pop(); + }, + ), + ], + ), + ), ), ), ], diff --git a/lib/app/router/app_routes.dart b/lib/app/router/app_routes.dart index 6c4581290..c51150a90 100644 --- a/lib/app/router/app_routes.dart +++ b/lib/app/router/app_routes.dart @@ -293,11 +293,11 @@ class PostDetailsRoute extends BaseRouteData { PostDetailsRoute({required this.$extra}) : super( child: PostDetailsPage( - postData: $extra, + postId: $extra, ), ); - final PostData $extra; + final String $extra; } class ReplyExpandedRoute extends BaseRouteData { @@ -306,9 +306,9 @@ class ReplyExpandedRoute extends BaseRouteData { }) : super( type: IceRouteType.bottomSheet, child: ReplyExpandedPage( - postData: $extra, + postId: $extra, ), ); - final PostData $extra; + final String $extra; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 386bf3712..11433bc65 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -222,6 +222,7 @@ "post_reply_hint": "Write your reply", "post_replying_to": "Replying to", "post_reply": "Reply", + "post_reply_sent": "Your reply was sent", "send_nft_confirm_asset": "Asset", "send_nft_confirm_network": "Network", "send_nft_confirm_arrival_time": "Arrival time",