diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart index 7c0dbdd..50e09d5 100644 --- a/lib/constants/app_constants.dart +++ b/lib/constants/app_constants.dart @@ -27,3 +27,5 @@ List folderColors = [ AppColors.folderColorD7E5FC, AppColors.folderColorECD8F3, ]; + +String uniqueKey = 'unique_key'; diff --git a/lib/providers/content_detail_provider.dart b/lib/providers/content_detail_provider.dart index 81aa526..118cd95 100644 --- a/lib/providers/content_detail_provider.dart +++ b/lib/providers/content_detail_provider.dart @@ -40,4 +40,24 @@ class ContentDetail extends _$ContentDetail { Future build() async { return null; } + + Future editContent({ + required String contentId, + required String contentName, + required String contentMemo, + required String hashTagStringList, + }) async { + state = const AsyncValue.loading(); + + state = await AsyncValue.guard(() async { + // await ContentRepository.instance.editContent( + // contentId: contentId, + // contentName: contentName, + // contentMemo: contentMemo, + // hashTagStringList: hashTagStringList, + // ); + var data = await fetchItem(contentId: contentId); + return data; + }); + } } diff --git a/lib/providers/hashtag_provider.dart b/lib/providers/hashtag_provider.dart index 2aba9d2..89366a7 100644 --- a/lib/providers/hashtag_provider.dart +++ b/lib/providers/hashtag_provider.dart @@ -27,10 +27,24 @@ class Hashtag extends _$Hashtag { state = const AsyncValue.loading(); state = await AsyncValue.guard(() async { - await HashtagRepository.instance.editHashtag( - tagId: tagId, - hashtags: hashtags, - ); + // await HashtagRepository.instance.editHashtag( + // tagId: tagId, + // hashtags: hashtags, + // ); + var data = await fetchItem(); + return data; + }); + } + + Future deleteHashtag({ + required List tagIds, + }) async { + state = const AsyncValue.loading(); + + state = await AsyncValue.guard(() async { + // await HashtagRepository.instance.deleteHashtag( + // tagIds: tagIds, + // ); var data = await fetchItem(); return data; }); diff --git a/lib/providers/hashtag_view_provider.dart b/lib/providers/hashtag_view_provider.dart index d4cc9fd..e1bef0e 100644 --- a/lib/providers/hashtag_view_provider.dart +++ b/lib/providers/hashtag_view_provider.dart @@ -11,7 +11,7 @@ part 'hashtag_view_provider.g.dart'; // @Riverpod(keepAlive: true) @riverpod class HashtagView extends _$HashtagView { - Future<(List, int)> fetchItem() async { + Future<(List, int)> fetchItem({String? hashtag}) async { // get the [KeepAliveLink] var link = ref.keepAlive(); // a timer to be used by the callbacks below @@ -34,7 +34,7 @@ class HashtagView extends _$HashtagView { timer?.cancel(); }); - var data = await HashtagRepository.instance.getHashtagView(); + var data = await HashtagRepository.instance.getHashtagView(tag: hashtag); return data; } diff --git a/lib/repositories/content_repository.dart b/lib/repositories/content_repository.dart index 8022336..5858dcc 100644 --- a/lib/repositories/content_repository.dart +++ b/lib/repositories/content_repository.dart @@ -18,7 +18,8 @@ abstract class IContentRepository { Future editContent({ required String contentId, - required ContentModel content, + required String contentName, + required String contentMemo, required String hashTagStringList, }); @@ -106,7 +107,8 @@ class ContentRepository implements IContentRepository { @override Future editContent({ required String contentId, - required ContentModel content, + required String contentName, + required String contentMemo, required String hashTagStringList, }) async { var token = await TokenRepository.instance.getToken(); @@ -114,7 +116,9 @@ class ContentRepository implements IContentRepository { await dio.post( '/api/v1/content/edit', data: { - //todo 수정데이터 + 'title': contentName, + 'memo': contentMemo, + 'hashTag': hashTagStringList, }, options: Options( headers: { diff --git a/lib/repositories/hashtag_repository.dart b/lib/repositories/hashtag_repository.dart index e7c516a..7912524 100644 --- a/lib/repositories/hashtag_repository.dart +++ b/lib/repositories/hashtag_repository.dart @@ -15,6 +15,9 @@ abstract class IHashtagRepository { required String tagId, required String hashtags, }); + Future deleteHashtag({ + required List tagIds, + }); } class HashtagRepository implements IHashtagRepository { @@ -79,7 +82,7 @@ class HashtagRepository implements IHashtagRepository { '/api/v1/hashtag/edit', data: { 'tagId': tagId, - 'hashtag': hashtags, + 'hashTag': hashtags, }, options: Options( headers: { @@ -88,4 +91,19 @@ class HashtagRepository implements IHashtagRepository { ), ); } + + @override + Future deleteHashtag({required List tagIds}) async { + var token = await TokenRepository.instance.getToken(); + + await dio.post( + '/api/v1/hashtag/delete', + data: {'tagIds': tagIds}, + options: Options( + headers: { + 'Authorization': 'Bearer $token', + }, + ), + ); + } } diff --git a/lib/screens/add_content/add_image_content.dart b/lib/screens/add_content/add_image_content.dart index cfcbfce..e0cb2c2 100644 --- a/lib/screens/add_content/add_image_content.dart +++ b/lib/screens/add_content/add_image_content.dart @@ -97,6 +97,7 @@ class AddImageContent extends HookConsumerWidget { await ContentRepository.instance.addContent( contentType: AddContentType.image, content: ContentModel( + folderName: 'folderName', contentId: folderId, contentName: title.value, contentHashTags: [], diff --git a/lib/screens/add_content/add_link_content.dart b/lib/screens/add_content/add_link_content.dart index 8c412f5..22cb351 100644 --- a/lib/screens/add_content/add_link_content.dart +++ b/lib/screens/add_content/add_link_content.dart @@ -125,6 +125,7 @@ class AddLinkContent extends HookConsumerWidget { await ContentRepository.instance.addContent( contentType: AddContentType.url, content: ContentModel( + folderName: 'folderName', contentId: folderId, contentUrl: link.value, contentName: title.value, diff --git a/lib/screens/file_sharing/user_listing_screen.dart b/lib/screens/file_sharing/user_listing_screen.dart index e4ddf6a..20ba2d2 100644 --- a/lib/screens/file_sharing/user_listing_screen.dart +++ b/lib/screens/file_sharing/user_listing_screen.dart @@ -17,6 +17,7 @@ class UserListingScreen extends HookWidget { @override Widget build(BuildContext context) { var content = useState(ContentModel( + folderName: '', contentId: '1', contentName: '', contentMemo: '', diff --git a/lib/screens/home/content_view.dart b/lib/screens/home/content_view.dart index 3d5540c..a0dc505 100644 --- a/lib/screens/home/content_view.dart +++ b/lib/screens/home/content_view.dart @@ -17,6 +17,7 @@ import 'package:moa_app/widgets/button.dart'; import 'package:moa_app/widgets/image.dart'; import 'package:moa_app/widgets/loading_indicator.dart'; import 'package:moa_app/widgets/moa_widgets/bottom_modal_item.dart'; +import 'package:moa_app/widgets/snackbar.dart'; import 'package:url_launcher/url_launcher.dart'; class ContentView extends HookConsumerWidget { @@ -41,6 +42,9 @@ class ContentView extends HookConsumerWidget { void pressGoToLink(String contentUrl) async { var url = Uri.parse(contentUrl); if (!await launchUrl(url)) { + if (context.mounted) { + snackbar.alert(context, '잘못된 링크입니다.\n$url'); + } throw Exception('Could not launch $url'); } } diff --git a/lib/screens/home/edit_content_view.dart b/lib/screens/home/edit_content_view.dart index 1eb9113..6ef48a4 100644 --- a/lib/screens/home/edit_content_view.dart +++ b/lib/screens/home/edit_content_view.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; @@ -6,7 +7,9 @@ import 'package:moa_app/constants/color_constants.dart'; import 'package:moa_app/constants/file_constants.dart'; import 'package:moa_app/constants/font_constants.dart'; import 'package:moa_app/models/content_model.dart'; +import 'package:moa_app/providers/content_detail_provider.dart'; import 'package:moa_app/providers/hashtag_provider.dart'; +import 'package:moa_app/repositories/content_repository.dart'; import 'package:moa_app/screens/add_content/add_image_content.dart'; import 'package:moa_app/utils/general.dart'; import 'package:moa_app/widgets/button.dart'; @@ -14,6 +17,7 @@ import 'package:moa_app/widgets/edit_text.dart'; import 'package:moa_app/widgets/image.dart'; import 'package:moa_app/widgets/loading_indicator.dart'; import 'package:moa_app/widgets/moa_widgets/hashtag_box.dart'; +import 'package:moa_app/widgets/snackbar.dart'; class EditContentView extends HookConsumerWidget { const EditContentView({ @@ -31,10 +35,31 @@ class EditContentView extends HookConsumerWidget { var hashtagList = useState>( content.contentHashTags.map((e) => e.hashTag).toList()); - void saveEditContent() { + var loading = useState(false); + + Future saveEditContent() async { // todo 컨텐츠 수정 - // ContentRepository.instance.editContent() - isEditMode.value = false; + try { + loading.value = true; + await ContentRepository.instance.editContent( + contentId: content.contentId, + contentMemo: titleController.text, + contentName: memoController.text, + hashTagStringList: hashtagList.value.join(','), + ); + await ref.read(contentDetailProvider.notifier).editContent( + contentId: content.contentId, + contentMemo: titleController.text, + contentName: memoController.text, + hashTagStringList: hashtagList.value.join(','), + ); + isEditMode.value = false; + } catch (e) { + snackbar.alert( + context, kDebugMode ? e.toString() : '오류가 발생했습니다. 다시 시도해주세요.'); + } finally { + loading.value = false; + } } void showEditHashtagModal() { @@ -139,6 +164,7 @@ class EditContentView extends HookConsumerWidget { ), const SizedBox(height: 30), Button( + loading: loading.value, backgroundColor: AppColors.primaryColor, text: '변경 내용 저장', onPressed: saveEditContent, diff --git a/lib/screens/home/folder_detail_view.dart b/lib/screens/home/folder_detail_view.dart index 895c560..1ba8b3d 100644 --- a/lib/screens/home/folder_detail_view.dart +++ b/lib/screens/home/folder_detail_view.dart @@ -82,7 +82,7 @@ class FolderDetailView extends HookConsumerWidget { child: DynamicGridList( contentList: contentList, pullToRefresh: pullToRefresh, - folderName: folderName, + folderNameProp: folderName, ), ), ], diff --git a/lib/screens/home/hashtag_detail_view.dart b/lib/screens/home/hashtag_detail_view.dart index 554747e..3f5036d 100644 --- a/lib/screens/home/hashtag_detail_view.dart +++ b/lib/screens/home/hashtag_detail_view.dart @@ -1,45 +1,81 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:moa_app/constants/color_constants.dart'; import 'package:moa_app/constants/file_constants.dart'; import 'package:moa_app/constants/font_constants.dart'; +import 'package:moa_app/models/content_model.dart'; +import 'package:moa_app/models/hashtag_model.dart'; +import 'package:moa_app/providers/hashtag_provider.dart'; +import 'package:moa_app/repositories/hashtag_repository.dart'; import 'package:moa_app/screens/home/widgets/type_header.dart'; import 'package:moa_app/utils/general.dart'; import 'package:moa_app/widgets/app_bar.dart'; import 'package:moa_app/widgets/button.dart'; +import 'package:moa_app/widgets/edit_text.dart'; +import 'package:moa_app/widgets/loading_indicator.dart'; import 'package:moa_app/widgets/moa_widgets/bottom_modal_item.dart'; import 'package:moa_app/widgets/moa_widgets/delete_content.dart'; import 'package:moa_app/widgets/moa_widgets/dynamic_grid_list.dart'; import 'package:moa_app/widgets/moa_widgets/edit_content.dart'; +import 'package:moa_app/widgets/snackbar.dart'; -class HashtagDetailView extends HookWidget { - const HashtagDetailView({super.key, required this.filterName}); +class HashtagDetailView extends HookConsumerWidget { + const HashtagDetailView({ + super.key, + required this.filterName, + required this.tagId, + }); final String filterName; + final String tagId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + var hashtagAsync = ref.watch(hashtagProvider); var gridController = useScrollController(); var gridPageNum = useState(20); var updatedHashtagName = useState(''); - Future pullToRefresh() async { - return Future.delayed( - const Duration(seconds: 2), - () {}, - ); - } + var searchFocusNode = useFocusNode(); + var searchTerms = useState>([]); + var matchQuery = useState>([]); + + var searchTextController = useTextEditingController(); + var searchBarHeight = useState(0.0); + + var searchHashText = useState(filterName); + var searchTagId = useState(tagId); + var changeText = useState(''); void showEditHashtagModal() { General.instance.showBottomSheet( context: context, child: EditContent( title: '해시태그 수정', - onPressed: () { - // todo 해시태그 수정 api 연동후 성공하면 아래 코드 실행 실패시 snackbar 경고 + onPressed: () async { + try { + await HashtagRepository.instance.editHashtag( + tagId: searchTagId.value, + hashtags: updatedHashtagName.value, + ); + await ref.read(hashtagProvider.notifier).editHashtag( + tagId: searchTagId.value, + hashtags: updatedHashtagName.value, + ); + + searchHashText.value = updatedHashtagName.value; + if (context.mounted) { + context.pop(); + } + } catch (e) { + snackbar.alert( + context, kDebugMode ? e.toString() : '해시태그 수정에 실패했습니다.'); + } }, updatedContentName: updatedHashtagName, - contentName: filterName, + contentName: searchHashText.value, ), ); } @@ -51,10 +87,22 @@ class HashtagDetailView extends HookWidget { isCloseButton: true, child: DeleteContent( folderColor: AppColors.folderColorECD8F3, - contentName: filterName, + contentName: searchHashText.value, type: ContentType.hashtag, - onPressed: () { - // todo 폴더 삭제 api 연동후 성공하면 아래 코드 실행 실패시 snackbar 경고 + onPressed: () async { + try { + await HashtagRepository.instance + .deleteHashtag(tagIds: [searchTagId.value]); + await ref + .read(hashtagProvider.notifier) + .deleteHashtag(tagIds: [searchTagId.value]); + if (context.mounted) { + context.pop(); + } + } catch (e) { + snackbar.alert( + context, kDebugMode ? e.toString() : '해시태그 삭제에 실패했습니다.'); + } }, ), ); @@ -89,6 +137,35 @@ class HashtagDetailView extends HookWidget { ); } + void onPressSearchHashtag() async { + if (searchTextController.text == '') { + return; + } + + try { + var (list, _) = await HashtagRepository.instance.getHashtagView( + tag: searchTextController.text, + ); + if (list.isEmpty) { + if (context.mounted) { + snackbar.alert(context, '검색 결과가 없습니다.'); + } + return; + } + searchHashText.value = searchTextController.text; + var tagId = list.first.contentHashTags + .where((e) => e.hashTag == searchTextController.text) + .toList() + .first + .tagId; + + searchTagId.value = tagId; + } catch (error) { + snackbar.alert( + context, kDebugMode ? error.toString() : '오류가 발생했어요 다시 시도해주세요.'); + } + } + useEffect(() { gridController.addListener(() { /// load date at when scroll reached -100 @@ -101,59 +178,280 @@ class HashtagDetailView extends HookWidget { return null; }, []); + useEffect(() { + if (searchTextController.text == '') { + matchQuery.value = []; + return; + } + + for (var hash in searchTerms.value) { + if (hash.hashTag.contains(searchTextController.text) && + !matchQuery.value.contains(hash)) { + matchQuery.value.add(HashtagModel( + tagId: hash.tagId, + hashTag: hash.hashTag, + count: hash.count, + )); + } + } + return null; + }, [searchTextController.text]); + + useEffect(() { + searchTerms.value = hashtagAsync.value ?? []; + + searchFocusNode.addListener(() { + if (searchFocusNode.hasFocus) { + searchBarHeight.value = 300; + } + + if (!searchFocusNode.hasFocus) { + searchBarHeight.value = 0; + } + }); + + return null; + }, [hashtagAsync.value]); + + void searchHashtag(String searchText) { + changeText.value = searchText; + } + return Scaffold( appBar: AppBarBack( - bottomBorderStyle: const BottomBorderStyle(height: 0), - actions: [ - CircleIconButton( - backgroundColor: AppColors.whiteColor, - icon: Image( - width: 36, - height: 36, - image: Assets.menu, + isBottomBorderDisplayed: false, + text: RichText( + text: TextSpan(children: [ + TextSpan( + text: '#${searchHashText.value}', + style: const H1TextStyle().merge( + const TextStyle( + color: AppColors.primaryColor, + ), + ), ), - onPressed: showHashtagDetailModal, - ), - ], - ), - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - children: [ - Container( - padding: - const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(50), - color: AppColors.primaryColor, + const TextSpan( + text: '의 취향들', + style: H1TextStyle(), + ), + ])), + actions: [ + CircleIconButton( + backgroundColor: AppColors.whiteColor, + icon: Image( + width: 36, + height: 36, + image: Assets.menu, + ), + onPressed: showHashtagDetailModal, + ), + ]), + body: SafeArea( + child: Stack( + children: [ + Column( + children: [ + EditText( + controller: searchTextController, + focusNode: searchFocusNode, + height: 50, + onChanged: searchHashtag, + hintText: '나의 해시태그 검색', + borderRadius: BorderRadius.circular(50), + suffixIcon: CircleIconButton( + icon: Image( + fit: BoxFit.contain, + image: Assets.searchIcon, + width: 16, + height: 16, ), - child: Text( - filterName, - style: const TextStyle( - color: AppColors.whiteColor, - fontSize: 16, - fontWeight: FontWeight.w600, - fontFamily: FontConstants.pretendard, - ), + onPressed: onPressSearchHashtag, + ), + ), + Expanded( + child: useMemoized( + () => HashtagDetailList( + searchHashText: searchHashText, ), + [searchHashText.value], + ), + ), + ], + ), + + /// 검색 결과 ui + Positioned( + top: 60, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + constraints: BoxConstraints( + maxHeight: searchBarHeight.value, + ), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 15), + width: MediaQuery.of(context).size.width - 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + color: AppColors.hashtagBackground, ), - ], + child: matchQuery.value.isNotEmpty + ? ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.only(top: 20, bottom: 20), + itemCount: matchQuery.value.length, + itemBuilder: (context, index) { + var element = matchQuery.value[index]; + + return Material( + child: InkWell( + onTap: () { + searchTagId.value = element.tagId; + searchHashText.value = element.hashTag; + searchFocusNode.unfocus(); + matchQuery.value = []; + searchTextController.clear(); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 25, vertical: 5), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + '# ${element.hashTag}', + style: const Hash1TextStyle().merge( + const TextStyle( + color: AppColors.subTitle, + height: 1.6), + ), + ), + Text( + '${element.count}개', + style: const Hash1TextStyle().merge( + const TextStyle( + color: AppColors.subTitle), + ), + ) + ], + ), + ), + ), + ); + }, + ) + : ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.only(top: 20, bottom: 20), + itemCount: searchTerms.value.length, + itemBuilder: (context, index) { + var element = searchTerms.value[index]; + + return Material( + child: InkWell( + onTap: () { + searchTagId.value = element.tagId; + searchHashText.value = element.hashTag; + searchFocusNode.unfocus(); + searchTextController.text = ''; + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 25, vertical: 5), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + '# ${element.hashTag}', + style: const Hash1TextStyle().merge( + const TextStyle( + color: AppColors.subTitle, + height: 1.6), + ), + ), + Text( + '${element.count}개', + style: const Hash1TextStyle().merge( + const TextStyle( + color: AppColors.subTitle), + ), + ) + ], + ), + ), + ), + ); + }, + ), + ), ), - const SizedBox(height: 15), - TypeHeader(count: 146, onPressFilter: () {}), - const SizedBox(height: 5), - Expanded( - child: DynamicGridList( - contentList: const [], - pullToRefresh: pullToRefresh, - folderName: 'folderName', - )), - ], - ), - ), + ) + ], + )), + ); + } +} + +class HashtagDetailList extends HookConsumerWidget { + const HashtagDetailList({ + super.key, + required this.searchHashText, + }); + final ValueNotifier searchHashText; + + @override + Widget build(BuildContext context, WidgetRef ref) { + Future pullToRefresh() async { + return Future.delayed( + const Duration(seconds: 2), + () {}, + ); + } + + void onPressFilter() {} + + return FutureBuilder<(List, int)>( + future: + HashtagRepository.instance.getHashtagView(tag: searchHashText.value), + builder: (context, snapshot) { + var (list, _) = snapshot.data ?? ([], 0); + + return AnimatedSwitcher( + transitionBuilder: (child, animation) { + return FadeTransition(opacity: animation, child: child); + }, + duration: const Duration(milliseconds: 300), + child: () { + var hashCount = list + .expand((element) => element.contentHashTags) + .where((element) => element.hashTag == searchHashText.value) + .length; + if (snapshot.connectionState == ConnectionState.waiting) { + return const LoadingIndicator(); + } + if (snapshot.hasData && list.isNotEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + TypeHeader(count: hashCount, onPressFilter: onPressFilter), + const SizedBox(height: 5), + Expanded( + child: DynamicGridList( + contentList: list as List, + pullToRefresh: pullToRefresh, + )), + ], + ), + ); + } + return const SizedBox(); + }(), + ); + }, ); } } diff --git a/lib/screens/home/tab_view/hashtag_tab_view.dart b/lib/screens/home/tab_view/hashtag_tab_view.dart index 441fac0..c56990e 100644 --- a/lib/screens/home/tab_view/hashtag_tab_view.dart +++ b/lib/screens/home/tab_view/hashtag_tab_view.dart @@ -1,14 +1,20 @@ import 'package:extended_nested_scroll_view/extended_nested_scroll_view.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:loading_more_list/loading_more_list.dart'; import 'package:moa_app/constants/app_constants.dart'; +import 'package:moa_app/constants/color_constants.dart'; import 'package:moa_app/constants/file_constants.dart'; +import 'package:moa_app/constants/font_constants.dart'; import 'package:moa_app/models/content_model.dart'; +import 'package:moa_app/models/hashtag_model.dart'; +import 'package:moa_app/providers/hashtag_provider.dart'; import 'package:moa_app/providers/hashtag_view_provider.dart'; import 'package:moa_app/repositories/content_repository.dart'; +import 'package:moa_app/repositories/hashtag_repository.dart'; import 'package:moa_app/screens/home/content_view.dart'; import 'package:moa_app/screens/home/home.dart'; import 'package:moa_app/screens/home/widgets/content_card.dart'; @@ -18,6 +24,7 @@ import 'package:moa_app/widgets/button.dart'; import 'package:moa_app/widgets/edit_text.dart'; import 'package:moa_app/widgets/image.dart'; import 'package:moa_app/widgets/loading_indicator.dart'; +import 'package:moa_app/widgets/snackbar.dart'; class HashtagTabView extends HookConsumerWidget { const HashtagTabView({ @@ -35,6 +42,15 @@ class HashtagTabView extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { var width = MediaQuery.of(context).size.width; + var hashtagAsync = ref.watch(hashtagProvider); + var searchFocusNode = useFocusNode(); + var searchTerms = useState>([]); + var matchQuery = useState>([]); + + var searchTextController = useTextEditingController(); + var searchBarHeight = useState(0.0); + + var changeText = useState(''); useEffect(() { if (isRefresh == true) { @@ -55,7 +71,7 @@ class HashtagTabView extends HookConsumerWidget { folderName: folderName, source: source, contentType: - contentUrl != null ? AddContentType.url : AddContentType.image, + contentUrl != '' ? AddContentType.url : AddContentType.image, ), ); } @@ -66,107 +82,297 @@ class HashtagTabView extends HookConsumerWidget { // ); } - return Column( + void onPressFilter() {} + + void searchHashtag(String searchText) { + changeText.value = searchText; + } + + void onPressSearchHashtag() async { + if (searchTextController.text == '') { + return; + } + + try { + var (list, _) = await HashtagRepository.instance.getHashtagView( + tag: searchTextController.text, + ); + if (list.isEmpty) { + if (context.mounted) { + snackbar.alert(context, '검색 결과가 없습니다.'); + } + return; + } + + var tagId = list.first.contentHashTags + .where((e) => e.hashTag == searchTextController.text) + .toList() + .first + .tagId; + + if (context.mounted) { + context.go( + '${GoRoutes.hashtag.fullPath}/${searchTextController.text}?tagId=$tagId', + ); + } + } catch (error) { + snackbar.alert( + context, kDebugMode ? error.toString() : '오류가 발생했어요 다시 시도해주세요.'); + } + } + + useEffect(() { + if (searchTextController.text == '') { + matchQuery.value = []; + return; + } + + for (var hash in searchTerms.value) { + if (hash.hashTag.contains(searchTextController.text) && + !matchQuery.value.contains(hash)) { + matchQuery.value.add(HashtagModel( + tagId: hash.tagId, + hashTag: hash.hashTag, + count: hash.count, + )); + } + } + return null; + }, [searchTextController.text]); + + useEffect(() { + searchTerms.value = hashtagAsync.value ?? []; + searchFocusNode.addListener(() { + if (searchFocusNode.hasFocus) { + searchBarHeight.value = 300; + } + + if (!searchFocusNode.hasFocus) { + searchBarHeight.value = 0; + } + }); + return null; + }, [hashtagAsync.value]); + + return Stack( children: [ - Container( - margin: const EdgeInsets.only(top: 25), - padding: const EdgeInsets.symmetric(horizontal: 15), - child: Column( - children: [ - EditText( - height: 50, - onChanged: (value) {}, - hintText: '나의 해시태그 검색', - borderRadius: BorderRadius.circular(50), - suffixIcon: CircleIconButton( - icon: Image( - fit: BoxFit.contain, - image: Assets.searchIcon, - width: 16, - height: 16, + Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 25), + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Column( + children: [ + EditText( + controller: searchTextController, + focusNode: searchFocusNode, + height: 50, + onChanged: searchHashtag, + hintText: '나의 해시태그 검색', + borderRadius: BorderRadius.circular(50), + suffixIcon: CircleIconButton( + icon: Image( + fit: BoxFit.contain, + image: Assets.searchIcon, + width: 16, + height: 16, + ), + onPressed: onPressSearchHashtag, + ), ), - onPressed: () {}, - ), + const SizedBox(height: 20), + TypeHeader(count: count, onPressFilter: onPressFilter), + const SizedBox(height: 5), + ], ), - const SizedBox(height: 20), - TypeHeader(count: count, onPressFilter: () {}), - const SizedBox(height: 5), - ], - ), - ), - Expanded( - child: ExtendedVisibilityDetector( - uniqueKey: uniqueKey, - child: RefreshIndicator( - onRefresh: () { - ref.refresh(hashtagViewProvider).value; - return source.refresh(true); - }, - child: LoadingMoreList( - onScrollNotification: (notification) { - if (notification is ScrollEndNotification) { - if (notification.metrics.pixels == - notification.metrics.maxScrollExtent) { - source.loadMore(); - } - } - return false; - }, - ListConfig( - physics: const BouncingScrollPhysics(), - addRepaintBoundaries: true, - padding: const EdgeInsets.only( - left: 15, - right: 15, - top: 15, - bottom: kBottomNavigationBarHeight, - ), - extendedListDelegate: - SliverWaterfallFlowDelegateWithFixedCrossAxisCount( - crossAxisCount: width > Breakpoints.md ? 3 : 2, - mainAxisSpacing: 20.0, - crossAxisSpacing: 12.0, - ), - sourceList: source, - indicatorBuilder: (context, status) { - if ((status == IndicatorStatus.loadingMoreBusying) || - (status == IndicatorStatus.fullScreenBusying)) { - return const LoadingIndicator(); - } - return const SizedBox(); + ), + Expanded( + child: ExtendedVisibilityDetector( + uniqueKey: uniqueKey, + child: RefreshIndicator( + onRefresh: () { + ref.refresh(hashtagViewProvider).value; + return source.refresh(true); }, - itemBuilder: (c, item, index) { - return InkWell( - splashColor: Colors.transparent, - borderRadius: BorderRadius.circular(10), - onTap: () => goContentView( - contentId: item.contentId, - folderName: item.folderName ?? '', - contentUrl: item.contentUrl, + child: LoadingMoreList( + onScrollNotification: (notification) { + if (notification is ScrollEndNotification) { + if (notification.metrics.pixels == + notification.metrics.maxScrollExtent) { + source.loadMore(); + } + } + return false; + }, + ListConfig( + physics: const BouncingScrollPhysics(), + addRepaintBoundaries: true, + padding: const EdgeInsets.only( + left: 15, + right: 15, + top: 15, + bottom: kBottomNavigationBarHeight, ), - child: Column( - children: [ - item.thumbnailImageUrl == '' - ? const Text('이미지 없을 경우 모아 이미지로 대체') - : AspectRatio( - aspectRatio: 1, - child: ImageOnNetwork( - imageURL: item.thumbnailImageUrl, - ), - ), - ContentCard( - content: item, - onPressHashtag: (tag) => goHashtagDetailView(tag), - ), - ], + extendedListDelegate: + SliverWaterfallFlowDelegateWithFixedCrossAxisCount( + crossAxisCount: width > Breakpoints.md ? 3 : 2, + mainAxisSpacing: 20.0, + crossAxisSpacing: 12.0, ), - ); - }, + sourceList: source, + indicatorBuilder: (context, status) { + if ((status == IndicatorStatus.loadingMoreBusying) || + (status == IndicatorStatus.fullScreenBusying)) { + return const LoadingIndicator(); + } + return const SizedBox(); + }, + itemBuilder: (c, item, index) { + return InkWell( + splashColor: Colors.transparent, + borderRadius: BorderRadius.circular(10), + onTap: () => goContentView( + contentId: item.contentId, + folderName: item.folderName ?? '', + contentUrl: item.contentUrl, + ), + child: Column( + children: [ + item.thumbnailImageUrl == '' + ? const Text('이미지 없을 경우 모아 이미지로 대체') + : AspectRatio( + aspectRatio: 1, + child: ImageOnNetwork( + imageURL: item.thumbnailImageUrl, + ), + ), + ContentCard( + content: item, + onPressHashtag: (tag) => + goHashtagDetailView(tag), + ), + ], + ), + ); + }, + ), + ), ), ), ), - ), + ], ), + + /// 검색 결과 ui + Positioned( + top: 85, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + constraints: BoxConstraints( + maxHeight: searchBarHeight.value, + ), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 15), + width: MediaQuery.of(context).size.width - 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + color: AppColors.hashtagBackground, + ), + child: matchQuery.value.isNotEmpty + ? ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.only(top: 20, bottom: 20), + itemCount: matchQuery.value.length, + itemBuilder: (context, index) { + var element = matchQuery.value[index]; + + return Material( + child: InkWell( + onTap: () { + context.go( + '${GoRoutes.hashtag.fullPath}/${element.hashTag}?tagId=${element.tagId}', + ); + searchFocusNode.unfocus(); + matchQuery.value = []; + searchTextController.clear(); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 25, vertical: 5), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + '# ${element.hashTag}', + style: const Hash1TextStyle().merge( + const TextStyle( + color: AppColors.subTitle, + height: 1.6), + ), + ), + Text( + '${element.count}개', + style: const Hash1TextStyle().merge( + const TextStyle( + color: AppColors.subTitle), + ), + ) + ], + ), + ), + ), + ); + }, + ) + : ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.only(top: 20, bottom: 20), + itemCount: searchTerms.value.length, + itemBuilder: (context, index) { + var element = searchTerms.value[index]; + + return Material( + child: InkWell( + onTap: () { + context.go( + '${GoRoutes.hashtag.fullPath}/${element.hashTag}?tagId=${element.tagId}', + ); + searchFocusNode.unfocus(); + searchTextController.text = ''; + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 25, vertical: 5), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + '# ${element.hashTag}', + style: const Hash1TextStyle().merge( + const TextStyle( + color: AppColors.subTitle, + height: 1.6), + ), + ), + Text( + '${element.count}개', + style: const Hash1TextStyle().merge( + const TextStyle( + color: AppColors.subTitle), + ), + ) + ], + ), + ), + ), + ); + }, + ), + ), + ), + ) ], ); } diff --git a/lib/screens/setting/edit_my_type_view.dart b/lib/screens/setting/edit_my_type_view.dart index 196b1f1..339c328 100644 --- a/lib/screens/setting/edit_my_type_view.dart +++ b/lib/screens/setting/edit_my_type_view.dart @@ -158,7 +158,6 @@ class EditMyTypeView extends HookWidget { child: DynamicGridList( contentList: const [], pullToRefresh: hashtagPullToRefresh, - folderName: 'folderName', ), ), ], diff --git a/lib/utils/router_provider.dart b/lib/utils/router_provider.dart index 02fdec3..b537d15 100644 --- a/lib/utils/router_provider.dart +++ b/lib/utils/router_provider.dart @@ -207,7 +207,9 @@ final routeProvider = Provider( context: context, state: state, child: HashtagDetailView( - filterName: state.pathParameters['hashtag']!), + filterName: state.pathParameters['hashtag'] ?? '', + tagId: state.queryParameters['tagId'] ?? '', + ), ); }, ), diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart index e2015b1..c8e7542 100644 --- a/lib/widgets/app_bar.dart +++ b/lib/widgets/app_bar.dart @@ -9,6 +9,7 @@ class AppBarBack extends StatelessWidget implements PreferredSizeWidget { const AppBarBack({ Key? key, this.title, + this.text, this.leading, this.actions, this.isBottomBorderDisplayed = true, @@ -16,6 +17,7 @@ class AppBarBack extends StatelessWidget implements PreferredSizeWidget { this.onPressedBack, }) : super(key: key); final String? title; + final Widget? text; final Widget? leading; final List? actions; final bool isBottomBorderDisplayed; @@ -26,10 +28,11 @@ class AppBarBack extends StatelessWidget implements PreferredSizeWidget { Widget build(BuildContext context) { return AppBar( backgroundColor: AppColors.whiteColor, - title: Text( - title ?? '', - style: const H2TextStyle(), - ), + title: text ?? + Text( + title ?? '', + style: const H2TextStyle(), + ), leading: CircleIconButton( backgroundColor: AppColors.whiteColor, icon: Image( diff --git a/lib/widgets/edit_text.dart b/lib/widgets/edit_text.dart index 0bcfa36..7cf7789 100644 --- a/lib/widgets/edit_text.dart +++ b/lib/widgets/edit_text.dart @@ -33,6 +33,7 @@ class EditText extends StatefulWidget { this.maxLength, this.padding, this.inputPadding, + this.focusNode, }); final void Function(String) onChanged; @@ -54,6 +55,7 @@ class EditText extends StatefulWidget { final int? maxLength; final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? inputPadding; + final FocusNode? focusNode; @override State createState() => _EditTextState(); @@ -71,6 +73,7 @@ class _EditTextState extends State { height: widget.height, width: widget.width, child: TextField( + focusNode: widget.focusNode, maxLength: widget.maxLength, maxLines: widget.maxLines, controller: widget.controller, diff --git a/lib/widgets/moa_widgets/custom_search_delegate.dart b/lib/widgets/moa_widgets/custom_search_delegate.dart new file mode 100644 index 0000000..197ff01 --- /dev/null +++ b/lib/widgets/moa_widgets/custom_search_delegate.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:moa_app/constants/color_constants.dart'; +import 'package:moa_app/constants/font_constants.dart'; +import 'package:moa_app/models/hashtag_model.dart'; +import 'package:moa_app/providers/hashtag_provider.dart'; +import 'package:moa_app/utils/router_provider.dart'; + +class CustomSearchDelegate extends HookConsumerWidget { + const CustomSearchDelegate({ + super.key, + required this.searchBarHeight, + required this.matchQuery, + required this.searchFocusNode, + required this.searchTextController, + required this.searchTerms, + }); + final ValueNotifier searchBarHeight; + final ValueNotifier> matchQuery; + final FocusNode searchFocusNode; + final TextEditingController searchTextController; + final ValueNotifier> searchTerms; + + @override + Widget build(BuildContext context, WidgetRef ref) { + var hashtagAsync = ref.watch(hashtagProvider); + + useEffect(() { + if (context.mounted) { + searchTerms.value = hashtagAsync.value ?? []; + searchFocusNode.addListener(() { + if (searchFocusNode.hasFocus) { + searchBarHeight.value = 300; + } + + if (!searchFocusNode.hasFocus) { + searchBarHeight.value = 0; + } + }); + } + return null; + }, [hashtagAsync.value]); + + return + + /// 검색 결과 ui + Positioned( + top: 60, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + constraints: BoxConstraints( + maxHeight: searchBarHeight.value, + ), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 15), + width: MediaQuery.of(context).size.width - 30, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + color: AppColors.hashtagBackground, + ), + child: matchQuery.value.isNotEmpty + ? ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.only(top: 20, bottom: 20), + itemCount: matchQuery.value.length, + itemBuilder: (context, index) { + var element = matchQuery.value[index]; + + return Material( + child: InkWell( + onTap: () { + context.go( + '${GoRoutes.hashtag.fullPath}/${element.hashTag}', + ); + searchFocusNode.unfocus(); + matchQuery.value = []; + searchTextController.clear(); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 25, vertical: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '# ${element.hashTag}', + style: const Hash1TextStyle().merge( + const TextStyle( + color: AppColors.subTitle, height: 1.6), + ), + ), + Text( + '${element.count}개', + style: const Hash1TextStyle().merge( + const TextStyle(color: AppColors.subTitle), + ), + ) + ], + ), + ), + ), + ); + }, + ) + : ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.only(top: 20, bottom: 20), + itemCount: searchTerms.value.length, + itemBuilder: (context, index) { + var element = searchTerms.value[index]; + + return Material( + child: InkWell( + onTap: () { + context.go( + '${GoRoutes.hashtag.fullPath}/${element.hashTag}', + ); + searchFocusNode.unfocus(); + searchTextController.text = ''; + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 25, vertical: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '# ${element.hashTag}', + style: const Hash1TextStyle().merge( + const TextStyle( + color: AppColors.subTitle, height: 1.6), + ), + ), + Text( + '${element.count}개', + style: const Hash1TextStyle().merge( + const TextStyle(color: AppColors.subTitle), + ), + ) + ], + ), + ), + ), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/moa_widgets/dynamic_grid_list.dart b/lib/widgets/moa_widgets/dynamic_grid_list.dart index 80d78cd..f2d502a 100644 --- a/lib/widgets/moa_widgets/dynamic_grid_list.dart +++ b/lib/widgets/moa_widgets/dynamic_grid_list.dart @@ -17,23 +17,27 @@ class DynamicGridList extends HookWidget { super.key, required this.contentList, required this.pullToRefresh, - required this.folderName, + this.folderNameProp, }); final List contentList; final Future Function() pullToRefresh; - final String folderName; + final String? folderNameProp; @override Widget build(BuildContext context) { var width = MediaQuery.of(context).size.width; - void goContentView({required String contentId, String? contentUrl}) { + void goContentView({ + required String contentId, + required String folderName, + String? contentUrl, + }) { context.push( '${GoRoutes.content.fullPath}/$contentId', extra: ContentView( id: contentId, - folderName: folderName, + folderName: folderNameProp ?? folderName, contentType: - contentUrl != null ? AddContentType.url : AddContentType.image, + contentUrl != '' ? AddContentType.url : AddContentType.image, ), ); } @@ -41,6 +45,7 @@ class DynamicGridList extends HookWidget { return RefreshIndicator( onRefresh: pullToRefresh, child: SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.only(bottom: kBottomNavigationBarHeight), child: StaggeredGrid.count( axisDirection: AxisDirection.down, @@ -57,6 +62,7 @@ class DynamicGridList extends HookWidget { goContentView( contentId: contentList[i].contentId, contentUrl: contentList[i].contentUrl, + folderName: contentList[i].folderName ?? '', ); }, child: Column( @@ -114,6 +120,7 @@ class DynamicGridList extends HookWidget { contentName: contentList[i].contentName, contentMemo: contentList[i].contentMemo, contentHashTags: contentList[i].contentHashTags, + folderName: contentList[i].folderName, ), ), ], diff --git a/lib/widgets/moa_widgets/edit_content.dart b/lib/widgets/moa_widgets/edit_content.dart index ceff34a..a38a815 100644 --- a/lib/widgets/moa_widgets/edit_content.dart +++ b/lib/widgets/moa_widgets/edit_content.dart @@ -49,7 +49,6 @@ class EditContent extends HookWidget { void editContent() async { onPressed(); - emptyContentName(); } void closeBottomSheet() {