From 2dfed761c31234edbabd574a3160634fbfc5c190 Mon Sep 17 00:00:00 2001 From: Dev Ttangkong Date: Thu, 25 Jun 2026 04:21:28 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EA=B2=80=EC=83=89=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app/enum/post_type.enum.dart | 37 +++---- lib/app/enum/report.enum.dart | 22 ++-- lib/app/enum/search_type.enum.dart | 5 +- lib/app/enum/sort_type.enum.dart | 12 ++- lib/app/enum/subscription_type.enum.dart | 24 +++-- .../extension/build_context_extension.dart | 4 +- lib/data/data_source/remote/feed_api.dart | 3 +- .../repository_impl/feed_repository_impl.dart | 2 +- lib/domain/repository/post_repository.dart | 7 +- .../usecase/post/search_posts_usecase.dart | 14 ++- .../board/view/board_list_view.dart | 79 -------------- lib/presentation/board/view/board_view.dart | 28 +++-- .../widget/popup/grimity_menu_popup.dart | 19 +++- .../system/board/grimity_post_card.dart | 41 ++++++- .../system/board/grimity_post_feed.dart | 3 + .../system/board/grimity_post_view.dart | 94 ++++++++++++++++ .../widget/user_card/grimity_user_card.dart | 85 --------------- .../profile/view/profile_feed_tab_view.dart | 4 +- lib/presentation/search/search_page.dart | 2 - lib/presentation/search/search_view.dart | 84 +++++++++++---- .../search/view/search_empty_state.dart | 18 ++++ .../search/view/search_feed_tab_view.dart | 83 ++++---------- .../search/view/search_post_tab_view.dart | 75 +++++-------- .../view/search_recommend_tag_view.dart | 86 ++++++++++----- .../search/view/search_user_tab_view.dart | 54 ++++++---- .../search/view/search_welcome_state.dart | 15 +++ .../search/widget/search_app_bar.dart | 66 +++--------- .../widget/search_recommand_tag_bar.dart | 91 ++++++++++++++++ .../search/widget/search_tab_bar.dart | 101 ++++++++++++++---- pubspec.lock | 14 +-- 30 files changed, 683 insertions(+), 489 deletions(-) delete mode 100644 lib/presentation/board/view/board_list_view.dart create mode 100644 lib/presentation/common/widget/system/board/grimity_post_view.dart delete mode 100644 lib/presentation/common/widget/user_card/grimity_user_card.dart create mode 100644 lib/presentation/search/view/search_empty_state.dart create mode 100644 lib/presentation/search/view/search_welcome_state.dart create mode 100644 lib/presentation/search/widget/search_recommand_tag_bar.dart diff --git a/lib/app/enum/post_type.enum.dart b/lib/app/enum/post_type.enum.dart index 76383ff5..7eced209 100644 --- a/lib/app/enum/post_type.enum.dart +++ b/lib/app/enum/post_type.enum.dart @@ -1,40 +1,31 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:gds/gds.dart'; +import 'package:json_annotation/json_annotation.dart'; enum PostType { @JsonValue('NORMAL') - normal('일반'), + normal('일반', 'NORMAL'), + @JsonValue('QUESTION') - question('질문'), + question('질문', 'QUESTION'), + @JsonValue('FEEDBACK') - feedback('피드백'), + feedback('피드백', 'FEEDBACK'), + @JsonValue('NOTICE') - notice('공지'), - @JsonValue('ALL') - all('전체'); + notice('공지', 'NOTICE'), - final String displayName; + @JsonValue('All') + all('전체', 'ALL'); - const PostType(this.displayName); + final String displayName; + final String jsonKey; + const PostType(this.displayName, this.jsonKey); static PostType fromString(String value) { return PostType.values.firstWhere((e) => e.toJson() == value, orElse: () => PostType.normal); } - String toJson() { - switch (this) { - case PostType.normal: - return 'NORMAL'; - case PostType.question: - return 'QUESTION'; - case PostType.feedback: - return 'FEEDBACK'; - case PostType.notice: - return 'NOTICE'; - case PostType.all: - return 'ALL'; - } - } + String toJson() => jsonKey; GdsChipVariant get chipVariant => switch (this) { PostType.normal => GdsChipVariant.assistive, diff --git a/lib/app/enum/report.enum.dart b/lib/app/enum/report.enum.dart index 523c8d27..eb317add 100644 --- a/lib/app/enum/report.enum.dart +++ b/lib/app/enum/report.enum.dart @@ -17,15 +17,25 @@ enum ReportType { enum ReportRefType { @JsonValue('USER') - user, + user('USER'), + @JsonValue('FEED') - feed, + feed('FEED'), + @JsonValue('FEED_COMMENT') - feedComment, + feedComment('FEED_COMMENT'), + @JsonValue('POST') - post, + post('POST'), + @JsonValue('POST_COMMENT') - postComment, + postComment('POST_COMMENT'), + @JsonValue('CHAT') - chat, + chat('CHAT'); + + final String jsonKey; + const ReportRefType(this.jsonKey); + + String toJson() => jsonKey; } diff --git a/lib/app/enum/search_type.enum.dart b/lib/app/enum/search_type.enum.dart index e2795c78..cc46da58 100644 --- a/lib/app/enum/search_type.enum.dart +++ b/lib/app/enum/search_type.enum.dart @@ -1,11 +1,14 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:json_annotation/json_annotation.dart'; enum SearchType { @JsonValue('combined') combined('제목'), + @JsonValue('name') name('글쓴이'); final String displayName; const SearchType(this.displayName); + + String toJson() => this.name; } diff --git a/lib/app/enum/sort_type.enum.dart b/lib/app/enum/sort_type.enum.dart index 820435ee..fba84527 100644 --- a/lib/app/enum/sort_type.enum.dart +++ b/lib/app/enum/sort_type.enum.dart @@ -1,19 +1,23 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:json_annotation/json_annotation.dart'; enum SortType { @JsonValue('latest') latest('최신순'), + @JsonValue('like') like('좋아요순'), + @JsonValue('oldest') oldest('오래된순'), + @JsonValue('popular') popular('인기순'); - final String typeName; - - const SortType(this.typeName); + final String displayName; + const SortType(this.displayName); static List get profileFeedSortValues => [latest, like, oldest]; static List get searchFeedSortValues => [latest, popular]; + + String toJson() => name; } diff --git a/lib/app/enum/subscription_type.enum.dart b/lib/app/enum/subscription_type.enum.dart index b2fa383e..dea4816e 100644 --- a/lib/app/enum/subscription_type.enum.dart +++ b/lib/app/enum/subscription_type.enum.dart @@ -1,16 +1,26 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:json_annotation/json_annotation.dart'; enum SubscriptionType { @JsonValue('FOLLOW') - follow, + follow('FOLLOW'), + @JsonValue('FEED_LIKE') - feedLike, + feedLike('FEED_LIKE'), + @JsonValue('FEED_COMMENT') - feedComment, + feedComment('FEED_COMMENT'), + @JsonValue('FEED_REPLY') - feedReply, + feedReply('FEED_REPLY'), + @JsonValue('POST_COMMENT') - postComment, + postComment('POST_COMMENT'), + @JsonValue('POST_REPLY') - postReply, + postReply('POST_REPLY'); + + final String jsonKey; + const SubscriptionType(this.jsonKey); + + String toJson() => jsonKey; } diff --git a/lib/app/extension/build_context_extension.dart b/lib/app/extension/build_context_extension.dart index dc881f8a..c7f7f87b 100644 --- a/lib/app/extension/build_context_extension.dart +++ b/lib/app/extension/build_context_extension.dart @@ -18,8 +18,8 @@ extension BuildContextExtension on BuildContext { } /// 가로상으로 표시해야 할 사용자 피드에 대한 아이템의 최대 개수를 반환합니다. - int get authorFeedRowCount { - return feedRowCount + 1; + int get userRowCount { + return (feedRowCount / 2).toInt(); } /// 가로상으로 표시해야 할 이미지 카드에 대한 아이템의 최대 개수를 반환합니다. diff --git a/lib/data/data_source/remote/feed_api.dart b/lib/data/data_source/remote/feed_api.dart index d2d27c2d..659533fe 100644 --- a/lib/data/data_source/remote/feed_api.dart +++ b/lib/data/data_source/remote/feed_api.dart @@ -1,5 +1,4 @@ import 'package:dio/dio.dart' hide Headers; -import 'package:grimity/app/enum/sort_type.enum.dart'; import 'package:grimity/data/model/common/id_response.dart'; import 'package:grimity/data/model/feed/feed_detail_response.dart'; import 'package:grimity/data/model/feed/feed_rankings_response.dart'; @@ -29,7 +28,7 @@ abstract class FeedAPI { @Query('cursor') String? cursor, @Query('size') int? size, @Query('keyword') String keyword, - @Query('sort') SortType sort, + @Query('sort') String sort, ); @GET('/feeds/latest') diff --git a/lib/data/repository_impl/feed_repository_impl.dart b/lib/data/repository_impl/feed_repository_impl.dart index 8a54e043..a01a8f61 100644 --- a/lib/data/repository_impl/feed_repository_impl.dart +++ b/lib/data/repository_impl/feed_repository_impl.dart @@ -45,7 +45,7 @@ class FeedRepositoryImpl extends FeedRepository { request.cursor, request.size, request.keyword, - request.sort, + request.sort.name, ); return Result.success(response.toEntity()); } on Exception catch (e) { diff --git a/lib/domain/repository/post_repository.dart b/lib/domain/repository/post_repository.dart index f19f8c66..16b5a15a 100644 --- a/lib/domain/repository/post_repository.dart +++ b/lib/domain/repository/post_repository.dart @@ -14,7 +14,12 @@ abstract class PostRepository { Future> updatePost(String id, CreatePostRequest request); - Future> searchPosts(int page, int size, String keyword, SearchType searchBy); + Future> searchPosts( + int page, + int size, + String keyword, + SearchType searchBy, + ); Future> getPostDetail(String id); diff --git a/lib/domain/usecase/post/search_posts_usecase.dart b/lib/domain/usecase/post/search_posts_usecase.dart index d55b0bfc..f5fe4f7d 100644 --- a/lib/domain/usecase/post/search_posts_usecase.dart +++ b/lib/domain/usecase/post/search_posts_usecase.dart @@ -13,7 +13,12 @@ class SearchPostsUseCase extends UseCase> @override Future> execute(SearchPostsRequestParam request) async { - return await _postRepository.searchPosts(request.page, request.size, request.keyword, request.searchBy); + return await _postRepository.searchPosts( + request.page, + request.size, + request.keyword, + request.searchBy, + ); } } @@ -23,5 +28,10 @@ class SearchPostsRequestParam { final String keyword; final SearchType searchBy; - SearchPostsRequestParam({required this.page, required this.size, required this.keyword, required this.searchBy}); + SearchPostsRequestParam({ + required this.page, + required this.size, + required this.keyword, + required this.searchBy, + }); } diff --git a/lib/presentation/board/view/board_list_view.dart b/lib/presentation/board/view/board_list_view.dart deleted file mode 100644 index 0db82fd8..00000000 --- a/lib/presentation/board/view/board_list_view.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:gds/gds.dart'; -import 'package:grimity/app/enum/post_type.enum.dart'; -import 'package:grimity/domain/entity/post.dart'; -import 'package:grimity/presentation/board/provider/board_notice_data_provider.dart'; -import 'package:grimity/presentation/board/provider/board_post_data_provider.dart'; -import 'package:grimity/presentation/board/provider/board_search_query_provider.dart'; -import 'package:grimity/presentation/common/widget/system/board/grimity_post_feed.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -class BoardListView extends ConsumerWidget { - const BoardListView({ - super.key, - required this.posts, - required this.totalCount, - required this.type, - required this.scrollController, - }); - - final List posts; - final int totalCount; - final PostType type; - final ScrollController scrollController; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final notifier = ref.read(boardPostDataProvider(type).notifier); - final isSearching = ref.watch(searchQueryProvider).keyword.trim().length >= 2; - final noticePosts = ref.watch(boardNoticeDataProvider).value; - final pageCount = (totalCount / notifier.size).ceil(); - final currentPageIndex = - notifier.currentPage <= 1 - ? 0 - : notifier.currentPage > pageCount - ? pageCount - 1 - : notifier.currentPage - 1; - - return ListView( - padding: EdgeInsets.zero, - children: [ - // 1페이지에서만 공지 표시 - if (!isSearching && notifier.currentPage == 1 && noticePosts != null && noticePosts.isNotEmpty) - GrimityPostFeed( - posts: noticePosts, - showPostType: isSearching || type == PostType.all ? true : false, - cardHorizontalPadding: 16, - ), - - GrimityPostFeed( - posts: posts, - showPostType: isSearching || type == PostType.all ? true : false, - isBookMark: true, - cardHorizontalPadding: 16, - ), - - if (pageCount > 0) - Container( - padding: EdgeInsets.only( - top: context.isMobile ? GdsSpacing.spacing20 : GdsSpacing.spacing24, - bottom: GdsSpacing.spacing40, - ), - alignment: Alignment.center, - child: GdsNavigation( - index: currentPageIndex, - maxCount: context.isMobile ? 5 : 10, - pageCount: pageCount, - onPageChanged: (page) { - if (scrollController.hasClients) { - scrollController.animateTo(0, duration: Duration(milliseconds: 250), curve: Curves.easeOut); - } - - notifier.goToPage(page + 1); - }, - ), - ), - ], - ); - } -} diff --git a/lib/presentation/board/view/board_view.dart b/lib/presentation/board/view/board_view.dart index 3321a5e2..6f212cb8 100644 --- a/lib/presentation/board/view/board_view.dart +++ b/lib/presentation/board/view/board_view.dart @@ -4,13 +4,15 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gds/gds.dart'; import 'package:grimity/app/enum/post_type.enum.dart'; import 'package:grimity/domain/entity/post.dart'; +import 'package:grimity/presentation/board/provider/board_notice_data_provider.dart'; import 'package:grimity/presentation/board/provider/board_post_data_provider.dart'; -import 'package:grimity/presentation/board/view/board_list_view.dart'; +import 'package:grimity/presentation/board/provider/board_search_query_provider.dart'; import 'package:grimity/presentation/board/widget/board_search_header.dart'; import 'package:grimity/presentation/board/widget/board_tab_header.dart'; import 'package:grimity/presentation/board/widget/board_title_header.dart'; import 'package:grimity/presentation/common/widget/grimity_refresh_indicator.dart'; import 'package:grimity/presentation/common/widget/grimity_state_view.dart'; +import 'package:grimity/presentation/common/widget/system/board/grimity_post_view.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -103,7 +105,6 @@ class BoardView extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final scrollController = useScrollController(); final type = useState(tabList.first); return AppBarConnection( @@ -111,6 +112,9 @@ class BoardView extends HookConsumerWidget { child: Builder( builder: (context) { final postAsync = ref.watch(boardPostDataProvider(type.value)); + final postNotifier = ref.read(boardPostDataProvider(type.value).notifier); + final isSearching = ref.watch(searchQueryProvider).keyword.trim().length >= 2; + final noticePosts = ref.watch(boardNoticeDataProvider).value ?? []; return postAsync.when( data: (posts) { @@ -118,21 +122,29 @@ class BoardView extends HookConsumerWidget { onRefresh: () async { await Future.wait([ref.refresh(boardPostDataProvider(type.value).future)]); }, - child: BoardListView( + child: GrimityPostView( posts: posts.posts, + noticePosts: !isSearching && postNotifier.currentPage == 1 ? noticePosts : [], totalCount: posts.totalCount ?? 0, - type: type.value, - scrollController: scrollController, + currentPage: postNotifier.currentPage, + size: postNotifier.size, + showPostType: isSearching || type.value == PostType.all, + isBookMark: true, + onPageChanged: postNotifier.goToPage, ), ); }, loading: () { return Skeletonizer( - child: BoardListView( + child: GrimityPostView( posts: Post.emptyList, + noticePosts: !isSearching && postNotifier.currentPage == 1 ? noticePosts : [], totalCount: 0, - type: type.value, - scrollController: scrollController, + currentPage: postNotifier.currentPage, + size: postNotifier.size, + showPostType: isSearching || type.value == PostType.all, + isBookMark: true, + onPageChanged: postNotifier.goToPage, ), ); }, diff --git a/lib/presentation/common/widget/popup/grimity_menu_popup.dart b/lib/presentation/common/widget/popup/grimity_menu_popup.dart index 80388272..3cbc0ed6 100644 --- a/lib/presentation/common/widget/popup/grimity_menu_popup.dart +++ b/lib/presentation/common/widget/popup/grimity_menu_popup.dart @@ -7,21 +7,26 @@ class GrimityMenuPopup { const GrimityMenuPopup({ required this.layerLink, required this.items, + this.title, + this.isOption = false, }); final LayerLink layerLink; final List items; + final String? title; + final bool isOption; Future show(BuildContext context, GdsMenuPosition position) { if (context.isMobile) { final child = Column( mainAxisSize: MainAxisSize.min, + spacing: isOption ? GdsSpacing.spacing8 : 0, children: items.map(_buildBottomSheetItem).toList(), ); final bottomSheet = GdsBottomSheet( - title: '', onClose: context.pop, + title: title ?? '', child: child, ); @@ -35,10 +40,18 @@ class GrimityMenuPopup { } } - static Widget _buildBottomSheetItem(GdsMenuItem item) { + Widget _buildBottomSheetItem(GdsMenuItem item) { + if (isOption) { + return GdsListItem.optionCard( + text: item.label, + state: item.state, + onTap: item.onTap, + ); + } + return GdsListItem.textLarge( text: item.label, - state: GdsListItemState.enabled, + state: item.state, isNegative: false, onTap: item.onTap, ); diff --git a/lib/presentation/common/widget/system/board/grimity_post_card.dart b/lib/presentation/common/widget/system/board/grimity_post_card.dart index ad0d8be3..d28957af 100644 --- a/lib/presentation/common/widget/system/board/grimity_post_card.dart +++ b/lib/presentation/common/widget/system/board/grimity_post_card.dart @@ -5,6 +5,7 @@ import 'package:grimity/app/enum/post_type.enum.dart'; import 'package:grimity/app/extension/date_time_extension.dart'; import 'package:grimity/app/util/sync_util.dart'; import 'package:grimity/domain/entity/post.dart'; +import 'package:grimity/domain/usecase/post_usecases.dart'; /// 게시글 위젯 class GrimityPostCard extends StatefulWidget { @@ -12,12 +13,14 @@ class GrimityPostCard extends StatefulWidget { Key? key, required this.post, this.showPostType = false, + this.showBookMark = false, this.isBookMark = false, this.keyword, }) : super(key: key ?? ValueKey(post.id)); final Post post; final bool showPostType; + final bool showBookMark; final bool isBookMark; final String? keyword; @@ -27,6 +30,7 @@ class GrimityPostCard extends StatefulWidget { class _GrimityPostCardState extends State { late Post post = widget.post; + bool _isBookmarkPending = false; void onPostUpdate(Post newPost) { if (mounted) { @@ -58,6 +62,37 @@ class _GrimityPostCardState extends State { super.dispose(); } + Future onBookmarkTap() async { + if (_isBookmarkPending || post.id.isEmpty) return; + + final postId = post.id; + final prevPost = post; + final nextIsSave = !(post.isSave ?? false); + final nextPost = post.copyWith(isSave: nextIsSave); + + setState(() { + _isBookmarkPending = true; + post = nextPost; + }); + SyncUtil.post.notify(nextPost); + + final result = nextIsSave ? await savePostUseCase.execute(postId) : await removeSavedPostUseCase.execute(postId); + + result.fold( + onSuccess: (_) {}, + onFailure: (_) { + if (mounted) { + setState(() => post = prevPost); + } + SyncUtil.post.notify(prevPost); + }, + ); + + if (mounted) { + setState(() => _isBookmarkPending = false); + } + } + @override Widget build(BuildContext context) { final userInfo = GdsUserInfo.community( @@ -85,7 +120,11 @@ class _GrimityPostCardState extends State { contentText: post.content, userInfo: userInfo, chip: chip, - showBookmark: false, + showBookmark: widget.showBookMark, + bookmark: GdsBookmark( + isBookmarked: post.isSave ?? false, + onTap: onBookmarkTap, + ), ); } diff --git a/lib/presentation/common/widget/system/board/grimity_post_feed.dart b/lib/presentation/common/widget/system/board/grimity_post_feed.dart index 599feaf3..37d7befd 100644 --- a/lib/presentation/common/widget/system/board/grimity_post_feed.dart +++ b/lib/presentation/common/widget/system/board/grimity_post_feed.dart @@ -8,6 +8,7 @@ class GrimityPostFeed extends StatelessWidget { required this.posts, this.cardHorizontalPadding = 0, this.showPostType = false, + this.showBookMark = false, this.isBookMark = false, this.keyword, }); @@ -15,6 +16,7 @@ class GrimityPostFeed extends StatelessWidget { final List posts; final double cardHorizontalPadding; final bool showPostType; + final bool showBookMark; final bool isBookMark; final String? keyword; @@ -29,6 +31,7 @@ class GrimityPostFeed extends StatelessWidget { child: GrimityPostCard( post: post, showPostType: showPostType, + showBookMark: showBookMark, isBookMark: isBookMark, keyword: keyword, ), diff --git a/lib/presentation/common/widget/system/board/grimity_post_view.dart b/lib/presentation/common/widget/system/board/grimity_post_view.dart new file mode 100644 index 00000000..9f6d4fe8 --- /dev/null +++ b/lib/presentation/common/widget/system/board/grimity_post_view.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:gds/gds.dart'; +import 'package:grimity/domain/entity/post.dart'; +import 'package:grimity/presentation/common/widget/system/board/grimity_post_feed.dart'; + +class GrimityPostView extends StatelessWidget { + const GrimityPostView({ + super.key, + required this.posts, + required this.totalCount, + required this.currentPage, + required this.size, + required this.onPageChanged, + this.noticePosts = const [], + this.scrollController, + this.showPostType = false, + this.showBookMark = false, + this.showNoticePostType, + this.isBookMark = false, + this.keyword, + this.cardHorizontalPadding = 16, + }); + + final List posts; + final List noticePosts; + final int totalCount; + final int currentPage; + final int size; + final ValueChanged onPageChanged; + final ScrollController? scrollController; + final bool showPostType; + final bool showBookMark; + final bool? showNoticePostType; + final bool isBookMark; + final String? keyword; + final double cardHorizontalPadding; + + @override + Widget build(BuildContext context) { + final pageCount = (totalCount / size).ceil(); + final currentPageIndex = + currentPage <= 1 + ? 0 + : currentPage > pageCount + ? pageCount - 1 + : currentPage - 1; + + return ListView( + controller: scrollController, + padding: EdgeInsets.zero, + children: [ + if (noticePosts.isNotEmpty) + GrimityPostFeed( + posts: noticePosts, + showPostType: showNoticePostType ?? showPostType, + showBookMark: showBookMark, + cardHorizontalPadding: cardHorizontalPadding, + ), + GrimityPostFeed( + posts: posts, + showPostType: showPostType, + showBookMark: showBookMark, + isBookMark: isBookMark, + keyword: keyword, + cardHorizontalPadding: cardHorizontalPadding, + ), + if (pageCount > 0) + Container( + padding: EdgeInsets.only( + top: context.isMobile ? GdsSpacing.spacing20 : GdsSpacing.spacing24, + bottom: GdsSpacing.spacing40, + ), + alignment: Alignment.center, + child: GdsNavigation( + index: currentPageIndex, + maxCount: context.isMobile ? 5 : 10, + pageCount: pageCount, + onPageChanged: (page) { + if (scrollController?.hasClients == true) { + scrollController!.animateTo( + 0, + duration: Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + } + + onPageChanged(page + 1); + }, + ), + ), + ], + ); + } +} diff --git a/lib/presentation/common/widget/user_card/grimity_user_card.dart b/lib/presentation/common/widget/user_card/grimity_user_card.dart deleted file mode 100644 index 09577e89..00000000 --- a/lib/presentation/common/widget/user_card/grimity_user_card.dart +++ /dev/null @@ -1,85 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:grimity/app/config/app_color.dart'; -import 'package:grimity/app/config/app_typeface.dart'; -import 'package:grimity/domain/entity/user.dart'; -import 'package:grimity/presentation/common/widget/button/grimity_button.dart'; -import 'package:grimity/presentation/common/widget/grimity_gesture.dart'; -import 'package:grimity/presentation/common/widget/grimity_gray_circle.dart'; -import 'package:grimity/presentation/common/widget/grimity_reaction.dart'; -import 'package:grimity/presentation/common/widget/system/profile/grimity_profile_background_image.dart'; -import 'package:grimity/presentation/common/widget/system/profile/grimity_user_image.dart'; - -class GrimityUserCard extends StatelessWidget { - const GrimityUserCard({super.key, required this.user, required this.onTap, required this.onFollowTap}); - - final User user; - final VoidCallback onTap; - final VoidCallback onFollowTap; - - final double _coverHeight = 110; - final double _avatarSize = 40; - final double _overlap = 36; - - @override - Widget build(BuildContext context) { - return GrimityGesture( - onTap: onTap, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadiusGeometry.circular(12), - border: Border.all(color: AppColor.gray300, width: 1), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - ClipRRect( - borderRadius: BorderRadiusGeometry.vertical(top: Radius.circular(12)), - child: GrimityProfileBackgroundImage(url: user.backgroundImage, height: _coverHeight), - ), - Padding( - padding: EdgeInsets.only(left: 16, right: 16, top: 16 + (_coverHeight - _overlap), bottom: 20), - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - GrimityUserImage(imageUrl: user.image, size: _avatarSize), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 6, - children: [ - Row( - children: [ - Text(user.name, style: AppTypeface.label2.copyWith(color: AppColor.gray700)), - GrimityGrayCircle(), - GrimityReaction.follower(followerCount: user.followerCount), - ], - ), - if (user.description?.isNotEmpty ?? false) - Text( - user.description!, - style: AppTypeface.caption2.copyWith(color: AppColor.gray600), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ], - ), - - if (user.isBlocking == false && user.isBlocked == false) - Positioned( - top: 26, - right: 0, - child: GrimityButton.follow(isFollowing: user.isFollowing ?? false, onTap: onFollowTap), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/presentation/profile/view/profile_feed_tab_view.dart b/lib/presentation/profile/view/profile_feed_tab_view.dart index 58f16443..b5a05cf3 100644 --- a/lib/presentation/profile/view/profile_feed_tab_view.dart +++ b/lib/presentation/profile/view/profile_feed_tab_view.dart @@ -1,4 +1,4 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:grimity/app/config/app_color.dart'; @@ -125,7 +125,7 @@ class ProfileFeedTabView extends HookConsumerWidget { .map( (e) => DropdownMenuItem( value: e, - child: Text(e.typeName, style: AppTypeface.caption2.copyWith(color: AppColor.gray700)), + child: Text(e.displayName, style: AppTypeface.caption2.copyWith(color: AppColor.gray700)), ), ) .toList(), diff --git a/lib/presentation/search/search_page.dart b/lib/presentation/search/search_page.dart index 4ddf921d..becbd1dd 100644 --- a/lib/presentation/search/search_page.dart +++ b/lib/presentation/search/search_page.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:grimity/presentation/search/search_view.dart'; import 'package:grimity/presentation/search/view/search_feed_tab_view.dart'; import 'package:grimity/presentation/search/view/search_post_tab_view.dart'; -import 'package:grimity/presentation/search/view/search_recommend_tag_view.dart'; import 'package:grimity/presentation/search/view/search_user_tab_view.dart'; class SearchPage extends StatelessWidget { @@ -14,7 +13,6 @@ class SearchPage extends StatelessWidget { Widget build(BuildContext context) { return SearchView( initialKeyword: keyword, - recommendTagView: SearchRecommendTagView(), searchFeedTabView: SearchFeedTabView(), searchUserTabView: SearchUserTabView(), searchPostTabView: SearchPostTabView(), diff --git a/lib/presentation/search/search_view.dart b/lib/presentation/search/search_view.dart index 5b1b99e5..af53054f 100644 --- a/lib/presentation/search/search_view.dart +++ b/lib/presentation/search/search_view.dart @@ -1,7 +1,12 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart' hide AppBar; +import 'package:flutter_appbar/flutter_appbar.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gds/gds.dart'; +import 'package:grimity/presentation/common/widget/navigation/grimity_drawer.dart'; import 'package:grimity/presentation/search/provider/search_keyword_provider.dart'; +import 'package:grimity/presentation/search/view/search_welcome_state.dart'; import 'package:grimity/presentation/search/widget/search_app_bar.dart'; +import 'package:grimity/presentation/search/widget/search_recommand_tag_bar.dart'; import 'package:grimity/presentation/search/widget/search_tab_bar.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -9,14 +14,12 @@ class SearchView extends HookConsumerWidget { const SearchView({ super.key, this.initialKeyword, - required this.recommendTagView, required this.searchFeedTabView, required this.searchUserTabView, required this.searchPostTabView, }); final String? initialKeyword; - final Widget recommendTagView; final Widget searchFeedTabView; final Widget searchUserTabView; final Widget searchPostTabView; @@ -36,25 +39,68 @@ class SearchView extends HookConsumerWidget { return null; }, [initialKeyword]); - return Scaffold( - body: SafeArea( - child: NestedScrollView( - headerSliverBuilder: (context, innerBoxIsScrolled) { - return [ - SearchAppBar(), - SliverPersistentHeader(pinned: true, delegate: SearchTabBar(tabController: tabController)), - ]; + return GdsScaffold( + drawer: GrimityDrawer(), + appBar: SearchAppBar(), + body: AppBarConnection( + appBars: buildAppBars(context, ref, tabController), + child: Builder( + builder: (context) { + // 검색을 진행하지 않았을때는 별도의 상태 표시 + if (keyword.isEmpty) { + return SearchWelcomeState(); + } + + return TabBarView( + controller: tabController, + children: [ + searchFeedTabView, + searchUserTabView, + searchPostTabView, + ], + ); }, - body: TabBarView( - controller: tabController, - children: - // 검색을 진행하지 않았을때는 각 탭에 추천 태그를 표시. - keyword.isEmpty - ? [recommendTagView, recommendTagView, recommendTagView] - : [searchFeedTabView, searchUserTabView, searchPostTabView], - ), ), ), ); } + + List buildAppBars( + BuildContext context, + WidgetRef ref, + TabController controller, + ) { + final keyword = ref.watch(searchKeywordProvider); + final welcome = keyword.isEmpty; + + if (context.isMobile) { + return [ + AppBar( + behavior: AbsoluteAppBarBehavior(), + body: Padding( + padding: EdgeInsets.only(top: GdsSpacing.spacing16), + child: welcome ? SearchRecommendTagBar() : SearchTabBar(controller: controller), + ), + ), + ]; + } + + // Tablet + return [ + AppBar( + behavior: MaterialAppBarBehavior(floating: true), + body: Padding( + padding: EdgeInsets.symmetric(vertical: GdsSpacing.spacing16), + child: SearchRecommendTagBar(), + ), + ), + AppBar( + behavior: AbsoluteAppBarBehavior(), + body: Padding( + padding: EdgeInsets.only(top: GdsSpacing.spacing16), + child: SearchRecommendTagBar(), + ), + ), + ]; + } } diff --git a/lib/presentation/search/view/search_empty_state.dart b/lib/presentation/search/view/search_empty_state.dart new file mode 100644 index 00000000..91930887 --- /dev/null +++ b/lib/presentation/search/view/search_empty_state.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:gds/gds.dart'; + +class SearchEmptyState extends StatelessWidget { + const SearchEmptyState({super.key}); + + @override + Widget build(BuildContext context) { + final isMobile = context.isMobile; + + return GdsEmptyState( + size: isMobile ? GdsEmptyStateSize.md : GdsEmptyStateSize.xl, + icon: GdsIcon.resultNull, + title: '검색한 결과를 찾을 수 없어요', + description: '검색어의 단어 수를 줄이거나${isMobile ? '\n' : ''}다른 검색어로 검색해보세요', + ); + } +} diff --git a/lib/presentation/search/view/search_feed_tab_view.dart b/lib/presentation/search/view/search_feed_tab_view.dart index b96efdd9..40637fcb 100644 --- a/lib/presentation/search/view/search_feed_tab_view.dart +++ b/lib/presentation/search/view/search_feed_tab_view.dart @@ -1,17 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:grimity/app/config/app_color.dart'; -import 'package:grimity/app/config/app_typeface.dart'; -import 'package:grimity/app/enum/sort_type.enum.dart'; +import 'package:gds/gds.dart'; import 'package:grimity/domain/entity/feed.dart'; -import 'package:grimity/domain/entity/feeds.dart'; import 'package:grimity/presentation/common/widget/grimity_feed_grid.dart'; import 'package:grimity/presentation/common/widget/grimity_infinite_scroll_pagination.dart'; import 'package:grimity/presentation/common/widget/grimity_state_view.dart'; -import 'package:grimity/presentation/common/widget/system/sort/grimity_search_sort_header.dart'; import 'package:grimity/presentation/search/provider/search_feed_data_provider.dart'; -import 'package:grimity/presentation/search/provider/search_feed_sort_type_provider.dart'; import 'package:grimity/presentation/search/provider/search_keyword_provider.dart'; +import 'package:grimity/presentation/search/view/search_empty_state.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -28,69 +24,21 @@ class SearchFeedTabView extends HookConsumerWidget with SearchFeedMixin { final feeds = data.feeds; if (feeds.isEmpty) { - return GrimityStateView.resultNull(title: '검색 결과가 없어요', subTitle: '다른 검색어를 입력해보세요'); + return SearchEmptyState(); } return GrimityInfiniteScrollPagination( isEnabled: data.nextCursor != null, onLoadMore: searchFeedNotifier(ref).loadMore, - child: _SearchResultFeedView(feeds: data), + child: _SearchFeedListView(feeds: data.feeds), ); }, - loading: - () => Skeletonizer( - child: _SearchResultFeedView(feeds: Feeds(feeds: Feed.createEmptyList(context))), - ), - error: (e, s) => GrimityStateView.error(onTap: () => invalidateSearchFeed(ref)), - ); - } -} - -class _SearchResultFeedView extends StatelessWidget { - const _SearchResultFeedView({required this.feeds}); - - final Feeds feeds; - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: CustomScrollView( - slivers: [ - SliverPadding( - padding: EdgeInsets.symmetric(vertical: 8), - sliver: SliverToBoxAdapter(child: _SearchFeedSortHeader()), - ), - _SearchFeedListView(feeds: feeds.feeds), - ], - ), - ); - } -} - -class _SearchFeedSortHeader extends ConsumerWidget { - const _SearchFeedSortHeader(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final sortType = ref.watch(searchFeedSortTypeProvider); - - return GrimitySearchSortHeader( - sortValue: sortType, - sortItems: - SortType.searchFeedSortValues - .map( - (e) => DropdownMenuItem( - value: e, - child: Text(e.typeName, style: AppTypeface.caption2.copyWith(color: AppColor.gray700)), - ), - ) - .toList(), - onSortChanged: (value) { - if (value == null) return; - ref.read(searchFeedSortTypeProvider.notifier).update(value); + loading: () { + return Skeletonizer( + child: _SearchFeedListView(feeds: Feed.createEmptyList(context)), + ); }, - padding: EdgeInsets.zero, + error: (e, s) => GrimityStateView.error(onTap: () => invalidateSearchFeed(ref)), ); } } @@ -104,9 +52,16 @@ class _SearchFeedListView extends ConsumerWidget with SearchFeedMixin { Widget build(BuildContext context, WidgetRef ref) { final searchKeyword = ref.watch(searchKeywordProvider); - return GrimityFeedGrid.sliver( - feeds: feeds, - keyword: searchKeyword, + return CustomScrollView( + slivers: [ + GrimityFeedGrid.sliver( + feeds: feeds, + keyword: searchKeyword, + padding: EdgeInsets.all( + context.isMobile ? GdsSpacing.spacing16 : GdsSpacing.spacing20, + ), + ), + ], ); } } diff --git a/lib/presentation/search/view/search_post_tab_view.dart b/lib/presentation/search/view/search_post_tab_view.dart index d1b1897e..ca67d421 100644 --- a/lib/presentation/search/view/search_post_tab_view.dart +++ b/lib/presentation/search/view/search_post_tab_view.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gds/gds.dart'; import 'package:grimity/domain/entity/post.dart'; import 'package:grimity/domain/entity/posts.dart'; -import 'package:grimity/presentation/common/widget/system/pagination/grimity_pagination_widget.dart'; -import 'package:grimity/presentation/common/widget/system/board/grimity_post_feed.dart'; import 'package:grimity/presentation/common/widget/grimity_state_view.dart'; -import 'package:grimity/presentation/common/widget/system/sort/grimity_search_sort_header.dart'; +import 'package:grimity/presentation/common/widget/system/board/grimity_post_view.dart'; import 'package:grimity/presentation/search/provider/search_post_data_provider.dart'; +import 'package:grimity/presentation/search/view/search_empty_state.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -18,12 +18,17 @@ class SearchPostTabView extends HookConsumerWidget with SearchPostMixin { useAutomaticKeepAlive(); return searchPostState(ref).when( - data: - (posts) => - posts.totalCount == 0 - ? GrimityStateView.resultNull(title: '검색 결과가 없어요', subTitle: '다른 검색어를 입력해보세요') - : _SearchResultPostView(posts: posts), - loading: () => Skeletonizer(child: _SearchResultPostView(posts: Posts(posts: Post.emptyList, totalCount: 0))), + data: (posts) { + if (posts.totalCount == 0) { + return SearchEmptyState(); + } + + return _SearchResultPostView(posts: posts); + }, + loading: + () => Skeletonizer( + child: _SearchResultPostView(posts: Posts(posts: Post.emptyList, totalCount: 0)), + ), error: (e, s) => GrimityStateView.error(onTap: () => invalidateSearchPost(ref)), ); } @@ -36,49 +41,19 @@ class _SearchResultPostView extends HookConsumerWidget with SearchPostMixin { @override Widget build(BuildContext context, WidgetRef ref) { - final scrollController = useScrollController(); final searchNotifier = searchPostNotifier(ref); - return CustomScrollView( - controller: scrollController, - slivers: [ - SliverPadding( - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), - sliver: SliverToBoxAdapter(child: _SearchPostSortHeader(resultCount: posts.totalCount ?? 0)), - ), - SliverToBoxAdapter(child: _SearchPostList(posts: posts.posts, keyword: searchNotifier.keyword)), - SliverToBoxAdapter( - child: GrimityPaginationWidget( - currentPage: searchNotifier.currentPage, - size: searchNotifier.size, - totalCount: posts.totalCount ?? 0, - onPageSelected: (page) => searchPostNotifier(ref).goToPage(page), - ), - ), - ], + return GrimityPostView( + posts: posts.posts, + totalCount: posts.totalCount ?? 0, + currentPage: searchNotifier.currentPage, + size: searchNotifier.size, + onPageChanged: searchNotifier.goToPage, + cardHorizontalPadding: context.isMobile ? GdsSpacing.spacing16 : GdsSpacing.spacing20, + keyword: searchNotifier.keyword, + isBookMark: true, + showPostType: true, + showBookMark: true, ); } } - -class _SearchPostSortHeader extends StatelessWidget { - const _SearchPostSortHeader({required this.resultCount}); - - final int resultCount; - - @override - Widget build(BuildContext context) { - return GrimitySearchSortHeader(resultCount: resultCount, padding: EdgeInsets.zero); - } -} - -class _SearchPostList extends StatelessWidget { - const _SearchPostList({required this.posts, required this.keyword}); - - final List posts; - final String keyword; - - @override - Widget build(BuildContext context) { - return GrimityPostFeed(posts: posts, cardHorizontalPadding: 16, keyword: keyword); - } -} diff --git a/lib/presentation/search/view/search_recommend_tag_view.dart b/lib/presentation/search/view/search_recommend_tag_view.dart index 3536364c..bee966cb 100644 --- a/lib/presentation/search/view/search_recommend_tag_view.dart +++ b/lib/presentation/search/view/search_recommend_tag_view.dart @@ -1,9 +1,8 @@ +import 'package:dynamic_height_list_view/dynamic_height_view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:grimity/app/config/app_color.dart'; -import 'package:grimity/app/config/app_typeface.dart'; +import 'package:gds/gds.dart'; import 'package:grimity/domain/entity/tag.dart'; -import 'package:grimity/presentation/common/widget/button/grimity_button.dart'; import 'package:grimity/presentation/common/widget/grimity_state_view.dart'; import 'package:grimity/presentation/search/provider/recommend_tag_data_provider.dart'; import 'package:grimity/presentation/search/provider/search_keyword_provider.dart'; @@ -15,45 +14,78 @@ class SearchRecommendTagView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final recommendTags = ref.watch(recommendTagDataProvider); + final colors = context.gdsColors; - return Padding( - padding: EdgeInsets.all(16), - child: Column( - spacing: 16, + final Widget child = recommendTags.when( + data: (tags) => _SearchTagListView(tags: tags), + loading: () => Skeletonizer(child: _SearchTagListView(tags: Tag.emptyList)), + error: (e, s) => GrimityStateView.error(onTap: () => ref.invalidate(recommendTagDataProvider)), + ); + + if (context.isMobile) { + return Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: GdsSpacing.spacing6, children: [ - Text('추천 태그', style: AppTypeface.subTitle4.copyWith(color: AppColor.gray700)), - recommendTags.when( - data: (tags) => _SearchTagWrap(tags: tags), - loading: () => Skeletonizer(child: _SearchTagWrap(tags: Tag.emptyList)), - error: (e, s) => GrimityStateView.error(onTap: () => ref.invalidate(recommendTagDataProvider)), + Padding( + padding: EdgeInsets.symmetric(horizontal: GdsSpacing.spacing16), + child: Text( + '추천 태그', + style: GdsTypography.label5.copyWith(color: colors.text.grayBold), + ), ), + child, ], + ); + } + + // Tablet + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: EdgeInsets.only(left: GdsSpacing.spacing20), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '추천 태그', + style: GdsTypography.subtitle2.copyWith(color: colors.text.grayBold), + ), + child, + ], + ), ), ); } } -class _SearchTagWrap extends ConsumerWidget { - const _SearchTagWrap({required this.tags}); +class _SearchTagListView extends ConsumerWidget { + const _SearchTagListView({required this.tags}); final List tags; @override Widget build(BuildContext context, WidgetRef ref) { - return Wrap( - spacing: 6, - runSpacing: 8, - children: - tags - .map( - (tag) => GrimityButton.round( - text: tag.tagName, - style: ButtonStyleType.line, - onTap: () => ref.read(searchKeywordProvider.notifier).setKeyword(tag.tagName), - ), - ) - .toList(), + return DynamicHeightListView( + scrollDirection: ScrollDirection.horizontal, + items: tags, + padding: EdgeInsets.symmetric( + horizontal: context.isMobile ? GdsSpacing.spacing16 : GdsSpacing.spacing20, + ), + itemPadding: EdgeInsets.zero, + itemBuilder: (context, tag) { + final isLast = tag == tags.last; + + return Padding( + padding: isLast ? EdgeInsets.zero : EdgeInsets.only(right: GdsSpacing.spacing8), + child: GdsTag( + text: tag.tagName, + size: context.isMobile ? GdsTagSize.small : GdsTagSize.medium, + onTap: () => ref.read(searchKeywordProvider.notifier).setKeyword(tag.tagName), + ), + ); + }, ); } } diff --git a/lib/presentation/search/view/search_user_tab_view.dart b/lib/presentation/search/view/search_user_tab_view.dart index 8a25e16d..f0dbab24 100644 --- a/lib/presentation/search/view/search_user_tab_view.dart +++ b/lib/presentation/search/view/search_user_tab_view.dart @@ -1,13 +1,15 @@ +import 'package:dynamic_height_list_view/dynamic_height_view.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; +import 'package:gds/gds.dart'; import 'package:grimity/app/config/app_router.dart'; +import 'package:grimity/app/extension/build_context_extension.dart'; import 'package:grimity/domain/entity/user.dart'; import 'package:grimity/domain/entity/users.dart'; import 'package:grimity/presentation/common/widget/grimity_infinite_scroll_pagination.dart'; import 'package:grimity/presentation/common/widget/grimity_state_view.dart'; -import 'package:grimity/presentation/common/widget/user_card/grimity_user_card.dart'; import 'package:grimity/presentation/search/provider/search_user_data_provider.dart'; +import 'package:grimity/presentation/search/view/search_empty_state.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; @@ -24,7 +26,7 @@ class SearchUserTabView extends HookConsumerWidget with SearchUserMixin { final users = data.users; if (users.isEmpty) { - return GrimityStateView.resultNull(title: '검색 결과가 없어요', subTitle: '다른 검색어를 입력해보세요'); + return SearchEmptyState(); } return GrimityInfiniteScrollPagination( @@ -46,14 +48,15 @@ class _SearchResultUserView extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: CustomScrollView( - slivers: [ - SliverGap(32), - _SearchUserSliverListView(users: users.users), - ], - ), + return CustomScrollView( + slivers: [ + SliverPadding( + padding: EdgeInsets.all( + context.isMobile ? GdsSpacing.spacing16 : GdsSpacing.spacing20, + ), + sliver: _SearchUserSliverListView(users: users.users), + ), + ], ); } } @@ -65,18 +68,31 @@ class _SearchUserSliverListView extends ConsumerWidget with SearchUserMixin { @override Widget build(BuildContext context, WidgetRef ref) { - return SliverList.separated( - itemBuilder: (context, index) { + return SliverDynamicHeightGridView( + crossAxisCount: context.userRowCount, + mainAxisSpacing: GdsSpacing.spacing24, + crossAxisSpacing: GdsSpacing.spacing16, + itemCount: users.length, + builder: (context, index) { final user = users[index]; - return GrimityUserCard( - user: user, + + return GdsUserCard( + type: GdsUserCardType.search, + nickname: user.name, + description: user.description ?? '', + coverImageUrl: user.backgroundImage, + profileImageUrl: user.image, + followerCount: user.followerCount ?? 0, + followingCount: user.followingCount ?? 0, + actionLabel: (user.isFollowing ?? false) ? '팔로우 중' : '팔로잉', + isActionSoild: !(user.isFollowing ?? false), onTap: () => ProfileRoute(url: user.url).push(context), - onFollowTap: - () => searchUserNotifier(ref).toggleFollow(id: user.id, follow: user.isFollowing == false ? true : false), + onActionPressed: () { + final newStatus = user.isFollowing == false ? true : false; + searchUserNotifier(ref).toggleFollow(id: user.id, follow: newStatus); + }, ); }, - separatorBuilder: (context, index) => Gap(16), - itemCount: users.length, ); } } diff --git a/lib/presentation/search/view/search_welcome_state.dart b/lib/presentation/search/view/search_welcome_state.dart new file mode 100644 index 00000000..58cb03f4 --- /dev/null +++ b/lib/presentation/search/view/search_welcome_state.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; +import 'package:gds/gds.dart'; + +class SearchWelcomeState extends StatelessWidget { + const SearchWelcomeState({super.key}); + + @override + Widget build(BuildContext context) { + return GdsEmptyState( + size: context.isMobile ? GdsEmptyStateSize.md : GdsEmptyStateSize.xl, + icon: GdsIcon.illust, + title: '그리미티에서 찾아보세요!', + ); + } +} diff --git a/lib/presentation/search/widget/search_app_bar.dart b/lib/presentation/search/widget/search_app_bar.dart index ffdc030e..5f67aaf8 100644 --- a/lib/presentation/search/widget/search_app_bar.dart +++ b/lib/presentation/search/widget/search_app_bar.dart @@ -1,57 +1,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:gds/gds.dart'; +import 'package:go_router/go_router.dart'; import 'package:grimity/app/service/toast_service.dart'; -import 'package:grimity/presentation/common/widget/grimity_gesture.dart'; -import 'package:grimity/presentation/common/widget/text_field/grimity_text_field.dart'; -import 'package:gap/gap.dart'; -import 'package:grimity/app/config/app_theme.dart'; import 'package:grimity/presentation/search/provider/search_keyword_provider.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -class SearchAppBar extends StatelessWidget { +class SearchAppBar extends HookConsumerWidget { const SearchAppBar({super.key}); - @override - Widget build(BuildContext context) { - return SliverPersistentHeader(pinned: true, floating: false, delegate: _SearchAppBarDelegate()); - } -} - -class _SearchAppBarDelegate extends SliverPersistentHeaderDelegate { - const _SearchAppBarDelegate(); - - @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 16), - color: Colors.white, - alignment: Alignment.centerLeft, - child: Row( - children: [ - GrimityGesture( - onTap: () => Navigator.of(context).maybePop(), - child: Icon(Icons.arrow_back_ios_new_outlined, size: 24), - ), - Gap(8), - Expanded(child: _SearchTextField()), - ], - ), - ); - } - - @override - double get maxExtent => AppTheme.kToolbarHeight.height; - - @override - double get minExtent => AppTheme.kToolbarHeight.height; - - @override - bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => true; -} - -class _SearchTextField extends HookConsumerWidget { - const _SearchTextField(); - @override Widget build(BuildContext context, WidgetRef ref) { final keyword = ref.watch(searchKeywordProvider); @@ -79,14 +36,15 @@ class _SearchTextField extends HookConsumerWidget { ref.read(searchKeywordProvider.notifier).setKeyword(kw); } - return GrimityTextField.small( - controller: controller, - focusNode: focusNode, - hintText: '검색어를 입력해주세요', - maxLines: 1, - showSearchIcon: true, - onSearch: () => submit(controller.text), - onSubmitted: (keyword) => submit(keyword), + return GdsTopNavigation.search( + onBack: context.pop, + field: GdsTextField.search( + size: GdsTextFieldSize.medium, + placeholder: '그림, 작가, 글을 검색해보세요.', + controller: controller, + focusNode: focusNode, + onEditingComplete: () => submit(controller.text), + ), ); } } diff --git a/lib/presentation/search/widget/search_recommand_tag_bar.dart b/lib/presentation/search/widget/search_recommand_tag_bar.dart new file mode 100644 index 00000000..04454533 --- /dev/null +++ b/lib/presentation/search/widget/search_recommand_tag_bar.dart @@ -0,0 +1,91 @@ +import 'package:dynamic_height_list_view/dynamic_height_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:gds/gds.dart'; +import 'package:grimity/domain/entity/tag.dart'; +import 'package:grimity/presentation/common/widget/grimity_state_view.dart'; +import 'package:grimity/presentation/search/provider/recommend_tag_data_provider.dart'; +import 'package:grimity/presentation/search/provider/search_keyword_provider.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +class SearchRecommendTagBar extends ConsumerWidget { + const SearchRecommendTagBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final recommendTags = ref.watch(recommendTagDataProvider); + final colors = context.gdsColors; + + final Widget child = recommendTags.when( + data: (tags) => _SearchTagListView(tags: tags), + loading: () => Skeletonizer(child: _SearchTagListView(tags: Tag.emptyList)), + error: (e, s) => GrimityStateView.error(onTap: () => ref.invalidate(recommendTagDataProvider)), + ); + + if (context.isMobile) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + spacing: GdsSpacing.spacing6, + children: [ + Padding( + padding: EdgeInsets.symmetric(horizontal: GdsSpacing.spacing16), + child: Text( + '추천 태그', + style: GdsTypography.label5.copyWith(color: colors.text.grayBold), + ), + ), + child, + ], + ); + } + + // Tablet + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Padding( + padding: EdgeInsets.only(left: GdsSpacing.spacing20), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '추천 태그', + style: GdsTypography.subtitle2.copyWith(color: colors.text.grayBold), + ), + child, + ], + ), + ), + ); + } +} + +class _SearchTagListView extends ConsumerWidget { + const _SearchTagListView({required this.tags}); + + final List tags; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return DynamicHeightListView( + scrollDirection: ScrollDirection.horizontal, + items: tags, + padding: EdgeInsets.symmetric( + horizontal: context.isMobile ? GdsSpacing.spacing16 : GdsSpacing.spacing20, + ), + itemPadding: EdgeInsets.zero, + itemBuilder: (context, tag) { + final isLast = tag == tags.last; + + return Padding( + padding: isLast ? EdgeInsets.zero : EdgeInsets.only(right: GdsSpacing.spacing8), + child: GdsTag( + text: tag.tagName, + size: context.isMobile ? GdsTagSize.small : GdsTagSize.medium, + onTap: () => ref.read(searchKeywordProvider.notifier).setKeyword(tag.tagName), + ), + ); + }, + ); + } +} diff --git a/lib/presentation/search/widget/search_tab_bar.dart b/lib/presentation/search/widget/search_tab_bar.dart index 4550fbf4..8d3c5273 100644 --- a/lib/presentation/search/widget/search_tab_bar.dart +++ b/lib/presentation/search/widget/search_tab_bar.dart @@ -1,31 +1,92 @@ import 'package:flutter/material.dart'; -import 'package:grimity/presentation/common/widget/system/tabs/grimity_tab.dart'; -import 'package:grimity/presentation/common/widget/system/tabs/grimity_tab_bar.dart'; +import 'package:gds/gds.dart'; +import 'package:go_router/go_router.dart'; +import 'package:grimity/app/enum/sort_type.enum.dart'; +import 'package:grimity/presentation/common/widget/popup/grimity_menu_popup.dart'; +import 'package:grimity/presentation/search/provider/search_feed_sort_type_provider.dart'; +import 'package:grimity/presentation/search/provider/search_keyword_provider.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -class SearchTabBar extends SliverPersistentHeaderDelegate { - const SearchTabBar({required this.tabController}); +class SearchTabBar extends ConsumerWidget { + const SearchTabBar({ + super.key, + required this.controller, + }); - final TabController tabController; + final TabController controller; @override - Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { - return GrimityTabBar.medium( - tabController: tabController, - buildTabs: - (currentIndex) => [ - GrimityTab.medium(text: '그림', tabStatus: currentIndex == 0 ? GrimityTabStatus.on : GrimityTabStatus.off), - GrimityTab.medium(text: '유저', tabStatus: currentIndex == 1 ? GrimityTabStatus.on : GrimityTabStatus.off), - GrimityTab.medium(text: '자유게시판', tabStatus: currentIndex == 2 ? GrimityTabStatus.on : GrimityTabStatus.off), - ], + Widget build(BuildContext context, WidgetRef ref) { + final sortType = ref.watch(searchFeedSortTypeProvider); + final keyword = ref.watch(searchKeywordProvider); + final colors = context.gdsColors; + + return Container( + margin: EdgeInsets.symmetric( + horizontal: context.isMobile ? GdsSpacing.spacing16 : GdsSpacing.spacing20, + ), + decoration: BoxDecoration( + border: Border(bottom: BorderSide(color: colors.border.graySubtle)), + ), + child: ListenableBuilder( + listenable: controller, + builder: (context, child) { + return Row( + children: [ + Expanded( + child: GdsTab( + size: context.isMobile ? GdsTabSize.sm : GdsTabSize.md, + showBorder: false, + index: controller.index, + items: [ + GdsTabItem(label: '그림', onTap: () => controller.animateTo(0)), + GdsTabItem(label: '유저', onTap: () => controller.animateTo(1)), + GdsTabItem(label: '자유게시판', onTap: () => controller.animateTo(2)), + ], + ), + ), + GdsMenuAnchor( + builder: (link) { + return GdsFilter( + type: GdsFilterType.text, + text: sortType.displayName, + enabled: keyword.isNotEmpty, + onTap: () => _showSortTypeMenu(context, ref, link), + ); + }, + ), + ], + ); + }, + ), ); } - @override - double get maxExtent => GrimityTabBarSize.medium.height; + static Future _showSortTypeMenu( + BuildContext context, + WidgetRef ref, + LayerLink link, + ) { + final sortType = ref.read(searchFeedSortTypeProvider); - @override - double get minExtent => GrimityTabBarSize.medium.height; + final popup = GrimityMenuPopup( + title: '정렬', + layerLink: link, + isOption: true, + items: [ + ...SortType.values.map((type) { + return GdsMenuItem( + label: type.displayName, + state: sortType == type ? GdsListItemState.pressed : GdsListItemState.enabled, + onTap: () { + context.pop(); + ref.read(searchFeedSortTypeProvider.notifier).update(type); + }, + ); + }), + ], + ); - @override - bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => true; + return popup.show(context, GdsMenuPosition.right); + } } diff --git a/pubspec.lock b/pubspec.lock index 2b14ce34..bca460b5 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1146,7 +1146,7 @@ packages: description: path: "." ref: main - resolved-ref: a5f57f5ff134ad0e88e6ca2cbfc97174b4ecd3c1 + resolved-ref: "66475a91e539a6aab70bf46a122e35a79fb71de2" url: "https://github.com/Grimity/gds-flutter" source: git version: "0.0.1" @@ -1154,8 +1154,8 @@ packages: dependency: transitive description: path: "packages/components" - ref: a5f57f5ff134ad0e88e6ca2cbfc97174b4ecd3c1 - resolved-ref: a5f57f5ff134ad0e88e6ca2cbfc97174b4ecd3c1 + ref: "66475a91e539a6aab70bf46a122e35a79fb71de2" + resolved-ref: "66475a91e539a6aab70bf46a122e35a79fb71de2" url: "https://github.com/Grimity/gds-flutter" source: git version: "0.0.1" @@ -1163,8 +1163,8 @@ packages: dependency: transitive description: path: "packages/foundation" - ref: a5f57f5ff134ad0e88e6ca2cbfc97174b4ecd3c1 - resolved-ref: a5f57f5ff134ad0e88e6ca2cbfc97174b4ecd3c1 + ref: "66475a91e539a6aab70bf46a122e35a79fb71de2" + resolved-ref: "66475a91e539a6aab70bf46a122e35a79fb71de2" url: "https://github.com/Grimity/gds-flutter" source: git version: "0.0.1" @@ -1172,8 +1172,8 @@ packages: dependency: transitive description: path: "packages/tokens" - ref: a5f57f5ff134ad0e88e6ca2cbfc97174b4ecd3c1 - resolved-ref: a5f57f5ff134ad0e88e6ca2cbfc97174b4ecd3c1 + ref: "66475a91e539a6aab70bf46a122e35a79fb71de2" + resolved-ref: "66475a91e539a6aab70bf46a122e35a79fb71de2" url: "https://github.com/Grimity/gds-flutter" source: git version: "0.0.1" From cf426f4f8c753364e322e7fd2aa08a55fa42a456 Mon Sep 17 00:00:00 2001 From: Dev Ttangkong Date: Thu, 25 Jun 2026 06:07:52 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EC=A7=81=EB=A0=AC=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=A0=95=EB=A0=AC=20=EC=98=B5=EC=85=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app/enum/post_type.enum.dart | 2 +- lib/presentation/search/widget/search_tab_bar.dart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/app/enum/post_type.enum.dart b/lib/app/enum/post_type.enum.dart index 7eced209..249e3c8a 100644 --- a/lib/app/enum/post_type.enum.dart +++ b/lib/app/enum/post_type.enum.dart @@ -14,7 +14,7 @@ enum PostType { @JsonValue('NOTICE') notice('공지', 'NOTICE'), - @JsonValue('All') + @JsonValue('ALL') all('전체', 'ALL'); final String displayName; diff --git a/lib/presentation/search/widget/search_tab_bar.dart b/lib/presentation/search/widget/search_tab_bar.dart index 8d3c5273..dbfcfcf5 100644 --- a/lib/presentation/search/widget/search_tab_bar.dart +++ b/lib/presentation/search/widget/search_tab_bar.dart @@ -74,7 +74,7 @@ class SearchTabBar extends ConsumerWidget { layerLink: link, isOption: true, items: [ - ...SortType.values.map((type) { + ...SortType.searchFeedSortValues.map((type) { return GdsMenuItem( label: type.displayName, state: sortType == type ? GdsListItemState.pressed : GdsListItemState.enabled,