diff --git a/lib/core/design_system/components/app_empty_state.dart b/lib/core/design_system/components/app_empty_state.dart new file mode 100644 index 00000000..7a9fc451 --- /dev/null +++ b/lib/core/design_system/components/app_empty_state.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:ondo/core/design_system/app_colors.dart'; +import 'package:ondo/core/design_system/app_icon.dart'; +import 'package:ondo/core/design_system/app_text_styles.dart'; + +class AppEmptyState extends StatelessWidget { + final String message; + final AppIcon? icon; + + const AppEmptyState({ + super.key, + required this.message, + this.icon, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + _buildIcon(icon!), + const SizedBox(height: 12), + ], + Text( + message, + style: AppTextStyles.textMedium(textColor: AppColors.gray60), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + Widget _buildIcon(AppIcon appIcon) { + const size = 48.0; + final path = appIcon.path; + if (path.endsWith('.svg')) { + return SvgPicture.asset( + path, + width: size, + height: size, + colorFilter: const ColorFilter.mode(AppColors.gray60, BlendMode.srcIn), + ); + } + return Image.asset( + path, + width: size, + height: size, + color: AppColors.gray60, + ); + } +} diff --git a/lib/core/design_system/components/app_error_state.dart b/lib/core/design_system/components/app_error_state.dart new file mode 100644 index 00000000..bdb227ce --- /dev/null +++ b/lib/core/design_system/components/app_error_state.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:ondo/core/design_system/app_colors.dart'; +import 'package:ondo/core/design_system/app_text_styles.dart'; + +class AppErrorState extends StatelessWidget { + final String message; + final VoidCallback? onRetry; + + const AppErrorState({ + super.key, + this.message = '데이터를 불러오지 못했어요.', + this.onRetry, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, size: 48, color: AppColors.gray60), + const SizedBox(height: 12), + Text( + message, + style: AppTextStyles.textMedium(textColor: AppColors.gray60), + textAlign: TextAlign.center, + ), + if (onRetry != null) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: onRetry, + child: Text( + '다시 시도', + style: AppTextStyles.textMedium(textColor: AppColors.primary), + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/core/design_system/components/app_loading_indicator.dart b/lib/core/design_system/components/app_loading_indicator.dart new file mode 100644 index 00000000..661afdbc --- /dev/null +++ b/lib/core/design_system/components/app_loading_indicator.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:ondo/core/design_system/app_colors.dart'; + +class AppLoadingIndicator extends StatelessWidget { + const AppLoadingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return const Center( + child: CircularProgressIndicator( + color: AppColors.primary, + strokeWidth: 2.5, + ), + ); + } +} + +/// 리스트 하단 무한스크롤 로딩 인디케이터 +class AppBottomLoadingIndicator extends StatelessWidget { + const AppBottomLoadingIndicator({super.key}); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: AppColors.primary, + strokeWidth: 2, + ), + ), + ), + ); + } +} diff --git a/lib/core/router/bindings/community_binding.dart b/lib/core/router/bindings/community_binding.dart index 21bce934..b984b24a 100644 --- a/lib/core/router/bindings/community_binding.dart +++ b/lib/core/router/bindings/community_binding.dart @@ -3,6 +3,7 @@ import 'package:ondo/core/router/bindings/search_binding.dart'; import 'package:ondo/domain/usecases/post/like_post_usecase.dart'; import 'package:ondo/domain/usecases/post/liked_post_use_case.dart'; import 'package:ondo/domain/usecases/post/load_recommend_post_list_use_case.dart'; +import 'package:ondo/domain/usecases/post/post_search_use_case.dart'; import 'package:ondo/domain/usecases/post/save_post_like_local_use_case.dart'; import 'package:ondo/domain/usecases/post/unlike_post_usecase.dart'; import 'package:ondo/presentation/community/controllers/community_controller.dart'; @@ -20,6 +21,7 @@ class CommunityBinding extends Bindings { getRecommendPostsUseCase: Get.find(), savePostLikeLocalUseCase: Get.find(), likedPostUseCase: Get.find(), + postSearchUseCase: Get.find(), ), ); } diff --git a/lib/core/router/bindings/post_binding.dart b/lib/core/router/bindings/post_binding.dart index db20b677..2a7c89e1 100644 --- a/lib/core/router/bindings/post_binding.dart +++ b/lib/core/router/bindings/post_binding.dart @@ -4,7 +4,9 @@ import 'package:ondo/data/datasource/post/post_remote_datasource.dart'; import 'package:ondo/data/network/clients/auth_client.dart'; import 'package:ondo/data/repositories/post/post_repository_impl.dart'; import 'package:ondo/domain/usecases/post/bookmark_post_usecase.dart'; +import 'package:ondo/domain/usecases/post/create_post_usecase.dart'; import 'package:ondo/domain/usecases/post/like_post_usecase.dart'; +import 'package:ondo/domain/usecases/post/update_post_usecase.dart'; import 'package:ondo/domain/usecases/post/liked_post_use_case.dart'; // ← 추가 import 'package:ondo/domain/usecases/post/load_recent_popular_post_list_use_case.dart'; import 'package:ondo/domain/usecases/post/load_recommend_post_list_use_case.dart'; @@ -44,6 +46,14 @@ class PostBinding extends Bindings { () => UnlikePostUseCase(Get.find()), ); + Get.lazyPut( + () => CreatePostUseCase(Get.find()), + ); + + Get.lazyPut( + () => UpdatePostUseCase(Get.find()), + ); + Get.lazyPut( () => BookmarkPostUseCase(Get.find()), ); diff --git a/lib/core/router/bindings/post_view_binding.dart b/lib/core/router/bindings/post_view_binding.dart index 6aa0acd0..b2333bda 100644 --- a/lib/core/router/bindings/post_view_binding.dart +++ b/lib/core/router/bindings/post_view_binding.dart @@ -11,7 +11,6 @@ import '../../../data/repositories/comment/comment_repository_impl.dart'; import '../../../data/repositories/post/post_repository_impl.dart'; import '../../../domain/usecases/post/bookmark_post_usecase.dart'; -import '../../../domain/usecases/post/create_post_usecase.dart'; import '../../../domain/usecases/post/delete_post_usecase.dart'; import '../../../domain/usecases/post/get_post_detail_usecase.dart'; import '../../../domain/usecases/post/like_post_usecase.dart'; @@ -33,14 +32,6 @@ class PostViewBinding extends Bindings { () => GetPostDetailUseCase(Get.find()), ); - Get.lazyPut( - () => CreatePostUseCase(Get.find()), - ); - - Get.lazyPut( - () => UpdatePostUseCase(Get.find()), - ); - Get.lazyPut( () => DeletePostUseCase(Get.find()), ); diff --git a/lib/core/utils/app_snackbar.dart b/lib/core/utils/app_snackbar.dart new file mode 100644 index 00000000..28e5fbf8 --- /dev/null +++ b/lib/core/utils/app_snackbar.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:ondo/core/design_system/app_colors.dart'; +import 'package:ondo/core/design_system/app_text_styles.dart'; + +final GlobalKey scaffoldMessengerKey = + GlobalKey(); + +class AppSnackbar { + static void showError(String message) { + scaffoldMessengerKey.currentState + ?..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + message, + style: AppTextStyles.caption(textColor: AppColors.white), + ), + backgroundColor: AppColors.gray90, + behavior: SnackBarBehavior.floating, + duration: const Duration(seconds: 3), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ); + } +} diff --git a/lib/data/datasource/chat/chat_remote_datasource.dart b/lib/data/datasource/chat/chat_remote_datasource.dart index 3ace4eea..195ed0d9 100644 --- a/lib/data/datasource/chat/chat_remote_datasource.dart +++ b/lib/data/datasource/chat/chat_remote_datasource.dart @@ -10,7 +10,7 @@ class ChatRemoteDatasource { ChatRemoteDatasource({required this.client}); - Future loadMyChatRoomList(ListRequestModelBasePage model) async { + Future loadMyChatRoomList(ListRequestModelBasePage model) async { final log = ApiConstants(logName: "내 채팅 목록"); try { @@ -20,18 +20,19 @@ class ChatRemoteDatasource { final body = jsonDecode(res.body); + log.statusLog(res.statusCode); log.successLog(body["success"] == true); log.messageLog(body["message"]); - if (res.statusCode == 200 && body["success"] == true) { - return body["data"]; + if (body["success"] != true) { + throw Exception(body["message"]); } - log.statusLog(res.statusCode); + + return body["data"]; } catch (e) { log.errorLog(e); + rethrow; } - - return null; } Future createChatRoom(String usersPublicId) async { diff --git a/lib/data/datasource/comment/comment_remote_datasource.dart b/lib/data/datasource/comment/comment_remote_datasource.dart index 1dbe6ad4..dedc6521 100644 --- a/lib/data/datasource/comment/comment_remote_datasource.dart +++ b/lib/data/datasource/comment/comment_remote_datasource.dart @@ -2,9 +2,10 @@ import 'dart:convert'; import 'package:ondo/data/network/clients/auth_client.dart'; import 'package:ondo/data/network/constants/api_constants.dart'; import 'package:ondo/data/models/comment/response/comment_model.dart'; +import 'package:ondo/domain/entities/base/listable_wrapper.dart'; abstract class CommentRemoteDataSource { - Future> getComments(int postId); + Future> getComments(int postId, {int page = 0, int size = 10}); Future createComment({ required int postId, required String content, @@ -28,11 +29,11 @@ class CommentRemoteDataSourceImpl implements CommentRemoteDataSource { } @override - Future> getComments(int postId) async { + Future> getComments(int postId, {int page = 0, int size = 10}) async { final log = ApiConstants(logName: '댓글 조회'); try { final response = await _client.get( - Uri.parse('${ApiConstants.comment}/$postId'), + Uri.parse('${ApiConstants.comment}/$postId?page=$page&size=$size'), ); log.statusLog(response.statusCode); @@ -49,10 +50,17 @@ class CommentRemoteDataSourceImpl implements CommentRemoteDataSource { } final data = body['data']; - if (data == null) return []; + if (data == null) return ListableWrapper.none(); final list = (data['content'] as List?) ?? []; - return list.map((e) => CommentModel.fromJson(e)).toList(); + return ListableWrapper( + content: list.map((e) => CommentModel.fromJson(e)).toList(), + page: data['page'] ?? 0, + size: data['size'] ?? size, + totalElements: data['totalElements'] ?? 0, + totalPages: data['totalPages'] ?? 0, + last: data['last'], + ); } catch (e) { log.errorLog(e); rethrow; diff --git a/lib/data/datasource/notification/notification_remote_datasource.dart b/lib/data/datasource/notification/notification_remote_datasource.dart index fe774449..864c16ef 100644 --- a/lib/data/datasource/notification/notification_remote_datasource.dart +++ b/lib/data/datasource/notification/notification_remote_datasource.dart @@ -9,7 +9,7 @@ class NotificationRemoteDatasource { NotificationRemoteDatasource({required this.client}); - Future loadMyNotificationList(ListRequestModelBasePage model) async { + Future loadMyNotificationList(ListRequestModelBasePage model) async { final log = ApiConstants(logName: "내 알림 목록"); try { @@ -19,17 +19,19 @@ class NotificationRemoteDatasource { final body = jsonDecode(res.body); + log.statusLog(res.statusCode); log.successLog(body["success"] == true); log.messageLog(body["message"]); - if (res.statusCode == 200 && body["success"] == true) { - return body["data"]; + if (body["success"] != true) { + throw Exception(body["message"]); } - log.statusLog(res.statusCode); + + return body["data"]; } catch (e) { log.errorLog(e); + rethrow; } - return null; } Future loadUnreadNotificationCount() async { diff --git a/lib/data/datasource/post/post_remote_datasource.dart b/lib/data/datasource/post/post_remote_datasource.dart index aabd7361..07ca6fe4 100644 --- a/lib/data/datasource/post/post_remote_datasource.dart +++ b/lib/data/datasource/post/post_remote_datasource.dart @@ -11,23 +11,22 @@ class PostRemoteDatasource { PostRemoteDatasource(this._client); - Future getRecentPopularPostList() async { + Future getRecentPopularPostList() async { final log = ApiConstants(logName: "인기 게시글 Top 10 조회 서버"); try { final res = await _client.get(Uri.parse("${ApiConstants.post}/popular")); - final body = jsonDecode(res.body); - log.serverLog(body, statusCode: res.statusCode); - - if (res.statusCode == 200 && body["success"] == true) { - return body["data"]; + log.statusLog(res.statusCode); + log.successLog(body["success"] ?? false); + log.messageLog(body["message"]); + if (body["success"] != true) { + throw Exception(body["message"]); } - + return body["data"] as List? ?? []; } catch (e) { log.errorLog(e); + rethrow; } - - return null; } Future getRecommendPostList(ListRequestModelBasePage model) async { @@ -218,7 +217,7 @@ class PostRemoteDatasource { } } - Future search(PostSearchRequestModel model) async { + Future search(PostSearchRequestModel model) async { final log = ApiConstants(logName: "게시글 검색 서버"); try { @@ -230,18 +229,18 @@ class PostRemoteDatasource { final body = jsonDecode(res.body); + log.statusLog(res.statusCode); log.successLog(body["success"]); log.messageLog(body["message"]); - if (res.statusCode == 200 && body["success"] == true) { - return body["data"]; + if (body["success"] != true) { + throw Exception(body["message"]); } - log.statusLog(res.statusCode); + return body["data"]; } catch (e) { log.errorLog(e); + rethrow; } - - return null; } } diff --git a/lib/data/datasource/user/user_remote_datasource.dart b/lib/data/datasource/user/user_remote_datasource.dart index 7585b414..fcc9443c 100644 --- a/lib/data/datasource/user/user_remote_datasource.dart +++ b/lib/data/datasource/user/user_remote_datasource.dart @@ -12,7 +12,7 @@ class UserRemoteDatasource { UserRemoteDatasource({required this.client}); - Future search(UserSearchRequestModel model) async { + Future search(UserSearchRequestModel model) async { final log = ApiConstants(logName: "유저 검색 서버"); try { @@ -22,21 +22,22 @@ class UserRemoteDatasource { final body = jsonDecode(res.body); + log.statusLog(res.statusCode); log.successLog(body["success"] == true); log.messageLog(body["message"]); - if (res.statusCode == 200 && body["success"] == true) { - return body["data"]; + if (body["success"] != true) { + throw Exception(body["message"]); } - log.statusLog(res.statusCode); + + return body["data"]; } catch (e) { log.errorLog(e); + rethrow; } - - return null; } - Future loadRecommendUserList( + Future loadRecommendUserList( ListRequestModelBasePage model, ) async { final log = ApiConstants(logName: "홈 추천 사용자 조회"); @@ -47,18 +48,19 @@ class UserRemoteDatasource { ); final body = jsonDecode(res.body); + log.statusLog(res.statusCode); log.successLog(body["success"] == true); log.messageLog(body["message"]); - if (res.statusCode == 200 && body["success"] == true) { - return body["data"]; + if (body["success"] != true) { + throw Exception(body["message"]); } - log.statusLog(res.statusCode); + return body["data"]; } catch (e) { log.errorLog(e); + rethrow; } - return null; } /// 상대 프로필 조회 GET /users/{publicId}/profile diff --git a/lib/data/repositories/chat/chat_repository_impl.dart b/lib/data/repositories/chat/chat_repository_impl.dart index 6d81f090..2974ea2f 100644 --- a/lib/data/repositories/chat/chat_repository_impl.dart +++ b/lib/data/repositories/chat/chat_repository_impl.dart @@ -19,9 +19,6 @@ class ChatRepositoryImpl extends ChatRepository { final model = ListRequestModelBasePage(size: size, page: page); final json = await remoteDatasource.loadMyChatRoomList(model); - - if (json == null) return []; - final res = ChatDataModel.fromJson(json); return res.content diff --git a/lib/data/repositories/comment/comment_repository_impl.dart b/lib/data/repositories/comment/comment_repository_impl.dart index 341725cc..aaf68128 100644 --- a/lib/data/repositories/comment/comment_repository_impl.dart +++ b/lib/data/repositories/comment/comment_repository_impl.dart @@ -1,5 +1,6 @@ import 'package:ondo/data/datasource/comment/comment_remote_datasource.dart'; import 'package:ondo/data/models/comment/response/comment_model.dart'; +import 'package:ondo/domain/entities/base/listable_wrapper.dart'; import 'package:ondo/domain/repositories/comment/comment_repository.dart'; class CommentRepositoryImpl implements CommentRepository { @@ -10,9 +11,9 @@ class CommentRepositoryImpl implements CommentRepository { }); @override - Future> getComments(int postId) async { + Future> getComments(int postId, {int page = 0, int size = 10}) async { try { - return await remoteDataSource.getComments(postId); + return await remoteDataSource.getComments(postId, page: page, size: size); } catch (e) { throw Exception('댓글 조회 실패: $e'); } diff --git a/lib/data/repositories/notification/notification_repository_impl.dart b/lib/data/repositories/notification/notification_repository_impl.dart index 801a265f..ac33fcc4 100644 --- a/lib/data/repositories/notification/notification_repository_impl.dart +++ b/lib/data/repositories/notification/notification_repository_impl.dart @@ -28,9 +28,6 @@ class NotificationRepositoryImpl extends NotificationRepository { ); final json = await remoteDatasource.loadMyNotificationList(model); - - if (json == null) return ListableWrapper.none(); - final res = NotificationDataModel.fromJson(json); return ListableWrapper( diff --git a/lib/data/repositories/post/post_repository_impl.dart b/lib/data/repositories/post/post_repository_impl.dart index 89c3bdf1..0f6fce95 100644 --- a/lib/data/repositories/post/post_repository_impl.dart +++ b/lib/data/repositories/post/post_repository_impl.dart @@ -23,7 +23,7 @@ class PostRepositoryImpl implements PostRepository { @override Future> loadRecentPopularPostList() async { final json = await _remoteDatasource.getRecentPopularPostList(); - final data = json?.map((e) => PostRankModel.fromJson(e)).toList() ?? []; + final data = json.map((e) => PostRankModel.fromJson(e)).toList(); return data.map((e) => PostRankEntity.fromPostRankModel(e)).toList(); } @@ -121,7 +121,6 @@ class PostRepositoryImpl implements PostRepository { size: size, ); final json = await _remoteDatasource.search(model); - if (json == null) return ListableWrapper.none(); final data = PostDataModel.fromJson(json); return ListableWrapper( diff --git a/lib/data/repositories/rating/rating_repository_impl.dart b/lib/data/repositories/rating/rating_repository_impl.dart index 415a68e8..e40fe7ee 100644 --- a/lib/data/repositories/rating/rating_repository_impl.dart +++ b/lib/data/repositories/rating/rating_repository_impl.dart @@ -2,6 +2,7 @@ import 'package:ondo/data/datasource/rating/rating_remote_datasource.dart'; import 'package:ondo/data/models/base/request/base_list_request_model.dart'; import 'package:ondo/data/models/rating/request/rating_chat_request_model.dart'; import 'package:ondo/data/models/rating/response/rating_model.dart'; +import 'package:ondo/domain/entities/base/pageable_wrapper.dart'; import 'package:ondo/domain/entities/rating/rating_entity.dart'; import 'package:ondo/domain/repositories/rating/rating_repository.dart'; @@ -30,7 +31,7 @@ class RatingRepositoryImpl extends RatingRepository { } @override - Future> loadOtherRatingList( + Future> loadOtherRatingList( String userPublicId, int cursor, int size, @@ -38,32 +39,28 @@ class RatingRepositoryImpl extends RatingRepository { final model = ListRequestModelBaseCursor(size: size, cursor: cursor); final json = await remoteDatasource.getOtherRatingList(userPublicId, model); - //TODO : PageableModel 적용 - if (json.isEmpty) return []; + if (json.isEmpty) return PageableWrapper(pages: [], nextCursor: null, hasNext: false); final data = RatingDataModel.fromJson(json); - - return data.items - .map( - (e) => RatingEntity.fromRatingModel(e), - ) - .toList(); + return PageableWrapper( + pages: data.items.map((e) => RatingEntity.fromRatingModel(e)).toList(), + nextCursor: data.nextCursor, + hasNext: data.hasNext, + ); } @override - Future> loadMyRatingList(int cursor, int size) async { + Future> loadMyRatingList(int cursor, int size) async { final model = ListRequestModelBaseCursor(size: size, cursor: cursor); final json = await remoteDatasource.getMyRatingList(model); - //TODO : Pageable 객체로 관리 - if (json.isEmpty) return []; + if (json.isEmpty) return PageableWrapper(pages: [], nextCursor: null, hasNext: false); final data = RatingDataModel.fromJson(json); - - return data.items - .map( - (e) => RatingEntity.fromRatingModel(e), - ) - .toList(); + return PageableWrapper( + pages: data.items.map((e) => RatingEntity.fromRatingModel(e)).toList(), + nextCursor: data.nextCursor, + hasNext: data.hasNext, + ); } } diff --git a/lib/data/repositories/user/user_repository_impl.dart b/lib/data/repositories/user/user_repository_impl.dart index 6086bcb5..d15cde92 100644 --- a/lib/data/repositories/user/user_repository_impl.dart +++ b/lib/data/repositories/user/user_repository_impl.dart @@ -1,6 +1,7 @@ import 'package:ondo/data/datasource/user/user_remote_datasource.dart'; import 'package:ondo/data/models/user/request/user_search_request_model.dart'; import 'package:ondo/data/models/user/response/user_model.dart'; +import 'package:ondo/domain/entities/base/listable_wrapper.dart'; import 'package:ondo/domain/repositories/user/user_repository.dart'; import '../../models/base/request/base_list_request_model.dart'; @@ -11,7 +12,7 @@ class UserRepositoryImpl extends UserRepository { UserRepositoryImpl({required this.remoteDatasource}); @override - Future> search({ + Future> search({ String? keyword, String? major, List? interests, @@ -30,25 +31,33 @@ class UserRepositoryImpl extends UserRepository { final json = await remoteDatasource.search(model); - if (json == null) return []; - final res = UserDataModel.fromJson(json); - - return res.content; + return ListableWrapper( + content: res.content, + page: res.page, + size: res.size, + totalElements: res.totalElements, + totalPages: res.totalPages, + last: res.last, + ); } @override - Future> getRecommendUserList({ + Future> getRecommendUserList({ required int page, required int size, }) async { final model = ListRequestModelBasePage(size: size, page: page); final json = await remoteDatasource.loadRecommendUserList(model); - if (json != null) { - final res = UserDataModel.fromJson(json); - return res.content; - } - return []; + final res = UserDataModel.fromJson(json); + return ListableWrapper( + content: res.content, + page: res.page, + size: res.size, + totalElements: res.totalElements, + totalPages: res.totalPages, + last: res.last, + ); } } diff --git a/lib/domain/entities/post/post_entity.dart b/lib/domain/entities/post/post_entity.dart index ed3676d9..7e17e53e 100644 --- a/lib/domain/entities/post/post_entity.dart +++ b/lib/domain/entities/post/post_entity.dart @@ -28,17 +28,25 @@ class PostEntity { this.isBookmark = false, }); - PostEntity copyWith({int? likeCount, bool? isFavorite}) => PostEntity( + PostEntity copyWith({ + String? title, + List? tags, + int? likeCount, + int? bookmarkCount, + bool? isFavorite, + bool? isBookmark, + }) => PostEntity( postId: postId, - title: title, + title: title ?? this.title, authorName: authorName, - tags: tags, + tags: tags ?? this.tags, viewCount: viewCount, likeCount: likeCount ?? this.likeCount, commentCount: commentCount, - bookmarkCount: bookmarkCount, + bookmarkCount: bookmarkCount ?? this.bookmarkCount, createAt: createAt, isFavorite: isFavorite ?? this.isFavorite, + isBookmark: isBookmark ?? this.isBookmark, ); PostEntity.fromPostModel(PostModel model) diff --git a/lib/domain/entities/post/post_rank_entity.dart b/lib/domain/entities/post/post_rank_entity.dart index 3bfafb61..8afd8a47 100644 --- a/lib/domain/entities/post/post_rank_entity.dart +++ b/lib/domain/entities/post/post_rank_entity.dart @@ -15,9 +15,34 @@ class PostRankEntity extends PostEntity { required super.commentCount, required super.bookmarkCount, required super.createAt, + super.isFavorite, + super.isBookmark, }); PostRankEntity.fromPostRankModel(PostRankModel super.model) : rank = model.rank, super.fromPostModel(); + + @override + PostRankEntity copyWith({ + String? title, + List? tags, + int? likeCount, + int? bookmarkCount, + bool? isFavorite, + bool? isBookmark, + }) => PostRankEntity( + rank: rank, + postId: postId, + title: title ?? this.title, + authorName: authorName, + tags: tags ?? this.tags, + viewCount: viewCount, + likeCount: likeCount ?? this.likeCount, + commentCount: commentCount, + bookmarkCount: bookmarkCount ?? this.bookmarkCount, + createAt: createAt, + isFavorite: isFavorite ?? this.isFavorite, + isBookmark: isBookmark ?? this.isBookmark, + ); } diff --git a/lib/domain/entities/post/post_updated_event.dart b/lib/domain/entities/post/post_updated_event.dart new file mode 100644 index 00000000..a2d73bf6 --- /dev/null +++ b/lib/domain/entities/post/post_updated_event.dart @@ -0,0 +1,11 @@ +class PostUpdatedEvent { + final int postId; + final String title; + final List tags; + + PostUpdatedEvent({ + required this.postId, + required this.title, + required this.tags, + }); +} diff --git a/lib/domain/repositories/comment/comment_repository.dart b/lib/domain/repositories/comment/comment_repository.dart index fbcbb29c..71c1fc4a 100644 --- a/lib/domain/repositories/comment/comment_repository.dart +++ b/lib/domain/repositories/comment/comment_repository.dart @@ -1,7 +1,8 @@ import 'package:ondo/data/models/comment/response/comment_model.dart'; +import 'package:ondo/domain/entities/base/listable_wrapper.dart'; abstract class CommentRepository { - Future> getComments(int postId); + Future> getComments(int postId, {int page = 0, int size = 10}); Future createComment({ required int postId, diff --git a/lib/domain/repositories/rating/rating_repository.dart b/lib/domain/repositories/rating/rating_repository.dart index 4c75d4cd..1ef4e296 100644 --- a/lib/domain/repositories/rating/rating_repository.dart +++ b/lib/domain/repositories/rating/rating_repository.dart @@ -1,3 +1,4 @@ +import 'package:ondo/domain/entities/base/pageable_wrapper.dart'; import 'package:ondo/domain/entities/rating/rating_entity.dart'; abstract class RatingRepository { @@ -8,13 +9,13 @@ abstract class RatingRepository { List tags, ); - Future> loadOtherRatingList( + Future> loadOtherRatingList( String userPublicId, int cursor, int size, ); - Future> loadMyRatingList( + Future> loadMyRatingList( int cursor, int size, ); diff --git a/lib/domain/repositories/user/user_repository.dart b/lib/domain/repositories/user/user_repository.dart index 76fcd85b..a9234278 100644 --- a/lib/domain/repositories/user/user_repository.dart +++ b/lib/domain/repositories/user/user_repository.dart @@ -1,7 +1,8 @@ import 'package:ondo/data/models/user/response/user_model.dart'; +import 'package:ondo/domain/entities/base/listable_wrapper.dart'; abstract class UserRepository { - Future> search({ + Future> search({ String? keyword, String? major, List? interests, @@ -10,6 +11,5 @@ abstract class UserRepository { int? size, }); - Future> getRecommendUserList({required int page, required int size}); - + Future> getRecommendUserList({required int page, required int size}); } diff --git a/lib/domain/usecases/comment/get_comments_usecase.dart b/lib/domain/usecases/comment/get_comments_usecase.dart index 9047ca83..33e401ff 100644 --- a/lib/domain/usecases/comment/get_comments_usecase.dart +++ b/lib/domain/usecases/comment/get_comments_usecase.dart @@ -1,4 +1,5 @@ import 'package:ondo/data/models/comment/response/comment_model.dart'; +import 'package:ondo/domain/entities/base/listable_wrapper.dart'; import 'package:ondo/domain/repositories/comment/comment_repository.dart'; class GetCommentsUseCase { @@ -6,7 +7,7 @@ class GetCommentsUseCase { GetCommentsUseCase(this.repository); - Future> call(int postId) async { - return await repository.getComments(postId); + Future> call(int postId, {int page = 0, int size = 10}) async { + return await repository.getComments(postId, page: page, size: size); } } \ No newline at end of file diff --git a/lib/domain/usecases/rating/load_my_rating_list_use_case.dart b/lib/domain/usecases/rating/load_my_rating_list_use_case.dart index 2e2c332f..89d70975 100644 --- a/lib/domain/usecases/rating/load_my_rating_list_use_case.dart +++ b/lib/domain/usecases/rating/load_my_rating_list_use_case.dart @@ -1,3 +1,4 @@ +import 'package:ondo/domain/entities/base/pageable_wrapper.dart'; import 'package:ondo/domain/entities/rating/rating_entity.dart'; import 'package:ondo/domain/repositories/rating/rating_repository.dart'; @@ -6,8 +7,7 @@ class LoadMyRatingListUseCase { LoadMyRatingListUseCase({required this.repository}); - Future> call(int cursor, int size) async { - final res = await repository.loadMyRatingList(cursor, size); - return res; + Future> call(int cursor, int size) async { + return await repository.loadMyRatingList(cursor, size); } } diff --git a/lib/domain/usecases/rating/load_other_rating_list_use_case.dart b/lib/domain/usecases/rating/load_other_rating_list_use_case.dart index b05305fd..b293546a 100644 --- a/lib/domain/usecases/rating/load_other_rating_list_use_case.dart +++ b/lib/domain/usecases/rating/load_other_rating_list_use_case.dart @@ -1,3 +1,4 @@ +import 'package:ondo/domain/entities/base/pageable_wrapper.dart'; import 'package:ondo/domain/entities/rating/rating_entity.dart'; import 'package:ondo/domain/repositories/rating/rating_repository.dart'; @@ -6,12 +7,11 @@ class LoadOtherRatingListUseCase { LoadOtherRatingListUseCase({required this.repository}); - Future> call({ + Future> call({ required String userPublicId, required int cursor, required int size, }) async { - final res = await repository.loadOtherRatingList(userPublicId, cursor, size); - return res; + return await repository.loadOtherRatingList(userPublicId, cursor, size); } } diff --git a/lib/domain/usecases/user/load_recommend_users_use_case.dart b/lib/domain/usecases/user/load_recommend_users_use_case.dart index c880b64c..915a6930 100644 --- a/lib/domain/usecases/user/load_recommend_users_use_case.dart +++ b/lib/domain/usecases/user/load_recommend_users_use_case.dart @@ -1,3 +1,4 @@ +import 'package:ondo/domain/entities/base/listable_wrapper.dart'; import 'package:ondo/domain/entities/user/user_entity.dart'; import 'package:ondo/domain/repositories/user/user_repository.dart'; @@ -6,12 +7,15 @@ class LoadRecommendUsersUseCase { LoadRecommendUsersUseCase({required this.repository}); - //TODO : 정적 값 임시 삽입 - Future> call() async { - return (await repository.getRecommendUserList(page: 0, size: 10)) - .map( - (e) => UserEntity.fromUserModel(e), - ) - .toList(); + Future> call({int page = 0, int size = 10}) async { + final res = await repository.getRecommendUserList(page: page, size: size); + return ListableWrapper( + content: res.content.map(UserEntity.fromUserModel).toList(), + page: res.page, + size: res.size, + totalElements: res.totalElements, + totalPages: res.totalPages, + last: res.last, + ); } } diff --git a/lib/domain/usecases/user/user_search_use_case.dart b/lib/domain/usecases/user/user_search_use_case.dart index bb91e791..2a50db9f 100644 --- a/lib/domain/usecases/user/user_search_use_case.dart +++ b/lib/domain/usecases/user/user_search_use_case.dart @@ -1,3 +1,4 @@ +import 'package:ondo/domain/entities/base/listable_wrapper.dart'; import 'package:ondo/domain/entities/user/user_entity.dart'; import 'package:ondo/domain/repositories/user/user_repository.dart'; @@ -6,7 +7,7 @@ class UserSearchUseCase { UserSearchUseCase({required this.repository}); - Future> call({ + Future> call({ String? keyword, String? major, List? interests, @@ -22,6 +23,13 @@ class UserSearchUseCase { major: major, sort: sort, ); - return res.map((e) => UserEntity.fromUserModel(e)).toList(); + return ListableWrapper( + content: res.content.map((e) => UserEntity.fromUserModel(e)).toList(), + page: res.page, + size: res.size, + totalElements: res.totalElements, + totalPages: res.totalPages, + last: res.last, + ); } } diff --git a/lib/main.dart b/lib/main.dart index 4865a4c7..36700334 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -2,8 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:ondo/core/design_system/app_theme.dart'; import 'package:ondo/core/router/app_router.dart'; - -final GlobalKey navigatorKey = GlobalKey(); +import 'package:ondo/core/utils/app_snackbar.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -18,6 +17,7 @@ class MyApp extends StatelessWidget { Widget build(BuildContext context) { return MaterialApp.router( debugShowCheckedModeBanner: false, + scaffoldMessengerKey: scaffoldMessengerKey, theme: AppTheme.appTheme(), routerConfig: appRouter, ); diff --git a/lib/presentation/auth/signup/controllers/signup_email_input_controller.dart b/lib/presentation/auth/signup/controllers/signup_email_input_controller.dart index 0243752d..543dc586 100644 --- a/lib/presentation/auth/signup/controllers/signup_email_input_controller.dart +++ b/lib/presentation/auth/signup/controllers/signup_email_input_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:ondo/core/design_system/app_strings.dart'; +import 'package:ondo/core/utils/app_snackbar.dart'; import 'package:ondo/domain/usecases/auth/send_email_code_usecase.dart'; import 'package:ondo/presentation/auth/signup/controllers/signup_flow_controller.dart'; import 'package:ondo/presentation/auth/signup/states/input_validation_state.dart'; @@ -52,11 +53,7 @@ class SignupEmailInputController extends GetxController { emailState = InputValidationState.invalid; update(); - Get.snackbar( - AppStrings.emailSendFail, - AppStrings.retryMessage, - snackPosition: SnackPosition.BOTTOM, - ); + AppSnackbar.showError('${AppStrings.emailSendFail}: ${AppStrings.retryMessage}'); return false; } finally { isLoading = false; diff --git a/lib/presentation/auth/signup/controllers/signup_flow_controller.dart b/lib/presentation/auth/signup/controllers/signup_flow_controller.dart index 62b54a86..20192c74 100644 --- a/lib/presentation/auth/signup/controllers/signup_flow_controller.dart +++ b/lib/presentation/auth/signup/controllers/signup_flow_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; +import 'package:ondo/core/utils/app_snackbar.dart'; import 'package:ondo/data/models/auth/request/signup_request_model.dart'; import 'package:ondo/domain/usecases/auth/signup_usecase.dart'; @@ -106,6 +107,7 @@ class SignupFlowController extends GetxController { return true; } catch (e, s) { debugPrint('Failed to signup: $e\n$s'); + AppSnackbar.showError('회원가입에 실패했습니다. 다시 시도해 주세요.'); return false; } finally { isLoading.value = false; diff --git a/lib/presentation/chat/controllers/chat_main_controller.dart b/lib/presentation/chat/controllers/chat_main_controller.dart index 79315559..66e14cb6 100644 --- a/lib/presentation/chat/controllers/chat_main_controller.dart +++ b/lib/presentation/chat/controllers/chat_main_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:ondo/core/router/bindings/chat_room_binding.dart'; +import 'package:ondo/core/utils/app_snackbar.dart'; import 'package:ondo/domain/entities/chat/chat_entity.dart'; import 'package:ondo/domain/usecases/chat/block_chat_room_use_case.dart'; import 'package:ondo/domain/usecases/chat/cancel_block_chat_room_use_case.dart'; @@ -36,6 +37,7 @@ class ChatMainController extends GetxController { int _currentPage = 0; bool _hasMore = true; + final RxBool isLoading = false.obs; final RxBool isLoadingMore = false.obs; static const int _pageSize = 20; @@ -66,12 +68,23 @@ class ChatMainController extends GetxController { Future _loadChatRooms() async { _currentPage = 0; _hasMore = true; - final result = await loadChatRoomsUseCase.call(page: 0, size: _pageSize); - _cacheChatRoomList.assignAll(result); - viewChatRoomList.assignAll(_cacheChatRoomList); - if (result.length < _pageSize) _hasMore = false; + error.value = ''; + isLoading.value = true; + try { + final result = await loadChatRoomsUseCase.call(page: 0, size: _pageSize); + _cacheChatRoomList.assignAll(result); + viewChatRoomList.assignAll(_cacheChatRoomList); + if (result.length < _pageSize) _hasMore = false; + } catch (e) { + debugPrint('[ChatMainController] 채팅 목록 조회 실패 - error: $e'); + error.value = '채팅 목록을 불러오지 못했어요.'; + } finally { + isLoading.value = false; + } } + Future retryLoadChatRooms() => _loadChatRooms(); + Future loadMoreChatRooms() async { if (!_hasMore || isLoadingMore.value) return; @@ -90,7 +103,8 @@ class ChatMainController extends GetxController { _cacheChatRoomList.addAll(result); viewChatRoomList.assignAll(_cacheChatRoomList); } catch (e) { - // 페이지 번호 롤백 없이 다음 시도 가능하도록 유지 + debugPrint('[ChatMainController] 채팅 목록 추가 로드 실패 - error: $e'); + AppSnackbar.showError('채팅 목록을 불러오지 못했습니다. 다시 시도해 주세요.'); } finally { isLoadingMore.value = false; } diff --git a/lib/presentation/chat/controllers/chat_review_controller.dart b/lib/presentation/chat/controllers/chat_review_controller.dart index e2a31f21..0e250bfc 100644 --- a/lib/presentation/chat/controllers/chat_review_controller.dart +++ b/lib/presentation/chat/controllers/chat_review_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; +import 'package:ondo/core/utils/app_snackbar.dart'; import 'package:ondo/domain/usecases/chat/delete_chat_room_use_case.dart'; import 'package:ondo/domain/usecases/rating/rating_chat_room_use_case.dart'; import 'package:ondo/presentation/chat/states/chat_room_back_result.dart'; @@ -63,22 +64,14 @@ class ChatReviewController extends GetxController { try { final deleteSuccess = await _deleteChatRoom(); if (!deleteSuccess) { - Get.snackbar( - '오류', - '채팅방 종료에 실패했습니다. 다시 시도해 주세요.', - snackPosition: SnackPosition.BOTTOM, - ); + AppSnackbar.showError('채팅방 종료에 실패했습니다. 다시 시도해 주세요.'); return; } // 채팅방 삭제 성공 → 리뷰 전송 실패해도 이미 삭제됐으므로 무조건 화면 이탈 final ratingSuccess = await _ratingChatRoom(); if (!ratingSuccess) { - Get.snackbar( - '알림', - '리뷰 전송에 실패했습니다.', - snackPosition: SnackPosition.BOTTOM, - ); + AppSnackbar.showError('리뷰 전송에 실패했습니다.'); } Get.back( @@ -86,11 +79,7 @@ class ChatReviewController extends GetxController { result: ChatRoomQuitResult(success: true), ); } catch (e) { - Get.snackbar( - '오류', - '오류가 발생했습니다. 다시 시도해 주세요.', - snackPosition: SnackPosition.BOTTOM, - ); + AppSnackbar.showError('오류가 발생했습니다. 다시 시도해 주세요.'); } finally { isLoading.value = false; } diff --git a/lib/presentation/chat/screens/chat_main_screen.dart b/lib/presentation/chat/screens/chat_main_screen.dart index 6d2ca261..4474211a 100644 --- a/lib/presentation/chat/screens/chat_main_screen.dart +++ b/lib/presentation/chat/screens/chat_main_screen.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:ondo/core/design_system/app_colors.dart'; +import 'package:ondo/core/design_system/app_icon.dart'; import 'package:ondo/core/design_system/app_layout.dart'; +import 'package:ondo/core/design_system/components/app_empty_state.dart'; +import 'package:ondo/core/design_system/components/app_error_state.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'package:ondo/core/design_system/components/custom_tag_card.dart'; import 'package:ondo/presentation/chat/controllers/chat_main_controller.dart'; import 'package:ondo/core/ui/base/base_scaffold.dart'; @@ -64,28 +68,41 @@ class ChatList extends GetView { @override Widget build(BuildContext context) { - return Obx( - () { - final list = controller.viewChatRoomList; - final isLoadingMore = controller.isLoadingMore.value; - return ListView.separated( - controller: controller.scrollController, - itemCount: list.length + (isLoadingMore ? 1 : 0), - separatorBuilder: (context, index) => AppGap.v4, - itemBuilder: (context, index) { - if (index == list.length) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center(child: CircularProgressIndicator()), - ); - } - return ChatRoomCard( - onTap: () => controller.enterChatRoom(index), - chat: list[index], - ); - }, + return Obx(() { + final list = controller.viewChatRoomList; + final isLoading = controller.isLoading.value; + final isLoadingMore = controller.isLoadingMore.value; + final errorMsg = controller.error.value; + + if (isLoading && list.isEmpty) { + return const AppLoadingIndicator(); + } + if (errorMsg.isNotEmpty && list.isEmpty) { + return AppErrorState( + message: errorMsg, + onRetry: controller.retryLoadChatRooms, ); - }, - ); + } + if (list.isEmpty) { + return const AppEmptyState( + message: '아직 채팅이 없어요.\n관심 있는 유저에게 먼저 메시지를 보내보세요!', + icon: AppIcon.chatUnSelect, + ); + } + return ListView.separated( + controller: controller.scrollController, + itemCount: list.length + (isLoadingMore ? 1 : 0), + separatorBuilder: (context, index) => AppGap.v4, + itemBuilder: (context, index) { + if (index == list.length) { + return const AppBottomLoadingIndicator(); + } + return ChatRoomCard( + onTap: () => controller.enterChatRoom(index), + chat: list[index], + ); + }, + ); + }); } } diff --git a/lib/presentation/community/controllers/community_controller.dart b/lib/presentation/community/controllers/community_controller.dart index b1c64dd2..9e20180d 100644 --- a/lib/presentation/community/controllers/community_controller.dart +++ b/lib/presentation/community/controllers/community_controller.dart @@ -5,6 +5,7 @@ import 'package:ondo/domain/usecases/post/create_post_usecase.dart'; import 'package:ondo/domain/usecases/post/load_recommend_post_list_use_case.dart'; import 'package:ondo/domain/usecases/post/like_post_usecase.dart'; import 'package:ondo/domain/usecases/post/liked_post_use_case.dart'; +import 'package:ondo/domain/usecases/post/post_search_use_case.dart'; import 'package:ondo/domain/usecases/post/save_post_like_local_use_case.dart'; import 'package:ondo/domain/usecases/post/unlike_post_usecase.dart'; import 'package:ondo/domain/usecases/post/update_post_usecase.dart'; @@ -18,6 +19,7 @@ class CommunityController extends GetxController { final LoadRecommendPostListUseCase _getRecommendPostsUseCase; final SavePostLikeLocalUseCase _savePostLikeLocalUseCase; final LikedPostUseCase _likedPostUseCase; + final PostSearchUseCase _postSearchUseCase; CommunityController({ required LikePostUseCase likeUseCase, @@ -25,11 +27,13 @@ class CommunityController extends GetxController { required LoadRecommendPostListUseCase getRecommendPostsUseCase, required SavePostLikeLocalUseCase savePostLikeLocalUseCase, required LikedPostUseCase likedPostUseCase, + required PostSearchUseCase postSearchUseCase, }) : _likeUseCase = likeUseCase, _unlikeUseCase = unlikeUseCase, _getRecommendPostsUseCase = getRecommendPostsUseCase, _savePostLikeLocalUseCase = savePostLikeLocalUseCase, - _likedPostUseCase = likedPostUseCase; + _likedPostUseCase = likedPostUseCase, + _postSearchUseCase = postSearchUseCase; final RxSet viewTagList = {}.obs; final RxSet selectTagList = {}.obs; @@ -39,6 +43,7 @@ class CommunityController extends GetxController { final RxBool isLoading = false.obs; final RxBool isLastPage = false.obs; + final RxString errorMessage = ''.obs; int _currentPage = 0; @@ -50,11 +55,25 @@ class CommunityController extends GetxController { ever( Get.find().lastLikeEvent, - (event) { + (event) { if (event == null) return; _syncLike(event.postId, event.isLiked, event.likeCount); }, ); + ever( + Get.find().lastDeleteEvent, + (postId) { + if (postId == null) return; + removePost(postId); + }, + ); + ever( + Get.find().lastUpdateEvent, + (event) { + if (event == null) return; + _updatePostInList(event.postId, event.title, event.tags); + }, + ); } void _syncLike(int postId, bool isLiked, int likeCount) { @@ -64,7 +83,13 @@ class CommunityController extends GetxController { likeCount: likeCount, isFavorite: isLiked, ); - viewPostList.assignAll(_cachePostList); + } + final viewIndex = viewPostList.indexWhere((p) => p.postId == postId); + if (viewIndex != -1) { + viewPostList[viewIndex] = viewPostList[viewIndex].copyWith( + likeCount: likeCount, + isFavorite: isLiked, + ); } } @@ -80,6 +105,7 @@ class CommunityController extends GetxController { _cachePostList.clear(); viewPostList.clear(); isLastPage.value = false; + errorMessage.value = ''; } if (isLastPage.value) return; @@ -107,6 +133,7 @@ class CommunityController extends GetxController { _currentPage++; } catch (e) { debugPrint('[CommunityController] 게시물 조회 실패 - error: $e'); + errorMessage.value = '게시물을 불러오지 못했어요.'; } finally { isLoading.value = false; } @@ -132,6 +159,8 @@ class CommunityController extends GetxController { Future toggleLike(int postId, bool isLiked) async { _updatePostLikeInList(postId, isLiked ? 1 : -1, isLiked); + // 로컬 캐시를 먼저 저장해야 PostDetail 진입 시 _initIsFavorite()가 최신 상태를 읽는다 + await _savePostLikeLocalUseCase(postId, isLiked); try { if (isLiked) { @@ -139,23 +168,14 @@ class CommunityController extends GetxController { } else { await _unlikeUseCase(postId); } - await _savePostLikeLocalUseCase(postId, isLiked); } catch (e) { debugPrint('[CommunityController] 좋아요 토글 실패 - error: $e'); _updatePostLikeInList(postId, isLiked ? -1 : 1, !isLiked); + await _savePostLikeLocalUseCase(postId, !isLiked); } } void _updatePostLikeInList(int postId, int delta, bool isFavorite) { - final index = viewPostList.indexWhere((p) => p.postId == postId); - if (index != -1) { - viewPostList[index] = viewPostList[index].copyWith( - likeCount: viewPostList[index].likeCount + delta, - isFavorite: isFavorite, - ); - viewPostList.refresh(); - } - final cacheIndex = _cachePostList.indexWhere((p) => p.postId == postId); if (cacheIndex != -1) { _cachePostList[cacheIndex] = _cachePostList[cacheIndex].copyWith( @@ -163,6 +183,13 @@ class CommunityController extends GetxController { isFavorite: isFavorite, ); } + final viewIndex = viewPostList.indexWhere((p) => p.postId == postId); + if (viewIndex != -1) { + viewPostList[viewIndex] = viewPostList[viewIndex].copyWith( + likeCount: viewPostList[viewIndex].likeCount + delta, + isFavorite: isFavorite, + ); + } } void removePost(int postId) { @@ -170,20 +197,54 @@ class CommunityController extends GetxController { _cachePostList.removeWhere((p) => p.postId == postId); } - void searchPost(List searchList) { - final Set result = {}; - result.addAllIf( - searchList.isNotEmpty, - _cachePostList.where( - (post) => - searchList.any((search) => post.title.contains(search)) || - searchList.any((search) => post.authorName.contains(search)) || - searchList.any( - (search) => post.tags.any((tag) => tag.contains(search)), - ), - ), - ); - Get.find().updateResult(result); + void _updatePostInList(int postId, String title, List tags) { + final cacheIndex = _cachePostList.indexWhere((p) => p.postId == postId); + if (cacheIndex != -1) { + _cachePostList[cacheIndex] = _cachePostList[cacheIndex].copyWith(title: title, tags: tags); + } + final viewIndex = viewPostList.indexWhere((p) => p.postId == postId); + if (viewIndex != -1) { + viewPostList[viewIndex] = viewPostList[viewIndex].copyWith(title: title, tags: tags); + } + } + + Future search(String keyword) async { + final resultController = Get.find(); + resultController._prepareNewSearch(keyword); + resultController.isLoading.value = true; + try { + final result = await _postSearchUseCase( + keyword: keyword, + sort: 'latest', + page: 0, + size: 20, + ); + resultController._appendResults(result.content, result.last ?? true); + } catch (e) { + debugPrint('[CommunityController] 검색 실패 - error: $e'); + resultController.errorMessage.value = '검색에 실패했어요.'; + } finally { + resultController.isLoading.value = false; + } + } + + Future loadMoreSearchResults() async { + final resultController = Get.find(); + if (!resultController.canLoadMore) return; + resultController.isLoading.value = true; + try { + final result = await _postSearchUseCase( + keyword: resultController.currentQuery, + sort: 'latest', + page: resultController.nextPage, + size: 20, + ); + resultController._appendResults(result.content, result.last ?? true); + } catch (e) { + debugPrint('[CommunityController] 검색 추가 로드 실패 - error: $e'); + } finally { + resultController.isLoading.value = false; + } } void filterPostTag(String tag, bool isSelect) { @@ -211,9 +272,29 @@ class CommunityController extends GetxController { class CommunityResultController extends GetxController { final RxList viewPosts = [].obs; + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + final RxBool isLastPage = false.obs; + + String _currentQuery = ''; + int _currentPage = 0; + + String get currentQuery => _currentQuery; + int get nextPage => _currentPage; + bool get canLoadMore => !isLastPage.value && !isLoading.value; + + void _prepareNewSearch(String query) { + _currentQuery = query; + _currentPage = 0; + isLastPage.value = false; + errorMessage.value = ''; + viewPosts.clear(); + } - void updateResult(Iterable results) { - viewPosts.assignAll(results); + void _appendResults(List posts, bool isLast) { + viewPosts.addAll(posts); + isLastPage.value = isLast; + _currentPage++; } } diff --git a/lib/presentation/community/controllers/community_post_create_screen_controller.dart b/lib/presentation/community/controllers/community_post_create_screen_controller.dart index 92293d6f..b4174640 100644 --- a/lib/presentation/community/controllers/community_post_create_screen_controller.dart +++ b/lib/presentation/community/controllers/community_post_create_screen_controller.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:ondo/core/utils/app_snackbar.dart'; import 'package:ondo/data/models/post/request/post_create_request_model.dart'; import 'package:ondo/data/models/post/request/post_update_request_model.dart'; import 'package:ondo/domain/usecases/post/create_post_usecase.dart'; import 'package:ondo/domain/usecases/post/update_post_usecase.dart'; import 'package:ondo/presentation/community/controllers/community_controller.dart'; +import 'package:ondo/presentation/post/controllers/post_controller.dart'; class CommunityPostCreateController extends GetxController { final CreatePostUseCase _createUseCase; @@ -84,7 +86,8 @@ class CommunityPostCreateController extends GetxController { Get.find().refreshPosts(); Get.back(); } catch (e) { - errorMessage.value = e.toString(); + debugPrint('[CommunityPostCreateController] 게시물 작성 실패 - error: $e'); + AppSnackbar.showError('게시물 작성에 실패했습니다. 다시 시도해 주세요.'); } finally { isLoading.value = false; } @@ -103,9 +106,15 @@ class CommunityPostCreateController extends GetxController { tags: inputTags, ), ); + Get.find().notifyPostUpdated( + editPostId!, + titleController.text.trim(), + inputTags, + ); Get.back(); } catch (e) { - errorMessage.value = e.toString(); + debugPrint('[CommunityPostCreateController] 게시물 수정 실패 - error: $e'); + AppSnackbar.showError('게시물 수정에 실패했습니다. 다시 시도해 주세요.'); } finally { isLoading.value = false; } diff --git a/lib/presentation/community/screens/community_main_screen.dart b/lib/presentation/community/screens/community_main_screen.dart index 6c60a067..b5d40db4 100644 --- a/lib/presentation/community/screens/community_main_screen.dart +++ b/lib/presentation/community/screens/community_main_screen.dart @@ -21,18 +21,19 @@ class CommunityMainScreen extends GetView { backgroundColor: AppColors.background, body: MainTopSearchBar( pageState: SearchPageState.community, - mainPage: SingleChildScrollView( - child: Column( - children: [ - AppGap.v16, - CommunityFilterTagList(), - CommunityPostList(), - ], - ), + mainPage: Column( + children: [ + AppGap.v16, + CommunityFilterTagList(), + Expanded(child: CommunityPostList()), + ], ), resultPageBuilder: (state) { if (state.keyword.isEmpty) return null; - controller.searchPost([state.keyword]); + final keyword = state.keyword; + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.search(keyword); + }); return CommunitySearchScreen(); }, ), diff --git a/lib/presentation/community/screens/community_search_screen.dart b/lib/presentation/community/screens/community_search_screen.dart index 6453d8e3..995e89a9 100644 --- a/lib/presentation/community/screens/community_search_screen.dart +++ b/lib/presentation/community/screens/community_search_screen.dart @@ -1,7 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:ondo/core/design_system/app_colors.dart'; +import 'package:ondo/core/design_system/app_icon.dart'; import 'package:ondo/core/design_system/app_layout.dart'; +import 'package:ondo/core/design_system/app_text_styles.dart'; +import 'package:ondo/core/design_system/components/app_empty_state.dart'; +import 'package:ondo/core/design_system/components/app_error_state.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'package:ondo/core/ui/base/base_scaffold.dart'; import 'package:ondo/presentation/community/controllers/community_controller.dart'; import 'package:ondo/presentation/post/widgets/base_post_list.dart'; @@ -14,27 +19,68 @@ class CommunitySearchScreen extends GetView { Widget build(BuildContext context) { return BaseScaffold( backgroundColor: AppColors.background, - body: SingleChildScrollView( - child: Column( - children: [ - AppGap.v16, - Obx( - () => PostGridList( - title: "게시물 검색 결과", - list: controller.viewPosts - .map( - (post) => PostItem( - post: post, - isMy: true, - ), - ) - .toList(), + body: Obx(() { + final isLoading = controller.isLoading.value; + final error = controller.errorMessage.value; + final hasPosts = controller.viewPosts.isNotEmpty; + + if (!hasPosts) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: AppPadding.screenHorizontal, + child: Text( + "게시물 검색 결과", + style: AppTextStyles.titleSm16(), + ), + ), + AppGap.v16, + Expanded( + child: isLoading + ? const AppLoadingIndicator() + : error.isNotEmpty + ? AppErrorState( + message: error, + onRetry: () => Get.find() + .search(controller.currentQuery), + ) + : const AppEmptyState( + message: '검색된 게시물이 없어요.\n다른 키워드로 검색해 보세요.', + icon: AppIcon.communityUnSelect, + ), ), + ], + ); + } + + return NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && + notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 200) { + Get.find().loadMoreSearchResults(); + } + return false; + }, + child: SingleChildScrollView( + child: Column( + children: [ + AppGap.v16, + PostGridList( + title: "게시물 검색 결과", + isLoading: isLoading, + emptyMessage: '검색된 게시물이 없어요.\n다른 키워드로 검색해 보세요.', + list: controller.viewPosts + .map((post) => PostItem(post: post, isMy: true)) + .toList(), + ), + AppGap.v16, + ], ), - AppGap.v16, - ], - ), - ), + ), + ); + }), ); } } diff --git a/lib/presentation/community/widgets/community_post_list.dart b/lib/presentation/community/widgets/community_post_list.dart index 7d20757d..273d62bf 100644 --- a/lib/presentation/community/widgets/community_post_list.dart +++ b/lib/presentation/community/widgets/community_post_list.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:ondo/core/design_system/components/app_error_state.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'package:ondo/presentation/community/controllers/community_controller.dart'; import 'package:ondo/presentation/post/widgets/indicator_post_page_list.dart'; import 'package:ondo/presentation/post/widgets/post_item.dart'; @@ -9,12 +11,24 @@ class CommunityPostList extends GetView { @override Widget build(BuildContext context) { - return Obx( - () => IndicatorPostPageList( + return Obx(() { + if (controller.isLoading.value && controller.viewPostList.isEmpty) { + return const AppLoadingIndicator(); + } + if (controller.errorMessage.value.isNotEmpty && + controller.viewPostList.isEmpty) { + return AppErrorState( + message: controller.errorMessage.value, + onRetry: () => controller.loadRecommendPostList(refresh: true), + ); + } + return IndicatorPostPageList( title: controller.selectTagList.isEmpty ? "게시물 목록" : "태그 분류 결과", + isLoading: controller.isLoading.value, + emptyMessage: '아직 게시물이 없어요.\n첫 번째 게시물을 작성해 보세요!', items: List.generate( controller.viewPostList.length, - (index) { + (index) { final post = controller.viewPostList[index]; return PostItem( key: ValueKey(post.postId), @@ -27,7 +41,8 @@ class CommunityPostList extends GetView { }, ), itemFloors: 4, - ), - ); + onLastPage: () => controller.loadRecommendPostList(), + ); + }); } -} \ No newline at end of file +} diff --git a/lib/presentation/home/controllers/home_controller.dart b/lib/presentation/home/controllers/home_controller.dart index 4e60af4f..cc38f415 100644 --- a/lib/presentation/home/controllers/home_controller.dart +++ b/lib/presentation/home/controllers/home_controller.dart @@ -45,9 +45,20 @@ class HomeController extends GetxController with BaseHomeController { }); bool isLast = false; + int _currentPage = 0; + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + + final RxBool isLoadingRanks = false.obs; + final RxString rankErrorMessage = ''.obs; + + bool _isUserLast = false; + int _userCurrentPage = 0; + final RxBool isLoadingUsers = false.obs; + final RxString userErrorMessage = ''.obs; @override - void onInit() async { + void onInit() { super.onInit(); Get.put(searchResultController); _loadRecentPopularPostList(); @@ -56,11 +67,27 @@ class HomeController extends GetxController with BaseHomeController { ever( Get.find().lastLikeEvent, - (event) { + (event) { if (event == null) return; _syncLike(event.postId, event.isLiked, event.likeCount); }, ); + ever( + Get.find().lastDeleteEvent, + (postId) { + if (postId == null) return; + _removePostFromList(postId); + searchResultController._removePostFromList(postId); + }, + ); + ever( + Get.find().lastUpdateEvent, + (event) { + if (event == null) return; + _updatePostInList(event.postId, event.title, event.tags); + searchResultController._updatePostInList(event.postId, event.title, event.tags); + }, + ); } void _syncLike(int postId, bool isLiked, int likeCount) { @@ -70,15 +97,35 @@ class HomeController extends GetxController with BaseHomeController { likeCount: likeCount, isFavorite: isLiked, ); - viewPostList.assignAll(_cachePostList); } + final viewIndex = viewPostList.indexWhere((p) => p.postId == postId); + if (viewIndex != -1) { + viewPostList[viewIndex] = viewPostList[viewIndex].copyWith( + likeCount: likeCount, + isFavorite: isLiked, + ); + } + _updateRankPostLikeInList(postId, isLiked, likeCount: likeCount); } Future _loadRecentPopularPostList() async { - final result = await loadRecentPopularPostListUseCase(); - if (result.isEmpty) return; - recentPopularPostList.assignAll(result); - recentPopularPostList.sort((a, b) => a.rank - b.rank); + isLoadingRanks.value = true; + rankErrorMessage.value = ''; + try { + final result = await loadRecentPopularPostListUseCase(); + if (result.isNotEmpty) result.sort((a, b) => a.rank - b.rank); + final applied = await Future.wait( + result.map((post) async => post.copyWith( + isFavorite: await likedPostUseCase(post.postId), + )), + ); + recentPopularPostList.assignAll(applied); + } catch (e) { + debugPrint('[HomeController] 인기 게시물 조회 실패 - error: $e'); + rankErrorMessage.value = '인기 게시물을 불러오지 못했어요.'; + } finally { + isLoadingRanks.value = false; + } } Future _loadRecommendPostList({ @@ -86,27 +133,54 @@ class HomeController extends GetxController with BaseHomeController { int size = 20, }) async { if (refresh) { + _currentPage = 0; _cachePostList.clear(); isLast = false; + errorMessage.value = ''; } - if (isLast) return; + if (isLast || isLoading.value) return; - final result = await loadRecommendPostsUseCase.call( - page: _cachePostList.length ~/ size, - size: size, - ); - isLast = result.last ?? true; + isLoading.value = true; + try { + final result = await loadRecommendPostsUseCase.call( + page: _currentPage, + size: size, + ); - final applied = await Future.wait( - result.content.map((post) async { - return post.copyWith( - isFavorite: await likedPostUseCase(post.postId), - ); - }), - ); + final applied = await Future.wait( + result.content.map((post) async { + return post.copyWith( + isFavorite: await likedPostUseCase(post.postId), + ); + }), + ); + + _cachePostList.addAll(applied); + viewPostList.assignAll(_cachePostList); + isLast = result.last ?? true; + _currentPage++; + } catch (e) { + debugPrint('[HomeController] 추천 게시물 조회 실패 - error: $e'); + errorMessage.value = '게시물을 불러오지 못했어요.'; + } finally { + isLoading.value = false; + } + } + + Future loadMorePosts() => _loadRecommendPostList(); - _cachePostList.addAll(applied); + Future retryLoadRanks() => _loadRecentPopularPostList(); + + void _removePostFromList(int postId) { + _cachePostList.removeWhere((p) => p.postId == postId); + viewPostList.assignAll(_cachePostList); + } + + void _updatePostInList(int postId, String title, List tags) { + final index = _cachePostList.indexWhere((p) => p.postId == postId); + if (index == -1) return; + _cachePostList[index] = _cachePostList[index].copyWith(title: title, tags: tags); viewPostList.assignAll(_cachePostList); } @@ -117,6 +191,9 @@ class HomeController extends GetxController with BaseHomeController { Future toggleLike(int postId, bool isLiked) async { _updatePostLikeInList(postId, isLiked); + _updateRankPostLikeInList(postId, isLiked); + // 로컬 캐시를 먼저 저장해야 PostDetail 진입 시 _initIsFavorite()가 최신 상태를 읽는다 + await savePostLikeLocalUseCase(postId, isLiked); try { if (isLiked) { @@ -124,10 +201,11 @@ class HomeController extends GetxController with BaseHomeController { } else { await unlikePostUseCase(postId); } - await savePostLikeLocalUseCase(postId, isLiked); } catch (e) { debugPrint('[HomeController] 좋아요 토글 실패 - error: $e'); _updatePostLikeInList(postId, !isLiked); + _updateRankPostLikeInList(postId, !isLiked); + await savePostLikeLocalUseCase(postId, !isLiked); } } @@ -142,38 +220,143 @@ class HomeController extends GetxController with BaseHomeController { viewPostList.assignAll(_cachePostList); } + void _updateRankPostLikeInList(int postId, bool isFavorite, {int? likeCount}) { + final index = recentPopularPostList.indexWhere((p) => p.postId == postId); + if (index == -1) return; + final current = recentPopularPostList[index]; + recentPopularPostList[index] = current.copyWith( + likeCount: likeCount ?? (current.likeCount + (isFavorite ? 1 : -1)), + isFavorite: isFavorite, + ); + } + Future loadRecommendUsers() async { + _userCurrentPage = 0; + _isUserLast = false; _cacheProfileList.clear(); - _cacheProfileList.addAll(await loadRecommendUsersUseCase.call()); - viewUserList.assignAll(_cacheProfileList); + userErrorMessage.value = ''; + await _fetchRecommendUsers(); } - void search(String query) async { - final Set userRes = {}; + Future _fetchRecommendUsers() async { + if (_isUserLast || isLoadingUsers.value) return; - userRes.addAll( - await userSearchUseCase.call(keyword: query), - ); + isLoadingUsers.value = true; + try { + final result = await loadRecommendUsersUseCase.call( + page: _userCurrentPage, + size: 10, + ); + _cacheProfileList.addAll(result.content); + viewUserList.assignAll(_cacheProfileList); + _isUserLast = result.last ?? true; + _userCurrentPage++; + } catch (e) { + debugPrint('[HomeController] 추천 유저 조회 실패 - error: $e'); + userErrorMessage.value = '추천 커피챗 파트너를 불러오지 못했어요.'; + } finally { + isLoadingUsers.value = false; + } + } - final postResult = await postSearchUseCase( - keyword: query, - sort: "latest", - ); + Future loadMoreUsers() => _fetchRecommendUsers(); - searchResultController.updateResult( - postResult.content, - userRes, - ); + Future search(String query) async { + searchResultController._prepareNewSearch(query); + searchResultController.isLoading.value = true; + try { + final userResult = await userSearchUseCase.call( + keyword: query, + page: 0, + size: 20, + ); + final postResult = await postSearchUseCase( + keyword: query, + sort: "latest", + page: 0, + size: 20, + ); + searchResultController._appendResults( + posts: postResult.content, + users: userResult.content, + isLast: postResult.last ?? true, + ); + } catch (e) { + debugPrint('[HomeController] 검색 실패 - error: $e'); + searchResultController.errorMessage.value = '검색에 실패했어요.'; + } finally { + searchResultController.isLoading.value = false; + } + } + + Future loadMoreSearchResults() async { + if (!searchResultController.canLoadMore) return; + + searchResultController.isLoading.value = true; + try { + final postResult = await postSearchUseCase( + keyword: searchResultController.currentQuery, + sort: "latest", + page: searchResultController.nextPostPage, + size: 20, + ); + searchResultController._appendResults( + posts: postResult.content, + users: const [], + isLast: postResult.last ?? true, + ); + } catch (e) { + debugPrint('[HomeController] 검색 추가 로드 실패 - error: $e'); + } finally { + searchResultController.isLoading.value = false; + } } } -class HomeSearchResultController extends GetxController - with BaseHomeController { - void updateResult( - Iterable posts, - Iterable profiles, - ) { - viewUserList.assignAll(profiles); - viewPostList.assignAll(posts); +class HomeSearchResultController extends GetxController with BaseHomeController { + final List _cachePostList = []; + String _currentQuery = ''; + int _postCurrentPage = 0; + final RxBool isLastPage = false.obs; + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + + String get currentQuery => _currentQuery; + int get nextPostPage => _postCurrentPage; + bool get canLoadMore => !isLastPage.value && !isLoading.value; + + void _prepareNewSearch(String query) { + _currentQuery = query; + _postCurrentPage = 0; + isLastPage.value = false; + errorMessage.value = ''; + _cachePostList.clear(); + viewPostList.clear(); + viewUserList.clear(); + } + + + void _appendResults({ + required List posts, + required List users, + required bool isLast, + }) { + _cachePostList.addAll(posts); + viewPostList.assignAll(_cachePostList); + if (users.isNotEmpty) viewUserList.assignAll(users); + isLastPage.value = isLast; + _postCurrentPage++; + } + + void _removePostFromList(int postId) { + _cachePostList.removeWhere((p) => p.postId == postId); + viewPostList.assignAll(_cachePostList); + } + + void _updatePostInList(int postId, String title, List tags) { + final index = _cachePostList.indexWhere((p) => p.postId == postId); + if (index == -1) return; + _cachePostList[index] = _cachePostList[index].copyWith(title: title, tags: tags); + viewPostList.assignAll(_cachePostList); } } \ No newline at end of file diff --git a/lib/presentation/home/screens/home_screen.dart b/lib/presentation/home/screens/home_screen.dart index 2a5e9ae4..16a93530 100644 --- a/lib/presentation/home/screens/home_screen.dart +++ b/lib/presentation/home/screens/home_screen.dart @@ -21,12 +21,25 @@ class HomeScreen extends GetView { backgroundColor: AppColors.background, body: MainTopSearchBar( pageState: SearchPageState.home, - mainPage: SingleChildScrollView( - child: _body(), + mainPage: NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && + notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 200) { + controller.loadMorePosts(); + } + return false; + }, + child: SingleChildScrollView( + child: _body(), + ), ), resultPageBuilder: (state) { if (state.keyword.isEmpty) return null; - controller.search(state.keyword); + final keyword = state.keyword; + WidgetsBinding.instance.addPostFrameCallback((_) { + controller.search(keyword); + }); return HomeSearchScreen(); }, ), @@ -42,24 +55,33 @@ class HomeScreen extends GetView { HomeProfileList( title: "커피챗 추천", controller: controller, + onEndReached: controller.loadMoreUsers, + isLoadingMore: controller.isLoadingUsers, + emptyMessage: '추천 커피챗 파트너가 없어요.\n프로필을 채우면 더 많은 추천을 받을 수 있어요.', + errorMessage: controller.userErrorMessage, + onRetry: controller.loadRecommendUsers, ), AppGap.v16, Obx( - () => PostGridList( + () => PostGridList( title: "추천 게시물", + isLoading: controller.isLoading.value, + errorMessage: controller.errorMessage.value, + emptyMessage: '아직 추천 게시물이 없어요.\n게시물에 관심을 표현하면 맞춤 추천이 시작돼요.', + onRetry: () => controller.refresh(), list: controller.viewPostList .map( (post) => PostItem( - post: post, - isMy: true, - heartAction: (isFavorite, total) { - controller.toggleLike(post.postId, isFavorite); - }, - bookmarkAction: (isBookmark, total) {}, - ), - ) + post: post, + isMy: true, + heartAction: (isFavorite, total) { + controller.toggleLike(post.postId, isFavorite); + }, + bookmarkAction: (isBookmark, total) {}, + ), + ) .toList(), ), ), diff --git a/lib/presentation/home/screens/home_search_screen.dart b/lib/presentation/home/screens/home_search_screen.dart index 78fdd4e3..327e0349 100644 --- a/lib/presentation/home/screens/home_search_screen.dart +++ b/lib/presentation/home/screens/home_search_screen.dart @@ -1,14 +1,18 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:ondo/core/design_system/app_colors.dart'; +import 'package:ondo/core/design_system/app_icon.dart'; +import 'package:ondo/core/design_system/app_layout.dart'; +import 'package:ondo/core/design_system/app_text_styles.dart'; +import 'package:ondo/core/design_system/components/app_empty_state.dart'; +import 'package:ondo/core/design_system/components/app_error_state.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'package:ondo/core/ui/base/base_scaffold.dart'; import 'package:ondo/presentation/home/controllers/home_controller.dart'; import 'package:ondo/presentation/home/widgets/home_recommend_profile_list.dart'; import 'package:ondo/presentation/post/widgets/base_post_list.dart'; import 'package:ondo/presentation/post/widgets/post_item.dart'; -import '../../../core/design_system/app_layout.dart'; - class HomeSearchScreen extends GetView { const HomeSearchScreen({super.key}); @@ -16,33 +20,123 @@ class HomeSearchScreen extends GetView { Widget build(BuildContext context) { return BaseScaffold( backgroundColor: AppColors.background, - body: SingleChildScrollView( - child: Column( - children: [ - AppGap.v16, - HomeProfileList( - title: "프로필 검색 결과", - controller: controller, - ), + body: Obx(() { + final isLoading = controller.isLoading.value; + final error = controller.errorMessage.value; + final hasPosts = controller.viewPostList.isNotEmpty; + final hasUsers = controller.viewUserList.isNotEmpty; + final hasData = hasPosts || hasUsers; - AppGap.v16, - Obx( - () => PostGridList( - title: "게시물 검색 결과", - list: controller.viewPostList - .map( - (post) => PostItem( - post: post, - isMy: true, - ), - ) - .toList(), + if (!hasData) { + return Column( + children: [ + Expanded( + child: _Section( + title: "프로필 검색 결과", + titleStyle: AppTextStyles.titleBold16(), + child: isLoading + ? const AppLoadingIndicator() + : error.isNotEmpty + ? AppErrorState( + message: error, + onRetry: () => Get.find() + .search(controller.currentQuery), + ) + : const AppEmptyState( + message: '검색된 유저가 없어요.\n다른 키워드로 검색해 보세요.', + icon: AppIcon.userUnSelect, + ), + ), + ), + Expanded( + child: _Section( + title: "게시물 검색 결과", + titleStyle: AppTextStyles.titleSm16(), + child: isLoading + ? const AppLoadingIndicator() + : error.isNotEmpty + ? AppErrorState( + message: error, + onRetry: () => Get.find() + .search(controller.currentQuery), + ) + : const AppEmptyState( + message: '검색된 게시물이 없어요.\n다른 키워드로 검색해 보세요.', + icon: AppIcon.communityUnSelect, + ), + ), ), + ], + ); + } + + return NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && + notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 200) { + Get.find().loadMoreSearchResults(); + } + return false; + }, + child: SingleChildScrollView( + child: Column( + children: [ + AppGap.v16, + HomeProfileList( + title: "프로필 검색 결과", + controller: controller, + isLoadingMore: controller.isLoading, + emptyMessage: '검색된 유저가 없어요.\n다른 키워드로 검색해 보세요.', + errorMessage: controller.errorMessage, + onRetry: () => Get.find() + .search(controller.currentQuery), + ), + AppGap.v16, + PostGridList( + title: "게시물 검색 결과", + isLoading: isLoading, + errorMessage: error, + emptyMessage: '검색된 게시물이 없어요.\n다른 키워드로 검색해 보세요.', + onRetry: () => Get.find() + .search(controller.currentQuery), + list: controller.viewPostList + .map((post) => PostItem(post: post, isMy: true)) + .toList(), + ), + AppGap.v16, + ], ), - AppGap.v16, - ], + ), + ); + }), + ); + } +} + +class _Section extends StatelessWidget { + final String title; + final TextStyle titleStyle; + final Widget child; + + const _Section({ + required this.title, + required this.titleStyle, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: AppPadding.screenHorizontal, + child: Text(title, style: titleStyle), ), - ), + AppGap.v16, + Expanded(child: child), + ], ); } } diff --git a/lib/presentation/home/widgets/home_post_rank_item.dart b/lib/presentation/home/widgets/home_post_rank_item.dart index fd1ec89f..b6f94f74 100644 --- a/lib/presentation/home/widgets/home_post_rank_item.dart +++ b/lib/presentation/home/widgets/home_post_rank_item.dart @@ -36,6 +36,7 @@ class HomePostRankItem extends StatelessWidget { total: post.likeCount, activeColor: AppColors.red, action: heartAction, + initialIsSelected: post.isFavorite, iconSize: AppSpacing.s16, totalStyle: AppTextStyles.caption(), ), diff --git a/lib/presentation/home/widgets/home_post_rank_list.dart b/lib/presentation/home/widgets/home_post_rank_list.dart index 9eb810ca..dda98117 100644 --- a/lib/presentation/home/widgets/home_post_rank_list.dart +++ b/lib/presentation/home/widgets/home_post_rank_list.dart @@ -3,6 +3,10 @@ import 'package:get/get.dart'; import 'package:ondo/core/design_system/app_colors.dart'; import 'package:ondo/core/design_system/app_layout.dart'; import 'package:ondo/core/design_system/app_text_styles.dart'; +import 'package:ondo/core/design_system/app_icon.dart'; +import 'package:ondo/core/design_system/components/app_empty_state.dart'; +import 'package:ondo/core/design_system/components/app_error_state.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'package:ondo/presentation/home/controllers/home_controller.dart'; import 'package:ondo/presentation/home/widgets/home_post_rank_item.dart'; import 'package:ondo/presentation/post/controllers/post_controller.dart'; @@ -56,9 +60,7 @@ class _HomePostRankListState extends State { ), AppGap.v16, Expanded(child: _postList()), - AppGap.v16, _indicator(), - AppGap.v16, ], ), ); @@ -66,7 +68,24 @@ class _HomePostRankListState extends State { Widget _postList() { return Obx(() { + if (_mainController.isLoadingRanks.value) { + return const AppLoadingIndicator(); + } + if (_mainController.rankErrorMessage.value.isNotEmpty) { + return AppErrorState( + message: _mainController.rankErrorMessage.value, + onRetry: () => _mainController.retryLoadRanks(), + ); + } + final ranks = _mainController.recentPopularPostList; + if (ranks.isEmpty) { + return const AppEmptyState( + message: '현재 집계된 인기 게시물이 없어요.', + icon: AppIcon.homeUnSelect, + ); + } + final pageCount = (ranks.length / 3).ceil(); return PageView.builder( controller: _pageController, @@ -95,7 +114,10 @@ class _HomePostRankListState extends State { ); }, heartAction: (isFavorite, total) { - //TODO : model 정의되면 setter 적용 + _mainController.toggleLike( + ranks[currentItemIndex].postId, + isFavorite, + ); }, ), ) @@ -109,19 +131,28 @@ class _HomePostRankListState extends State { } Widget _indicator() { - return ValueListenableBuilder( - valueListenable: curIndex, - builder: (context, value, _) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: AppSpacing.s6, - children: List.generate( - 3, - (index) => _indicatorIcon(index == value % 3), - ), - ); - }, - ); + return Obx(() { + final pageCount = + (_mainController.recentPopularPostList.length / 3).ceil(); + if (pageCount <= 1) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: ValueListenableBuilder( + valueListenable: curIndex, + builder: (context, value, _) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: AppSpacing.s6, + children: List.generate( + pageCount, + (index) => _indicatorIcon(index == value % pageCount), + ), + ); + }, + ), + ); + }); } Widget _indicatorIcon(bool isFocus) { diff --git a/lib/presentation/home/widgets/home_recommend_profile_list.dart b/lib/presentation/home/widgets/home_recommend_profile_list.dart index e7f84cdc..c3fe37ba 100644 --- a/lib/presentation/home/widgets/home_recommend_profile_list.dart +++ b/lib/presentation/home/widgets/home_recommend_profile_list.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:ondo/core/design_system/app_layout.dart'; import 'package:ondo/core/design_system/app_text_styles.dart'; +import 'package:ondo/core/design_system/app_icon.dart'; +import 'package:ondo/core/design_system/components/app_empty_state.dart'; +import 'package:ondo/core/design_system/components/app_error_state.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'package:ondo/presentation/home/controllers/base_home_controller.dart'; import 'package:ondo/presentation/home/widgets/home_profile_card.dart'; @@ -10,10 +14,20 @@ class HomeProfileList extends StatelessWidget { super.key, required this.title, required this.controller, + this.onEndReached, + this.isLoadingMore, + this.emptyMessage = '추천 유저가 없어요.', + this.errorMessage, + this.onRetry, }); final String title; final BaseHomeController controller; + final VoidCallback? onEndReached; + final RxBool? isLoadingMore; + final String emptyMessage; + final RxString? errorMessage; + final VoidCallback? onRetry; @override Widget build(BuildContext context) { @@ -37,23 +51,48 @@ class HomeProfileList extends StatelessWidget { } Widget _profileList() { - return Obx( - () => ListView.separated( - padding: AppPadding.screenHorizontal, - scrollDirection: Axis.horizontal, - separatorBuilder: (context, index) => AppGap.h16, - itemBuilder: (context, index) { - final chat = controller.viewUserList[index]; + return Obx(() { + final users = controller.viewUserList; + final loadingMore = isLoadingMore?.value ?? false; + final error = errorMessage?.value ?? ''; - return HomeProfileCard( - publicId: chat.publicId, - skill: chat.interests.firstOrNull ?? '', - name: chat.displayName, - rating: chat.ratingCount, - ); + if (loadingMore && users.isEmpty) { + return const AppLoadingIndicator(); + } + if (error.isNotEmpty && users.isEmpty) { + return AppErrorState(message: error, onRetry: onRetry); + } + if (users.isEmpty) { + return AppEmptyState( + message: emptyMessage, + icon: AppIcon.userUnSelect, + ); + } + return NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && + notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 100) { + onEndReached?.call(); + } + return false; }, - itemCount: controller.viewUserList.length, - ), - ); + child: ListView.separated( + padding: AppPadding.screenHorizontal, + scrollDirection: Axis.horizontal, + separatorBuilder: (context, index) => AppGap.h16, + itemBuilder: (context, index) { + final chat = users[index]; + return HomeProfileCard( + publicId: chat.publicId, + skill: chat.interests.firstOrNull ?? '', + name: chat.displayName, + rating: chat.ratingCount, + ); + }, + itemCount: users.length, + ), + ); + }); } } diff --git a/lib/presentation/notification/controllers/notification_controller.dart b/lib/presentation/notification/controllers/notification_controller.dart index dfed1153..c5c4c424 100644 --- a/lib/presentation/notification/controllers/notification_controller.dart +++ b/lib/presentation/notification/controllers/notification_controller.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; import 'package:ondo/core/design_system/components/custom_alert_dialog.dart'; +import 'package:ondo/core/utils/app_snackbar.dart'; import 'package:ondo/domain/entities/notification/notification_entity.dart'; import 'package:ondo/domain/usecases/notification/delete_all_read_notifications_use_case.dart'; import 'package:ondo/domain/usecases/notification/load_my_notification_list_use_case.dart'; @@ -89,6 +90,7 @@ class NotificationController extends GetxController { _currentPage++; } catch (e) { debugPrint('[NotificationController] 알림 추가 로드 실패 - error: $e'); + AppSnackbar.showError('알림을 불러오지 못했습니다. 다시 시도해 주세요.'); } finally { isLoadingMore.value = false; } @@ -128,6 +130,7 @@ class NotificationController extends GetxController { newNotificationCount.value = 0; } catch (e) { debugPrint('[NotificationController] 전체 읽음 처리 실패 - error: $e'); + AppSnackbar.showError('알림 읽음 처리에 실패했습니다. 다시 시도해 주세요.'); } } @@ -148,6 +151,7 @@ class NotificationController extends GetxController { } } catch (e) { debugPrint('[NotificationController] 알림 삭제 실패 - error: $e'); + AppSnackbar.showError('알림 삭제에 실패했습니다. 다시 시도해 주세요.'); } finally { if (context.mounted) context.pop(); } diff --git a/lib/presentation/notification/screens/notification_screen.dart b/lib/presentation/notification/screens/notification_screen.dart index 83fbdfee..5f526fa6 100644 --- a/lib/presentation/notification/screens/notification_screen.dart +++ b/lib/presentation/notification/screens/notification_screen.dart @@ -5,6 +5,7 @@ import 'package:ondo/core/design_system/app_colors.dart'; import 'package:ondo/core/design_system/app_icon.dart'; import 'package:ondo/core/design_system/app_layout.dart'; import 'package:ondo/core/design_system/app_text_styles.dart'; +import 'package:ondo/core/design_system/components/app_error_state.dart'; import 'package:ondo/core/design_system/components/custom_back_button.dart'; import 'package:ondo/core/ui/base/base_scaffold.dart'; import 'package:ondo/presentation/notification/controllers/notification_controller.dart'; @@ -183,25 +184,6 @@ class _ErrorView extends StatelessWidget { const _ErrorView({required this.message, required this.onRetry}); @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - message, - style: AppTextStyles.textMedium(textColor: AppColors.gray60), - ), - AppGap.v16, - TextButton( - onPressed: onRetry, - child: Text( - '다시 시도', - style: AppTextStyles.textMedium(textColor: AppColors.primary), - ), - ), - ], - ), - ); - } + Widget build(BuildContext context) => + AppErrorState(message: message, onRetry: onRetry); } diff --git a/lib/presentation/post/controllers/post_controller.dart b/lib/presentation/post/controllers/post_controller.dart index 8bcb6f62..15db8e9d 100644 --- a/lib/presentation/post/controllers/post_controller.dart +++ b/lib/presentation/post/controllers/post_controller.dart @@ -2,13 +2,18 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; import 'package:ondo/domain/entities/post/like_changed_event.dart'; +import 'package:ondo/domain/entities/post/post_updated_event.dart'; class PostController extends GetxController { - /// 상세 화면에서 변경된 좋아요 상태를 게시물 목록 화면들에 전파하기 위한 이벤트. - /// 게시물 목록 화면 컨트롤러(Home/Community 등)는 PostController를 통해 상세 - /// 화면으로 진입하므로, 좋아요 동기화 이벤트도 PostController에서 관리한다. + /// 상세 화면에서 변경된 좋아요 상태를 게시물 목록 화면들에 전파 final Rx lastLikeEvent = Rx(null); + /// 게시물 삭제 이벤트 — postId + final Rx lastDeleteEvent = Rx(null); + + /// 게시물 수정 이벤트 — 제목/태그가 바뀐 경우 + final Rx lastUpdateEvent = Rx(null); + void updateLikeState(int postId, bool isLiked, int likeCount) { lastLikeEvent.value = LikeChangedEvent( postId: postId, @@ -17,11 +22,23 @@ class PostController extends GetxController { ); } + void notifyPostDeleted(int postId) { + lastDeleteEvent.value = postId; + } + + void notifyPostUpdated(int postId, String title, List tags) { + lastUpdateEvent.value = PostUpdatedEvent( + postId: postId, + title: title, + tags: tags, + ); + } + Future?> enterPostDetail( - BuildContext context, - bool isMy, - int postId, - ) async { + BuildContext context, + bool isMy, + int postId, + ) async { final result = await context.push>( '/post/$postId', ); diff --git a/lib/presentation/post/controllers/post_view_controller.dart b/lib/presentation/post/controllers/post_view_controller.dart index ea6de5b1..cab1e199 100644 --- a/lib/presentation/post/controllers/post_view_controller.dart +++ b/lib/presentation/post/controllers/post_view_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:ondo/core/constants/report_type.dart'; +import 'package:ondo/core/utils/app_snackbar.dart'; import 'package:ondo/core/design_system/components/custom_alert_dialog.dart'; import 'package:ondo/core/design_system/components/custom_report_dialog.dart'; import 'package:ondo/domain/entities/comment/comment_entity.dart'; @@ -91,6 +92,10 @@ class PostViewController extends GetxController { final RxInt commentCount = 0.obs; final RxList comments = [].obs; + final List _commentCache = []; + int _commentPage = 0; + bool _commentIsLast = false; + final RxInt scrollToLastCommentTrigger = 0.obs; late final TextEditingController commentController; @@ -102,6 +107,11 @@ class PostViewController extends GetxController { commentController = TextEditingController(); _initPostState(); fetchComments(); + // 수정 완료 이벤트 수신 → 이 게시물이면 상세 재조회 + ever(Get.find().lastUpdateEvent, (event) { + if (event == null || event.postId != postId) return; + fetchPostDetail(postId); + }); } Future _initPostState() async { @@ -157,13 +167,47 @@ class PostViewController extends GetxController { Future fetchComments() async { + _commentPage = 0; + _commentIsLast = false; + _commentCache.clear(); + await loadMoreComments(); + } + + /// 작성/삭제 후 전체 재로드: 단일 요청으로 기존 + 신규 댓글까지 한 번에 가져옴 + Future _refetchAllComments() async { + final pagesToFetch = _commentPage + 1; + _commentPage = 0; + _commentIsLast = false; + _commentCache.clear(); + isCommentsLoading.value = true; + try { + final result = await _getCommentsUseCase(postId, page: 0, size: pagesToFetch * 10); + _commentCache.addAll( + result.content.map((e) => e.toEntity(currentUserId: null)), + ); + comments.assignAll(_commentCache); + _commentIsLast = result.last ?? true; + _commentPage = pagesToFetch; + } catch (e) { + debugPrint('[PostViewController] 댓글 재조회 실패 - error: $e'); + } finally { + isCommentsLoading.value = false; + } + } + + Future loadMoreComments() async { + if (_commentIsLast || isCommentsLoading.value) return; + isCommentsLoading.value = true; try { - final result = await _getCommentsUseCase(postId); - comments.assignAll( - result.map((e) => e.toEntity(currentUserId: null)), + final result = await _getCommentsUseCase(postId, page: _commentPage, size: 10); + _commentCache.addAll( + result.content.map((e) => e.toEntity(currentUserId: null)), ); + comments.assignAll(_commentCache); + _commentIsLast = result.last ?? true; + _commentPage++; } catch (e) { debugPrint('[PostViewController] 댓글 조회 실패 - error: $e'); errorMessage.value = e.toString(); @@ -259,11 +303,12 @@ class PostViewController extends GetxController { try { await _deletePostUseCase(postId); + Get.find().notifyPostDeleted(postId); Get.back(); Get.back(result: {'postId': postId, 'deleted': true}); } catch (e) { debugPrint('[PostViewController] 게시물 삭제 실패 - error: $e'); - errorMessage.value = e.toString(); + AppSnackbar.showError('게시물 삭제에 실패했습니다. 다시 시도해 주세요.'); } finally { isLoading.value = false; } @@ -280,20 +325,23 @@ class PostViewController extends GetxController { try { await _createCommentUseCase(postId: postId, content: content); commentController.clear(); - await fetchComments(); + await _refetchAllComments(); commentCount.value = comments.length; + scrollToLastCommentTrigger.value++; } catch (e) { debugPrint('[PostViewController] 댓글 작성 실패 - error: $e'); + AppSnackbar.showError('댓글 작성에 실패했습니다. 다시 시도해 주세요.'); } } Future deleteComment(CommentEntity comment) async { try { await _deleteCommentUseCase(comment.id); - await fetchComments(); + await _refetchAllComments(); commentCount.value = comments.length; } catch (e) { debugPrint('[PostViewController] 댓글 삭제 실패 - error: $e'); + AppSnackbar.showError('댓글 삭제에 실패했습니다. 다시 시도해 주세요.'); } } @@ -322,7 +370,7 @@ class PostViewController extends GetxController { void editPost() { Get.delete(force: true); Get.lazyPut( - () => CommunityPostCreateController( + () => CommunityPostCreateController( createUseCase: Get.find(), updateUseCase: Get.find(), isEditMode: true, diff --git a/lib/presentation/post/screens/post_detail_screen.dart b/lib/presentation/post/screens/post_detail_screen.dart index 076e659f..e3469109 100644 --- a/lib/presentation/post/screens/post_detail_screen.dart +++ b/lib/presentation/post/screens/post_detail_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; import 'package:ondo/core/design_system/app_layout.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'package:ondo/core/design_system/components/custom_back_button.dart'; import 'package:ondo/core/ui/base/base_scaffold.dart'; import 'package:ondo/presentation/post/controllers/post_view_controller.dart'; @@ -46,16 +47,21 @@ class _PostDetailScreenState extends State { if (!didPop) _onBack(); }, child: BaseScaffold( - body: SingleChildScrollView( - child: Column( - children: [ - ..._top(), - AppGap.v16, - _body(), - RelatedPostList(), - ], - ), - ), + body: Obx(() { + if (controller.isLoading.value && controller.post.value == null) { + return const AppLoadingIndicator(); + } + return SingleChildScrollView( + child: Column( + children: [ + ..._top(), + AppGap.v16, + _body(), + RelatedPostList(), + ], + ), + ); + }), ), ); } diff --git a/lib/presentation/post/widgets/base_post_list.dart b/lib/presentation/post/widgets/base_post_list.dart index 58fc23b8..057ee007 100644 --- a/lib/presentation/post/widgets/base_post_list.dart +++ b/lib/presentation/post/widgets/base_post_list.dart @@ -2,6 +2,10 @@ import 'package:flutter/material.dart'; import 'package:ondo/core/design_system/app_colors.dart'; import 'package:ondo/core/design_system/app_layout.dart'; import 'package:ondo/core/design_system/app_text_styles.dart'; +import 'package:ondo/core/design_system/app_icon.dart'; +import 'package:ondo/core/design_system/components/app_empty_state.dart'; +import 'package:ondo/core/design_system/components/app_error_state.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'post_item.dart'; @@ -10,12 +14,20 @@ class PostGridList extends StatelessWidget { final String title; final List list; final bool scrollable; + final bool isLoading; + final String errorMessage; + final String emptyMessage; + final VoidCallback? onRetry; const PostGridList({ super.key, required this.title, this.scrollable = false, required this.list, + this.isLoading = false, + this.errorMessage = '', + this.emptyMessage = '게시물이 없어요.', + this.onRetry, }); @override @@ -30,19 +42,47 @@ class PostGridList extends StatelessWidget { style: AppTextStyles.titleSm16(textColor: AppColors.gray90), ), AppGap.v16, - GridView( - shrinkWrap: true, - physics: scrollable ? null : NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: AppSpacing.s16, - crossAxisSpacing: AppSpacing.s16, - childAspectRatio: 3 / 2, - ), - children: list, - ), + _content(), ], ), ); } + + Widget _content() { + if (isLoading && list.isEmpty) { + return const SizedBox(height: 160, child: AppLoadingIndicator()); + } + if (errorMessage.isNotEmpty && list.isEmpty) { + return SizedBox( + height: 160, + child: AppErrorState(message: errorMessage, onRetry: onRetry), + ); + } + if (list.isEmpty) { + return SizedBox( + height: 160, + child: AppEmptyState( + message: emptyMessage, + icon: AppIcon.communityUnSelect, + ), + ); + } + return Column( + children: [ + GridView( + shrinkWrap: true, + physics: + scrollable ? null : const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: AppSpacing.s16, + crossAxisSpacing: AppSpacing.s16, + childAspectRatio: 3 / 2, + ), + children: list, + ), + if (isLoading) const AppBottomLoadingIndicator(), + ], + ); + } } diff --git a/lib/presentation/post/widgets/indicator_post_page_list.dart b/lib/presentation/post/widgets/indicator_post_page_list.dart index 791c5a05..5266f99d 100644 --- a/lib/presentation/post/widgets/indicator_post_page_list.dart +++ b/lib/presentation/post/widgets/indicator_post_page_list.dart @@ -4,6 +4,9 @@ import 'package:flutter/material.dart'; import 'package:ondo/core/design_system/app_colors.dart'; import 'package:ondo/core/design_system/app_layout.dart'; import 'package:ondo/core/design_system/app_text_styles.dart'; +import 'package:ondo/core/design_system/app_icon.dart'; +import 'package:ondo/core/design_system/components/app_empty_state.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'package:ondo/presentation/post/widgets/post_list_indicator.dart'; class IndicatorPostPageList extends StatefulWidget { @@ -14,6 +17,9 @@ class IndicatorPostPageList extends StatefulWidget { this.itemFloors = 2, this.scrollable = false, this.itemHeight = 127, + this.onLastPage, + this.isLoading = false, + this.emptyMessage = '게시물이 없어요.', }); final double itemHeight; @@ -21,6 +27,9 @@ class IndicatorPostPageList extends StatefulWidget { final List items; final int itemFloors; final bool scrollable; + final VoidCallback? onLastPage; + final bool isLoading; + final String emptyMessage; @override State createState() => _IndicatorPostPageListState(); @@ -41,12 +50,12 @@ class _IndicatorPostPageListState extends State { final list = widget.items; final pagePerItemCount = widget.itemFloors * 2; final pageCount = (list.length / pagePerItemCount).ceil(); + final listHeight = widget.itemHeight * widget.itemFloors; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ AppGap.v16, - //게시물 리스트 제목(ex 작성한 게시물 목록 Padding( padding: AppPadding.screenHorizontal, child: Text( @@ -55,53 +64,68 @@ class _IndicatorPostPageListState extends State { ), ), AppGap.v16, - //게시물 리스트 SizedBox( - height: widget.itemHeight * widget.itemFloors, - child: PageView.builder( - controller: _pageController, - itemCount: pageCount, - onPageChanged: (value) => setState(() => _currentPage = value), - itemBuilder: (context, int pageIndex) { - final start = pageIndex * pagePerItemCount; - final last = min((pageIndex + 1) * pagePerItemCount, list.length); - final slice = list.sublist(start, last); - return Padding( - padding: AppPadding.screenHorizontal, - child: GridView( - physics: widget.scrollable - ? null - : NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: AppSpacing.s16, - crossAxisSpacing: AppSpacing.s16, - mainAxisExtent: widget.itemHeight, - ), - children: List.generate( - slice.length, - (itemIndex) => slice[itemIndex], - ), - ), + height: listHeight, + child: _listContent(list, pageCount, pagePerItemCount), + ), + AppGap.v16, + if (list.isNotEmpty) + PostListIndicator( + currentPage: _currentPage, + totalPage: pageCount, + onTap: (value) { + _pageController.animateToPage( + value, + duration: const Duration(milliseconds: 300), + curve: Curves.ease, ); }, ), - ), - AppGap.v16, - //게시물 인디케이터 - PostListIndicator( - currentPage: _currentPage, - totalPage: pageCount, - onTap: (value) { - _pageController.animateToPage( - value, - duration: Duration(milliseconds: 300), - curve: Curves.ease, - ); - }, - ), AppGap.v16, ], ); } + + Widget _listContent(List list, int pageCount, int pagePerItemCount) { + if (widget.isLoading && list.isEmpty) { + return const AppLoadingIndicator(); + } + if (list.isEmpty) { + return AppEmptyState( + message: widget.emptyMessage, + icon: AppIcon.communityUnSelect, + ); + } + return PageView.builder( + controller: _pageController, + itemCount: pageCount, + onPageChanged: (value) { + setState(() => _currentPage = value); + if (value == pageCount - 1) widget.onLastPage?.call(); + }, + itemBuilder: (context, int pageIndex) { + final start = pageIndex * pagePerItemCount; + final last = min((pageIndex + 1) * pagePerItemCount, list.length); + final slice = list.sublist(start, last); + return Padding( + padding: AppPadding.screenHorizontal, + child: GridView( + physics: widget.scrollable + ? null + : const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: AppSpacing.s16, + crossAxisSpacing: AppSpacing.s16, + mainAxisExtent: widget.itemHeight, + ), + children: List.generate( + slice.length, + (itemIndex) => slice[itemIndex], + ), + ), + ); + }, + ); + } } diff --git a/lib/presentation/post/widgets/post_comment_list.dart b/lib/presentation/post/widgets/post_comment_list.dart index 7c59321c..ec7a6e1e 100644 --- a/lib/presentation/post/widgets/post_comment_list.dart +++ b/lib/presentation/post/widgets/post_comment_list.dart @@ -3,6 +3,9 @@ import 'package:get/get.dart'; import 'package:ondo/core/design_system/app_colors.dart'; import 'package:ondo/core/design_system/app_layout.dart'; import 'package:ondo/core/design_system/app_text_styles.dart'; +import 'package:ondo/core/design_system/app_icon.dart'; +import 'package:ondo/core/design_system/components/app_empty_state.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'package:ondo/presentation/post/controllers/post_view_controller.dart'; import 'package:ondo/presentation/post/widgets/post_comment_card.dart'; import 'package:ondo/presentation/post/widgets/post_comment_create_field.dart'; @@ -17,18 +20,33 @@ class PostCommentList extends StatefulWidget { class _PostCommentListState extends State { late final PageController _pageController; + late final Worker _scrollWorker; final PostViewController controller = Get.find(); final ValueNotifier curIndex = ValueNotifier(0); @override void initState() { - _pageController = PageController(); super.initState(); + _pageController = PageController(); + _scrollWorker = ever(controller.scrollToLastCommentTrigger, (_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_pageController.hasClients) return; + final pageCount = (controller.comments.length / 4).ceil(); + if (pageCount > 0) { + _pageController.animateToPage( + pageCount - 1, + duration: const Duration(milliseconds: 300), + curve: Curves.ease, + ); + } + }); + }); } @override void dispose() { + _scrollWorker.dispose(); _pageController.dispose(); super.dispose(); } @@ -41,6 +59,7 @@ class _PostCommentListState extends State { () { final list = controller.comments; final pageCount = (list.length / 4).ceil(); + final isLoadingComments = controller.isCommentsLoading.value; return Column( children: [ @@ -56,49 +75,24 @@ class _PostCommentListState extends State { ), AppGap.v16, Expanded( - child: PageView.builder( - controller: _pageController, - onPageChanged: (value) { - curIndex.value = value; - }, - itemCount: pageCount, - itemBuilder: (context, pIndex) => Column( - spacing: AppSpacing.s16, - mainAxisAlignment: MainAxisAlignment.start, - children: List.generate( - 4, - (iIndex) { - final itemIndex = (pIndex * 4) + iIndex; - if (itemIndex >= controller.comments.length) { - return Spacer(); - } - final comment = controller.comments[itemIndex]; - return Expanded( - child: PostCommentCard( - comment: comment, - ), - ); - }, - ), - ), - ), + child: _commentContent(list, pageCount, isLoadingComments), ), - AppGap.v16, - ValueListenableBuilder( - valueListenable: curIndex, - builder: (context, value, _) => PostListIndicator( - currentPage: value, - totalPage: pageCount, - onTap: (value) { - _pageController.animateToPage( - value, - duration: Duration(milliseconds: 300), - curve: Curves.ease, - ); - }, + if (list.isNotEmpty) + ValueListenableBuilder( + valueListenable: curIndex, + builder: (context, value, _) => PostListIndicator( + currentPage: value, + totalPage: pageCount, + onTap: (value) { + _pageController.animateToPage( + value, + duration: const Duration(milliseconds: 300), + curve: Curves.ease, + ); + }, + ), ), - ), AppGap.v16, ], ); @@ -107,6 +101,51 @@ class _PostCommentListState extends State { ); } + Widget _commentContent( + List list, + int pageCount, + bool isLoadingComments, + ) { + if (isLoadingComments && list.isEmpty) { + return const AppLoadingIndicator(); + } + if (list.isEmpty) { + return const AppEmptyState( + message: '아직 댓글이 없어요.\n첫 번째 댓글을 남겨보세요!', + icon: AppIcon.message, + ); + } + return PageView.builder( + controller: _pageController, + onPageChanged: (value) { + curIndex.value = value; + if (value == pageCount - 1) { + controller.loadMoreComments(); + } + }, + itemCount: pageCount, + itemBuilder: (context, pIndex) => Column( + spacing: AppSpacing.s16, + mainAxisAlignment: MainAxisAlignment.start, + children: List.generate( + 4, + (iIndex) { + final itemIndex = (pIndex * 4) + iIndex; + if (itemIndex >= controller.comments.length) { + return Spacer(); + } + final comment = controller.comments[itemIndex]; + return Expanded( + child: PostCommentCard( + comment: comment, + ), + ); + }, + ), + ), + ); + } + Widget _title() => Row( children: [ Text("댓글", style: AppTextStyles.titleSm16(textColor: AppColors.gray90)), diff --git a/lib/presentation/profile/controllers/edit_profile_controller.dart b/lib/presentation/profile/controllers/edit_profile_controller.dart index e621f3d6..ca08221f 100644 --- a/lib/presentation/profile/controllers/edit_profile_controller.dart +++ b/lib/presentation/profile/controllers/edit_profile_controller.dart @@ -1,6 +1,7 @@ import 'package:get/get.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:ondo/core/utils/app_snackbar.dart'; import 'package:ondo/data/datasource/media/media_remote_datasource.dart'; import 'package:ondo/data/datasource/user/profile_remote_datasource.dart'; import 'package:ondo/data/models/user/request/update_profile_request_model.dart'; @@ -108,11 +109,10 @@ class EditProfileController extends GetxController { // 3. MyProfileController 프로필 갱신 await Get.find().loadProfile(); - Get.snackbar('완료', '프로필이 수정되었습니다.'); return true; } catch (e, s) { debugPrint('Failed to save profile: $e\n$s'); - Get.snackbar('오류', '프로필 수정에 실패했습니다.'); + AppSnackbar.showError('프로필 수정에 실패했습니다.'); return false; } finally { isLoading.value = false; diff --git a/lib/presentation/profile/controllers/my_profile_controller.dart b/lib/presentation/profile/controllers/my_profile_controller.dart index 9a069ac9..80fc6b6c 100644 --- a/lib/presentation/profile/controllers/my_profile_controller.dart +++ b/lib/presentation/profile/controllers/my_profile_controller.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:ondo/core/utils/app_snackbar.dart'; import 'package:ondo/data/datasource/media/media_remote_datasource.dart'; import 'package:ondo/data/datasource/user/profile_remote_datasource.dart'; import 'package:ondo/data/models/user/response/user_profile_response_model.dart'; @@ -15,6 +16,9 @@ class MyProfileController extends GetxController { final List _cacheRatingList = []; final RxList viewRatingList = RxList(); + int _ratingCursor = 0; + bool _ratingHasNext = true; + final RxBool isLoadingRatings = false.obs; //TODO : 생성자 객체 할당 방식으로 변경 final ProfileRemoteDatasource profileRemoteDatasource = Get.find(); @@ -23,12 +27,15 @@ class MyProfileController extends GetxController { final DeleteAccountUseCase deleteAccountUseCase = Get.find(); final isLoading = false.obs; + final profileLoadFailed = false.obs; final Rxn profile = Rxn(); final profileImageUrl = RxnString(); + int _imageRefreshCount = 0; + @override void onInit() { - _initLoadMyRatingList(); + _resetAndLoadRatings(); loadProfile(); super.onInit(); } @@ -38,22 +45,48 @@ class MyProfileController extends GetxController { try { isLoading.value = true; + profileLoadFailed.value = false; + _imageRefreshCount = 0; profile.value = await profileRemoteDatasource.getMyProfile(); + } catch (e, s) { + debugPrint('Failed to load my profile: $e\n$s'); + profileLoadFailed.value = true; + return; + } finally { + isLoading.value = false; + } - // 프로필 이미지 URL 변환 - final imageKey = profile.value?.profileImageKey; - if (imageKey != null && imageKey.isNotEmpty) { + // 이미지 URL은 별도로 처리 — 실패해도 프로필 화면은 정상 표시 + final imageKey = profile.value?.profileImageKey; + if (imageKey != null && imageKey.isNotEmpty) { + try { profileImageUrl.value = await mediaRemoteDatasource.getDownloadUrl( key: imageKey, ); - } else { + } catch (e) { + debugPrint('Failed to load profile image URL: $e'); profileImageUrl.value = null; } - } catch (e, s) { - debugPrint('Failed to load my profile: $e\n$s'); - Get.snackbar('오류', '프로필을 불러오지 못했습니다.'); - } finally { - isLoading.value = false; + } else { + profileImageUrl.value = null; + } + } + + // presign URL 만료 시 재발급 — 최대 1회 재시도 + void refreshProfileImageUrl() { + if (_imageRefreshCount >= 1) return; + _imageRefreshCount++; + _fetchAndSetImageUrl(profile.value?.profileImageKey); + } + + Future _fetchAndSetImageUrl(String? imageKey) async { + if (imageKey == null || imageKey.isEmpty) return; + try { + profileImageUrl.value = await mediaRemoteDatasource.getDownloadUrl( + key: imageKey, + ); + } catch (e) { + debugPrint('Failed to refresh profile image URL: $e'); } } @@ -76,16 +109,34 @@ class MyProfileController extends GetxController { return true; } catch (e, s) { debugPrint('Failed to delete account: $e\n$s'); - Get.snackbar('오류', '회원탈퇴에 실패했습니다.'); + AppSnackbar.showError('회원탈퇴에 실패했습니다.'); return false; } finally { isLoading.value = false; } } - //TODO : 구조 변경 - Future _initLoadMyRatingList() async { - _cacheRatingList.assignAll(await loadMyRatingListUseCase.call(0, 20)); - viewRatingList.assignAll(_cacheRatingList); + void _resetAndLoadRatings() { + _ratingCursor = 0; + _ratingHasNext = true; + _cacheRatingList.clear(); + loadMoreRatings(); + } + + Future loadMoreRatings() async { + if (!_ratingHasNext || isLoadingRatings.value) return; + + isLoadingRatings.value = true; + try { + final result = await loadMyRatingListUseCase.call(_ratingCursor, 20); + _cacheRatingList.addAll(result.pages); + viewRatingList.assignAll(_cacheRatingList); + _ratingHasNext = result.hasNext; + _ratingCursor = result.nextCursor ?? _ratingCursor; + } catch (e) { + debugPrint('[MyProfileController] 평점 목록 조회 실패 - error: $e'); + } finally { + isLoadingRatings.value = false; + } } } diff --git a/lib/presentation/profile/controllers/other_profile_controller.dart b/lib/presentation/profile/controllers/other_profile_controller.dart index 76ae55bf..6cb22c8f 100644 --- a/lib/presentation/profile/controllers/other_profile_controller.dart +++ b/lib/presentation/profile/controllers/other_profile_controller.dart @@ -1,6 +1,7 @@ import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:ondo/core/router/bindings/chat_room_binding.dart'; +import 'package:ondo/core/utils/app_snackbar.dart'; import 'package:ondo/data/datasource/media/media_remote_datasource.dart'; import 'package:ondo/data/datasource/user/user_remote_datasource.dart'; import 'package:ondo/data/models/user/response/user_profile_response_model.dart'; @@ -17,15 +18,21 @@ class OtherProfileController extends GetxController { final List _cacheRatingList = []; final RxList viewRatingList = RxList(); + int _ratingCursor = 0; + bool _ratingHasNext = true; + final RxBool isLoadingRatings = false.obs; OtherProfileController({ required this.loadOtherRatingListUseCase, }); final isLoading = false.obs; + final profileLoadFailed = false.obs; final Rxn profile = Rxn(); final profileImageUrl = RxnString(); + int _imageRefreshCount = 0; + //TODO : userPublicId 생성자로 할당 String? userPublicId; @@ -36,33 +43,42 @@ class OtherProfileController extends GetxController { try { isLoading.value = true; + profileLoadFailed.value = false; + _imageRefreshCount = 0; // 새 프로필 로딩 시 기존 데이터 초기화 profile.value = null; profileImageUrl.value = null; userPublicId = publicId; profile.value = await userRemoteDatasource.getOtherProfile(publicId); + } catch (e, s) { + debugPrint('Failed to load other profile: $e\n$s'); + profileLoadFailed.value = true; + return; + } finally { + isLoading.value = false; + } - final imageKey = profile.value?.profileImageKey; - if (imageKey != null && imageKey.isNotEmpty) { + // 이미지 URL은 별도로 처리 — 실패해도 프로필 화면은 정상 표시 + final imageKey = profile.value?.profileImageKey; + if (imageKey != null && imageKey.isNotEmpty) { + try { profileImageUrl.value = await mediaRemoteDatasource.getDownloadUrl( key: imageKey, ); - } else { + } catch (e) { + debugPrint('Failed to load other profile image URL: $e'); profileImageUrl.value = null; } - } catch (e, s) { - debugPrint('Failed to load other profile: $e\n$s'); - Get.snackbar('오류', '프로필을 불러오지 못했습니다.'); - } finally { - isLoading.value = false; + } else { + profileImageUrl.value = null; } } Future createChatRoom(String publicId) async { if (isLoading.value) return; if (profile.value == null) { - Get.snackbar('오류', '상대방 프로필 정보를 불러오는 중입니다.'); + AppSnackbar.showError('상대방 프로필 정보를 불러오는 중입니다.'); return; } @@ -70,7 +86,7 @@ class OtherProfileController extends GetxController { isLoading.value = true; final roomId = await createChatRoomUseCase(publicId); if (roomId.isEmpty) { - Get.snackbar('오류', '채팅방을 생성하지 못했습니다.'); + AppSnackbar.showError('채팅방을 생성하지 못했습니다.'); return; } await Get.to( @@ -83,21 +99,58 @@ class OtherProfileController extends GetxController { ); } catch (e, s) { debugPrint('Failed to create chat room: $e\n$s'); - Get.snackbar('오류', '채팅방 생성 중 오류가 발생했습니다.'); + AppSnackbar.showError('채팅방 생성 중 오류가 발생했습니다.'); } finally { isLoading.value = false; } } - //TODO : 구조 변경 - Future loadRatingList(String userPublicId) async { - _cacheRatingList.assignAll( - await loadOtherRatingListUseCase.call( - userPublicId: userPublicId, - cursor: 0, + // presign URL 만료 시 재발급 — 최대 1회 재시도 + void refreshProfileImageUrl() { + if (_imageRefreshCount >= 1) return; + _imageRefreshCount++; + _fetchAndSetImageUrl(profile.value?.profileImageKey); + } + + Future _fetchAndSetImageUrl(String? imageKey) async { + if (imageKey == null || imageKey.isEmpty) return; + try { + profileImageUrl.value = await mediaRemoteDatasource.getDownloadUrl( + key: imageKey, + ); + } catch (e) { + debugPrint('Failed to refresh other profile image URL: $e'); + } + } + + Future loadRatingList(String userPublicId) async { + this.userPublicId = userPublicId; + _ratingCursor = 0; + _ratingHasNext = true; + _cacheRatingList.clear(); + viewRatingList.clear(); + await loadMoreRatings(); + } + + Future loadMoreRatings() async { + final publicId = userPublicId; + if (publicId == null || !_ratingHasNext || isLoadingRatings.value) return; + + isLoadingRatings.value = true; + try { + final result = await loadOtherRatingListUseCase.call( + userPublicId: publicId, + cursor: _ratingCursor, size: 20, - ), - ); - viewRatingList.assignAll(_cacheRatingList); + ); + _cacheRatingList.addAll(result.pages); + viewRatingList.assignAll(_cacheRatingList); + _ratingHasNext = result.hasNext; + _ratingCursor = result.nextCursor ?? _ratingCursor; + } catch (e) { + debugPrint('[OtherProfileController] 평점 목록 조회 실패 - error: $e'); + } finally { + isLoadingRatings.value = false; + } } } diff --git a/lib/presentation/profile/controllers/setting_controller.dart b/lib/presentation/profile/controllers/setting_controller.dart index 88093d06..e6fa4249 100644 --- a/lib/presentation/profile/controllers/setting_controller.dart +++ b/lib/presentation/profile/controllers/setting_controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:ondo/domain/usecases/notification/load_notification_setting_use_case.dart'; import 'package:ondo/domain/usecases/notification/update_notification_setting_use_case.dart'; @@ -22,10 +23,14 @@ class SettingController extends GetxController { } Future _loadSettings() async { - final settings = await loadNotificationSettingUseCase.call(); - isPush.value = settings['push'] ?? true; - isVibration.value = settings['vibration'] ?? false; - isSound.value = settings['sound'] ?? false; + try { + final settings = await loadNotificationSettingUseCase.call(); + isPush.value = settings['push'] ?? true; + isVibration.value = settings['vibration'] ?? false; + isSound.value = settings['sound'] ?? false; + } catch (e) { + debugPrint('[SettingController] 알림 설정 조회 실패 - error: $e'); + } } Future togglePush(bool value) async { diff --git a/lib/presentation/profile/screens/my_profile_screen.dart b/lib/presentation/profile/screens/my_profile_screen.dart index 3e4fe62d..507c815d 100644 --- a/lib/presentation/profile/screens/my_profile_screen.dart +++ b/lib/presentation/profile/screens/my_profile_screen.dart @@ -3,6 +3,8 @@ import 'package:get/get.dart'; import 'package:go_router/go_router.dart'; import 'package:ondo/core/design_system/app_colors.dart'; import 'package:ondo/core/design_system/app_layout.dart'; +import 'package:ondo/core/design_system/components/app_error_state.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'package:ondo/core/design_system/app_text_styles.dart'; import 'package:ondo/core/design_system/components/custom_popup_menu_button.dart'; import 'package:ondo/core/router/app_router.dart'; @@ -69,27 +71,46 @@ class MyProfileScreen extends GetView { backgroundColor: AppColors.background, body: Obx(() { if (controller.isLoading.value && controller.profile.value == null) { - return const Center(child: CircularProgressIndicator()); + return const AppLoadingIndicator(); + } + + if (controller.profileLoadFailed.value) { + return AppErrorState( + message: '프로필을 불러오지 못했어요.', + onRetry: controller.loadProfile, + ); } final profile = controller.profile.value; - return SingleChildScrollView( - child: Column( - children: [ - _buildUserIntroductionSession(context, profile), - ProfileRatingSession(), - ProfileIndicatorPostPageList( - title: '작성한 게시물 목록', - postItemCount: testPostItemList.length, - postItemList: testPostItemList, - ), - ProfileIndicatorPostPageList( - title: '즐겨찾기한 게시물', - postItemCount: testPostItemList.length, - postItemList: testPostItemList, - ), - ], + return NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && + notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 200) { + controller.loadMoreRatings(); + } + return false; + }, + child: SingleChildScrollView( + child: Column( + children: [ + _buildUserIntroductionSession(context, profile), + ProfileRatingSession(), + ProfileIndicatorPostPageList( + title: '작성한 게시물 목록', + postItemCount: testPostItemList.length, + postItemList: testPostItemList, + emptyMessage: '아직 작성한 게시물이 없어요.\n첫 게시물을 올려볼까요?', + ), + ProfileIndicatorPostPageList( + title: '즐겨찾기한 게시물', + postItemCount: testPostItemList.length, + postItemList: testPostItemList, + emptyMessage: '즐겨찾기한 게시물이 없어요.\n마음에 드는 게시물을 저장해 보세요.', + ), + ], + ), ), ); }), @@ -129,6 +150,7 @@ class MyProfileScreen extends GetView { Obx( () => UserProfileImage( imageUrl: controller.profileImageUrl.value, + onUrlExpired: controller.refreshProfileImageUrl, ), ), UserNameAndMajor( diff --git a/lib/presentation/profile/screens/other_profile_screen.dart b/lib/presentation/profile/screens/other_profile_screen.dart index cf97e5ec..de651101 100644 --- a/lib/presentation/profile/screens/other_profile_screen.dart +++ b/lib/presentation/profile/screens/other_profile_screen.dart @@ -5,6 +5,8 @@ import 'package:get/get.dart'; import 'package:ondo/core/constants/report_type.dart'; import 'package:ondo/core/design_system/app_colors.dart'; import 'package:ondo/core/design_system/app_layout.dart'; +import 'package:ondo/core/design_system/components/app_error_state.dart'; +import 'package:ondo/core/design_system/components/app_loading_indicator.dart'; import 'package:ondo/core/design_system/app_text_styles.dart'; import 'package:ondo/core/design_system/component_variants.dart'; import 'package:ondo/core/design_system/components/custom_back_button.dart'; @@ -79,7 +81,14 @@ class OtherProfileScreen extends GetView { backgroundColor: AppColors.background, body: Obx(() { if (controller.isLoading.value && controller.profile.value == null) { - return const Center(child: CircularProgressIndicator()); + return const AppLoadingIndicator(); + } + + if (controller.profileLoadFailed.value) { + return AppErrorState( + message: '프로필을 불러오지 못했어요.', + onRetry: () => controller.loadProfile(publicId), + ); } final profile = controller.profile.value; @@ -87,6 +96,15 @@ class OtherProfileScreen extends GetView { return Column( children: [ Expanded( + child: NotificationListener( + onNotification: (notification) { + if (notification.depth == 0 && + notification.metrics.pixels >= + notification.metrics.maxScrollExtent - 200) { + controller.loadMoreRatings(); + } + return false; + }, child: SingleChildScrollView( child: Column( children: [ @@ -127,13 +145,15 @@ class OtherProfileScreen extends GetView { title: '작성한 게시물 목록', postItemCount: _testPostItemList.length, postItemList: _testPostItemList, + emptyMessage: '아직 작성한 게시물이 없어요.', ), ], ), ), ), - _buildCoffeeChatRequestButton(), - ], + ), + _buildCoffeeChatRequestButton(), + ], ); }), ), @@ -171,6 +191,7 @@ class OtherProfileScreen extends GetView { Obx( () => UserProfileImage( imageUrl: controller.profileImageUrl.value, + onUrlExpired: controller.refreshProfileImageUrl, ), ), UserNameAndMajor( diff --git a/lib/presentation/profile/widget/profile_indicator_post_page_list.dart b/lib/presentation/profile/widget/profile_indicator_post_page_list.dart index 8e8382a5..5036ea3a 100644 --- a/lib/presentation/profile/widget/profile_indicator_post_page_list.dart +++ b/lib/presentation/profile/widget/profile_indicator_post_page_list.dart @@ -8,11 +8,13 @@ class ProfileIndicatorPostPageList extends StatelessWidget { required this.title, required this.postItemCount, required this.postItemList, + this.emptyMessage = '게시물이 없어요.', }); final String title; final int postItemCount; final List postItemList; + final String emptyMessage; @override Widget build(BuildContext context) { @@ -20,6 +22,7 @@ class ProfileIndicatorPostPageList extends StatelessWidget { title: title, itemFloors: 2, items: postItemList, + emptyMessage: emptyMessage, ); } } diff --git a/lib/presentation/profile/widget/user_profile_image.dart b/lib/presentation/profile/widget/user_profile_image.dart index 37bf6991..9a302b1b 100644 --- a/lib/presentation/profile/widget/user_profile_image.dart +++ b/lib/presentation/profile/widget/user_profile_image.dart @@ -5,9 +5,10 @@ import 'package:ondo/core/design_system/app_icon.dart'; import 'package:ondo/core/design_system/app_layout.dart'; class UserProfileImage extends StatelessWidget { - const UserProfileImage({super.key, this.imageUrl}); + const UserProfileImage({super.key, this.imageUrl, this.onUrlExpired}); final String? imageUrl; + final VoidCallback? onUrlExpired; @override Widget build(BuildContext context) { @@ -31,7 +32,10 @@ class UserProfileImage extends StatelessWidget { fit: BoxFit.cover, width: AppSpacing.s80, height: AppSpacing.s80, - errorWidget: (_, _, _) => _defaultImage(), + errorWidget: (_, _, _) { + onUrlExpired?.call(); + return _defaultImage(); + }, ) : _defaultImage(), ), diff --git a/lib/presentation/search/controllers/main_top_bar_search_controller.dart b/lib/presentation/search/controllers/main_top_bar_search_controller.dart index e3d48803..d431ab3e 100644 --- a/lib/presentation/search/controllers/main_top_bar_search_controller.dart +++ b/lib/presentation/search/controllers/main_top_bar_search_controller.dart @@ -15,6 +15,7 @@ class MainTopBarSearchController extends GetxController { final RxBool showPopup = false.obs; final RxBool showResult = false.obs; + final RxInt submitCount = 0.obs; final RxString queryNotifier = "".obs; @@ -45,10 +46,20 @@ class MainTopBarSearchController extends GetxController { showResult.value = false; return; } + submitCount.value++; showResult.value = true; _saveSearchTag(state.keyword); } + /// 태그/추천어 클릭 시 검색 필드에 텍스트를 채우고 submit + void submitFromSuggestion(String keyword) { + searchController.text = keyword; + searchController.selection = TextSelection.fromPosition( + TextPosition(offset: keyword.length), + ); + submit(query: keyword); + } + void tapOther() => focusNode.unfocus(); void tapSearchBar() => focusNode.requestFocus(); diff --git a/lib/presentation/search/controllers/search_popup_controller.dart b/lib/presentation/search/controllers/search_popup_controller.dart index 8b4d56ec..7fd5056c 100644 --- a/lib/presentation/search/controllers/search_popup_controller.dart +++ b/lib/presentation/search/controllers/search_popup_controller.dart @@ -24,6 +24,10 @@ class SearchPopupController extends GetxController { late final Worker typingWorker; late final Worker showPopupWorker; + // submit이 발생한 횟수 기준으로 마지막 태그 로드 시점 추적 + // -1: 아직 한 번도 로드 안 함 → onInit에서 무조건 1회 로드 + int _lastLoadedAtSubmitCount = -1; + @override void onInit() { //TODO 삭제 @@ -33,14 +37,18 @@ class SearchPopupController extends GetxController { //TODO : 서버로부터 추천 키워드 받기 viewSearchTipList.assignAll(_cacheSearchTipList.take(20)); + // 초기 1회 로드 + _loadTagsIfNeeded(); + /// worker 등록 typingWorker = ever( mainTopBarSearchController.queryNotifier, _update, ); + // 팝업이 열릴 때 새 검색어가 저장된 경우에만 재로드 showPopupWorker = ever( mainTopBarSearchController.showPopup, - _loadSearchTagList, + (bool show) { if (show) _loadTagsIfNeeded(); }, ); super.onInit(); @@ -53,20 +61,23 @@ class SearchPopupController extends GetxController { super.onClose(); } - Future _loadSearchTagList(bool showPopup) async { - if (!showPopup) return; + /// submit 이후 새 태그가 저장됐을 때만 로컬 스토리지 재조회 + Future _loadTagsIfNeeded() async { + final currentSubmitCount = mainTopBarSearchController.submitCount.value; + if (currentSubmitCount == _lastLoadedAtSubmitCount) return; + _lastLoadedAtSubmitCount = currentSubmitCount; final result = await loadSearchKeywordListUseCase.call(); viewSearchTagList.assignAll(result); debugPrint("로컬, 최근 검색 태그 불러오기 성공"); } - Future selectSearchTag(String tag) async { - mainTopBarSearchController.submit(tag: tag); + void selectSearchTag(String tag) { + mainTopBarSearchController.submitFromSuggestion(tag); } void selectSearchTip(String tip) { - mainTopBarSearchController.submit(tip: tip); + mainTopBarSearchController.submitFromSuggestion(tip); } void _update(String text) { diff --git a/lib/presentation/search/widgets/main_top_search_bar.dart b/lib/presentation/search/widgets/main_top_search_bar.dart index b55a2444..e3e8fd7e 100644 --- a/lib/presentation/search/widgets/main_top_search_bar.dart +++ b/lib/presentation/search/widgets/main_top_search_bar.dart @@ -48,6 +48,8 @@ class _MainTopSearchBarState extends State { GestureDetector( onTap: controller.tapOther, child: Obx(() { + // submitCount를 읽어 재검색 시에도 Obx가 재평가되도록 함 + controller.submitCount.value; if (!controller.showResult.value) return widget.mainPage; final resultPage = widget.resultPageBuilder(controller.state); //검색 결과가 없다면 null을 반환해 mainPage 표시