diff --git a/be/src/main/java/com/yumst/be/global/exception/ErrorResponse.java b/be/src/main/java/com/yumst/be/global/exception/ErrorResponse.java new file mode 100644 index 0000000..ae31c64 --- /dev/null +++ b/be/src/main/java/com/yumst/be/global/exception/ErrorResponse.java @@ -0,0 +1,14 @@ +package com.yumst.be.global.exception; + +import lombok.Getter; + +@Getter +public class ErrorResponse { + private final String message; + private final int status; + + public ErrorResponse(ErrorCode errorCode) { + this.message = errorCode.getMessage(); + this.status = errorCode.getHttpStatus().value(); + } +} \ No newline at end of file diff --git a/be/src/main/java/com/yumst/be/restaurant/repository/NaverReviewFeatureCountRepository.java b/be/src/main/java/com/yumst/be/restaurant/repository/NaverReviewFeatureCountRepository.java index 8592f2c..a658b6b 100644 --- a/be/src/main/java/com/yumst/be/restaurant/repository/NaverReviewFeatureCountRepository.java +++ b/be/src/main/java/com/yumst/be/restaurant/repository/NaverReviewFeatureCountRepository.java @@ -3,6 +3,7 @@ import com.yumst.be.restaurant.domain.RestaurantNaverReviewFeatureCount; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; @@ -15,4 +16,11 @@ public interface NaverReviewFeatureCountRepository extends JpaRepository findTop2ByRestaurantIdOrderByReviewCountDesc(String restaurantId); -} + + @Query("SELECT r " + + "FROM RestaurantNaverReviewFeatureCount r " + + "JOIN FETCH r.naverReviewFeature " + + "WHERE r.restaurantId IN :restaurantIds " + + "ORDER BY r.restaurantId, r.reviewCount DESC") + List findTop2FeaturesForRestaurantIds(@Param("restaurantIds") List restaurantIds); +} \ No newline at end of file diff --git a/be/src/main/java/com/yumst/be/restaurant/repository/RestaurantRepository.java b/be/src/main/java/com/yumst/be/restaurant/repository/RestaurantRepository.java index b391143..4f03771 100644 --- a/be/src/main/java/com/yumst/be/restaurant/repository/RestaurantRepository.java +++ b/be/src/main/java/com/yumst/be/restaurant/repository/RestaurantRepository.java @@ -1,8 +1,11 @@ package com.yumst.be.restaurant.repository; import com.yumst.be.restaurant.domain.Restaurant; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; import java.util.Optional; @@ -10,14 +13,171 @@ public interface RestaurantRepository extends JpaRepository { Optional findByRestaurantId(String restaurantId); List findTop10RestaurantsByCrawlCompleteTrue(); + + // 거리순 정렬 + @Query(value = + "WITH RankedRestaurants AS (" + + " SELECT " + + " r.restaurant_id, r.name, r.category, r.thumbnail_url, " + + " ST_Distance(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326))" + + " ) as dist, " + + " COALESCE(bool_or(us.restaurant_id IS NOT NULL), false) as is_scrapped, " + + " COALESCE(SUM(CASE WHEN v_all.vote_type = 'LIKE' THEN 1 ELSE 0 END), 0) as like_count, " + + " COALESCE(SUM(CASE WHEN v_all.vote_type = 'DISLIKE' THEN 1 ELSE 0 END), 0) as dislike_count, " + + " MAX(v_user.vote_type) as user_vote_status, " + + " ROW_NUMBER() OVER (PARTITION BY r.name ORDER BY ST_Distance(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)))\n" + + " ) as row_num " + + " FROM " + + " restaurant r " + + " LEFT JOIN user_restaurant_scrap us ON r.restaurant_id = us.restaurant_id AND us.user_id = :userId " + + " LEFT JOIN user_restaurant_vote v_all ON r.restaurant_id = v_all.restaurant_id " + + " LEFT JOIN user_restaurant_vote v_user ON r.restaurant_id = v_user.restaurant_id AND v_user.user_id = :userId " + + " WHERE " + + " r.crawl_complete = true AND " + + " ST_DWithin(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + + " :radius * 1000" + + " ) " + + " GROUP BY r.restaurant_id, r.name, r.category, r.thumbnail_url, r.longitude, r.latitude" + + ") " + + "SELECT restaurant_id, name, category, thumbnail_url, dist, is_scrapped, like_count, dislike_count, user_vote_status " + + "FROM RankedRestaurants " + + "WHERE row_num = 1 " + + "ORDER BY dist ASC", + countQuery = "SELECT COUNT(DISTINCT r.name) " + + "FROM restaurant r " + + "WHERE r.crawl_complete = true AND " + + "ST_DWithin(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + + " :radius * 1000" + + ")", + nativeQuery = true) + Page findNearbyRestaurantsOrderByDistance( + @Param("userId") String userId, + @Param("latitude") Double latitude, + @Param("longitude") Double longitude, + @Param("radius") Double radius, + Pageable pageable + ); + + // 좋아요순 정렬 + @Query(value = + "WITH RankedRestaurants AS (" + + " SELECT " + + " r.restaurant_id, r.name, r.category, r.thumbnail_url, " + + " ST_Distance(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326))" + + " ) as dist, " + + " COALESCE(bool_or(us.restaurant_id IS NOT NULL), false) as is_scrapped, " + + " COALESCE(SUM(CASE WHEN v_all.vote_type = 'LIKE' THEN 1 ELSE 0 END), 0) as like_count, " + + " COALESCE(SUM(CASE WHEN v_all.vote_type = 'DISLIKE' THEN 1 ELSE 0 END), 0) as dislike_count, " + + " MAX(v_user.vote_type) as user_vote_status, " + + " ROW_NUMBER() OVER (PARTITION BY r.name ORDER BY ST_Distance(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)))\n" + + " ) as row_num " + + " FROM " + + " restaurant r " + + " LEFT JOIN user_restaurant_scrap us ON r.restaurant_id = us.restaurant_id AND us.user_id = :userId " + + " LEFT JOIN user_restaurant_vote v_all ON r.restaurant_id = v_all.restaurant_id " + + " LEFT JOIN user_restaurant_vote v_user ON r.restaurant_id = v_user.restaurant_id AND v_user.user_id = :userId " + + " WHERE " + + " r.crawl_complete = true AND " + + " ST_DWithin(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + + " :radius * 1000" + + " ) " + + " GROUP BY r.restaurant_id, r.name, r.category, r.thumbnail_url, r.longitude, r.latitude" + + ") " + + "SELECT restaurant_id, name, category, thumbnail_url, dist, is_scrapped, like_count, dislike_count, user_vote_status " + + "FROM RankedRestaurants " + + "WHERE row_num = 1 " + + "ORDER BY like_count DESC, dist ASC", + countQuery = "SELECT COUNT(DISTINCT r.name) " + + "FROM restaurant r " + + "WHERE r.crawl_complete = true AND " + + "ST_DWithin(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + + " :radius * 1000" + + ")", + nativeQuery = true) + Page findNearbyRestaurantsOrderByLikes( + @Param("userId") String userId, + @Param("latitude") Double latitude, + @Param("longitude") Double longitude, + @Param("radius") Double radius, + Pageable pageable + ); + + // 싫어요순 정렬 + @Query(value = + "WITH RankedRestaurants AS (" + + " SELECT " + + " r.restaurant_id, r.name, r.category, r.thumbnail_url, " + + " ST_Distance(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326))" + + " ) as dist, " + + " COALESCE(bool_or(us.restaurant_id IS NOT NULL), false) as is_scrapped, " + + " COALESCE(SUM(CASE WHEN v_all.vote_type = 'LIKE' THEN 1 ELSE 0 END), 0) as like_count, " + + " COALESCE(SUM(CASE WHEN v_all.vote_type = 'DISLIKE' THEN 1 ELSE 0 END), 0) as dislike_count, " + + " MAX(v_user.vote_type) as user_vote_status, " + + " ROW_NUMBER() OVER (PARTITION BY r.name ORDER BY ST_Distance(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)))\n" + + " ) as row_num " + + " FROM " + + " restaurant r " + + " LEFT JOIN user_restaurant_scrap us ON r.restaurant_id = us.restaurant_id AND us.user_id = :userId " + + " LEFT JOIN user_restaurant_vote v_all ON r.restaurant_id = v_all.restaurant_id " + + " LEFT JOIN user_restaurant_vote v_user ON r.restaurant_id = v_user.restaurant_id AND v_user.user_id = :userId " + + " WHERE " + + " r.crawl_complete = true AND " + + " ST_DWithin(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + + " :radius * 1000" + + " ) " + + " GROUP BY r.restaurant_id, r.name, r.category, r.thumbnail_url, r.longitude, r.latitude" + + ") " + + "SELECT restaurant_id, name, category, thumbnail_url, dist, is_scrapped, like_count, dislike_count, user_vote_status " + + "FROM RankedRestaurants " + + "WHERE row_num = 1 " + + "ORDER BY dislike_count DESC, dist ASC", + countQuery = "SELECT COUNT(DISTINCT r.name) " + + "FROM restaurant r " + + "WHERE r.crawl_complete = true AND " + + "ST_DWithin(" + + " geography(ST_SetSRID(ST_MakePoint(CAST(r.longitude AS float), CAST(r.latitude AS float)), 4326)), " + + " geography(ST_SetSRID(ST_MakePoint(:longitude, :latitude), 4326)), " + + " :radius * 1000" + + ")", + nativeQuery = true) + Page findNearbyRestaurantsOrderByDislikes( + @Param("userId") String userId, + @Param("latitude") Double latitude, + @Param("longitude") Double longitude, + @Param("radius") Double radius, + Pageable pageable + ); + @Query( "SELECT r " + "FROM Restaurant r " + "WHERE r.crawlComplete = true " + -// "r.naverInformation.name like concat('%', '쭈꾸미블루스', '%') " + "ORDER BY r.naverInformation.rating DESC " + "LIMIT 10" ) List findTop10RestaurantsByCrawlCompleteTrueOrderByNaverInformation(); + Restaurant findFirstByOpenDataInformation_BusinessNameContainingAndOpenDataInformation_FullAddressContaining(String businessName, String fullAddress); -} +} \ No newline at end of file diff --git a/be/src/main/java/com/yumst/be/restaurant/service/RestaurantService.java b/be/src/main/java/com/yumst/be/restaurant/service/RestaurantService.java index 4a56966..11a988b 100644 --- a/be/src/main/java/com/yumst/be/restaurant/service/RestaurantService.java +++ b/be/src/main/java/com/yumst/be/restaurant/service/RestaurantService.java @@ -2,35 +2,41 @@ import com.yumst.be.recommendation.dto.response.RecommendedRestaurant; import com.yumst.be.restaurant.domain.Restaurant; +import com.yumst.be.restaurant.domain.RestaurantNaverReviewFeatureCount; import com.yumst.be.restaurant.exception.RestaurantException; import com.yumst.be.restaurant.repository.NaverReviewFeatureCountRepository; import com.yumst.be.restaurant.repository.RestaurantRepository; import com.yumst.be.restaurant.vo.ResponseRestaurant; import com.yumst.be.user.repository.UserRestaurantScrapRepository; +import com.yumst.be.vote.repository.UserRestaurantVoteRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import static com.yumst.be.restaurant.exception.RestaurantErrorCode.RESTAURANT_NOT_FOUND; @Service @RequiredArgsConstructor +@Transactional(readOnly = true) public class RestaurantService { private final RestaurantRepository restaurantRepository; private final NaverReviewFeatureCountRepository naverReviewFeatureCountRepository; private final UserRestaurantScrapRepository userRestaurantScrapRepository; + private final UserRestaurantVoteRepository userRestaurantVoteRepository; public List getRandomRestaurant(String userId) { - List result = restaurantRepository.findTop10RestaurantsByCrawlCompleteTrueOrderByNaverInformation(); - - return result.stream().map( - restaurant -> getResponseRestaurant(userId, restaurant)) + return result.stream() + .map(restaurant -> getResponseRestaurant(userId, restaurant)) .collect(Collectors.toList()); - } public List getResponseRestaurantFromRecommend(List results, String userId) { @@ -42,23 +48,26 @@ public List getResponseRestaurantFromRecommend(List getResponseRestaurantList(List restaurantIds, String userId) { return restaurantIds.stream() .map(restaurantId -> getResponseRestaurantByRestaurantId(userId, restaurantId)) - .toList(); + .collect(Collectors.toList()); } private ResponseRestaurant getResponseRestaurantWithDistanceByRestaurantId(String userId, String restaurantId, double distance) { Restaurant restaurant = findRestaurantById(restaurantId); List top2Features = findTop2Features(restaurant); boolean scrapped = userRestaurantScrapRepository.existsByUserIdAndRestaurantId(userId, restaurant.getRestaurantId()); + String userVoteStatus = userRestaurantVoteRepository.findByUserIdAndRestaurantId(userId, restaurant.getRestaurantId()) + .map(vote -> vote.getVoteType().name()) + .orElse(null); return ResponseRestaurant.from( restaurant, top2Features, scrapped, - distance + distance, + userVoteStatus ); } @@ -69,29 +78,93 @@ private ResponseRestaurant getResponseRestaurantByRestaurantId(String userId, St private Restaurant findRestaurantById(String restaurantId) { return restaurantRepository.findByRestaurantId(restaurantId) - .orElseThrow(() -> new RestaurantException(RESTAURANT_NOT_FOUND)); + .orElseThrow(() -> new RestaurantException(RESTAURANT_NOT_FOUND)); } - private ResponseRestaurant getResponseRestaurant(String userId, Restaurant restaurant) { + /** + * Validates if a restaurant exists by its ID + * @param restaurantId the ID of the restaurant to check + * @throws RestaurantException if the restaurant doesn't exist + */ + public void validateRestaurantExists(String restaurantId) { + findRestaurantById(restaurantId); + } + private ResponseRestaurant getResponseRestaurant(String userId, Restaurant restaurant) { List top2Features = findTop2Features(restaurant); boolean scrapped = userRestaurantScrapRepository.existsByUserIdAndRestaurantId(userId, restaurant.getRestaurantId()); + String userVoteStatus = userRestaurantVoteRepository.findByUserIdAndRestaurantId(userId, restaurant.getRestaurantId()) + .map(vote -> vote.getVoteType().name()) + .orElse(null); return ResponseRestaurant.createWithNoDistance( restaurant, top2Features, - scrapped + scrapped, + userVoteStatus ); } private List findTop2Features(Restaurant restaurant) { return naverReviewFeatureCountRepository - .findTop2ByRestaurantIdOrderByReviewCountDesc(restaurant.getRestaurantId()) - .stream() - .map(c -> c.getNaverReviewFeature().getFeature()) - .toList(); + .findTop2ByRestaurantIdOrderByReviewCountDesc(restaurant.getRestaurantId()) + .stream() + .map(c -> c.getNaverReviewFeature().getFeature()) + .toList(); } + public List findNearbyRestaurants(String userId, Double latitude, Double longitude, Double radius, String sort, Pageable pageable) { + Page results; + + switch (sort) { + case "likes": + results = restaurantRepository.findNearbyRestaurantsOrderByLikes(userId, latitude, longitude, radius, pageable); + break; + case "dislikes": + results = restaurantRepository.findNearbyRestaurantsOrderByDislikes(userId, latitude, longitude, radius, pageable); + break; + case "distance": + default: + results = restaurantRepository.findNearbyRestaurantsOrderByDistance(userId, latitude, longitude, radius, pageable); + break; + } + + List content = results.getContent(); + + if (content.isEmpty()) { + return new ArrayList<>(); + } + + List restaurantIds = content.stream() + .map(result -> (String) result[0]) + .toList(); - + List allFeatures = naverReviewFeatureCountRepository.findTop2FeaturesForRestaurantIds(restaurantIds); + + Map> featureMap = allFeatures.stream() + .collect(Collectors.groupingBy( + RestaurantNaverReviewFeatureCount::getRestaurantId, + Collectors.mapping(rc -> rc.getNaverReviewFeature().getFeature(), Collectors.toList()) + )) + .entrySet().stream() + .collect(Collectors.toMap( + Map.Entry::getKey, + entry -> entry.getValue().stream().limit(2).toList() + )); + + return content.stream() + .map(result -> ResponseRestaurant.fromSearchResult( + (String) result[0], // restaurantId + (String) result[1], // name + (String) result[2], // category + (String) result[3], // thumbnailUrl + ((Number) result[4]).doubleValue(), // distance + (Boolean) result[5], // isScrapped + ((Number) result[6]).longValue(), // likeCount + ((Number) result[7]).longValue(), // dislikeCount + featureMap.getOrDefault((String) result[0], new ArrayList<>()), // features + (String) result[8] // userVoteStatus 추가 + )) + .toList(); + } } diff --git a/be/src/main/java/com/yumst/be/restaurant/vo/ResponseRestaurant.java b/be/src/main/java/com/yumst/be/restaurant/vo/ResponseRestaurant.java index f01ddc4..173f8c1 100644 --- a/be/src/main/java/com/yumst/be/restaurant/vo/ResponseRestaurant.java +++ b/be/src/main/java/com/yumst/be/restaurant/vo/ResponseRestaurant.java @@ -24,17 +24,20 @@ public record ResponseRestaurant( boolean isScrapped, Long likeCount, Long dislikeCount, - Double distance + Double distance, + String userVoteStatus ) { - public static ResponseRestaurant from(Restaurant restaurant, List top2Features, boolean isScrapped, double distance) { + public static ResponseRestaurant from(Restaurant restaurant, List top2Features, boolean isScrapped, double distance, String userVoteStatus) { return baseBuilder(restaurant, top2Features, isScrapped) .distance(distance) + .userVoteStatus(userVoteStatus) .build(); } - public static ResponseRestaurant createWithNoDistance(Restaurant restaurant, List top2Features, boolean isScrapped) { + public static ResponseRestaurant createWithNoDistance(Restaurant restaurant, List top2Features, boolean isScrapped, String userVoteStatus) { return baseBuilder(restaurant, top2Features, isScrapped) + .userVoteStatus(userVoteStatus) .build(); } @@ -54,8 +57,40 @@ private static ResponseRestaurantBuilder baseBuilder(Restaurant restaurant, List .isScrapped(isScrapped); } + public static ResponseRestaurant fromSearchResult( + String restaurantId, + String name, + String category, + String thumbnailUrl, + Double distance, + Boolean isScrapped, + Long likeCount, + Long dislikeCount, + List features, + String userVoteStatus + ) { + return new ResponseRestaurant( + restaurantId, + name, + category, + null, // latitude + null, // longitude + thumbnailUrl, + null, // fullAddress + null, // roadNameFullAddress + null, // phoneNumber + null, // todayOpening + features, + isScrapped, + likeCount, + dislikeCount, + distance, + userVoteStatus + ); + } + @Builder - public ResponseRestaurant(String restaurantId, String name, String category, String latitude, String longitude, String thumbnailUrl, String fullAddress, String roadNameFullAddress, String phoneNumber, String todayOpening, List top2Features, boolean isScrapped, Long likeCount, Long dislikeCount, Double distance) { + public ResponseRestaurant(String restaurantId, String name, String category, String latitude, String longitude, String thumbnailUrl, String fullAddress, String roadNameFullAddress, String phoneNumber, String todayOpening, List top2Features, boolean isScrapped, Long likeCount, Long dislikeCount, Double distance, String userVoteStatus) { this.restaurantId = restaurantId; this.name = name; this.category = category; @@ -71,5 +106,6 @@ public ResponseRestaurant(String restaurantId, String name, String category, Str this.likeCount = likeCount; this.dislikeCount = dislikeCount; this.distance = distance; + this.userVoteStatus = userVoteStatus; } } diff --git a/be/src/main/java/com/yumst/be/user/config/SecurityConfig.java b/be/src/main/java/com/yumst/be/user/config/SecurityConfig.java index fb723de..923dcb1 100644 --- a/be/src/main/java/com/yumst/be/user/config/SecurityConfig.java +++ b/be/src/main/java/com/yumst/be/user/config/SecurityConfig.java @@ -45,8 +45,8 @@ public WebSecurityCustomizer webSecurityCustomizer() { "/v3/api-docs/**", "/api/user/v1/login/google", "/api/user/v1/login/apple", - "api/user/v1/login/guest", -}; + "/api/user/v1/login/guest", + }; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { diff --git a/be/src/main/java/com/yumst/be/user/repository/UserRestaurantScrapRepository.java b/be/src/main/java/com/yumst/be/user/repository/UserRestaurantScrapRepository.java index 36e4119..4f2ca6d 100644 --- a/be/src/main/java/com/yumst/be/user/repository/UserRestaurantScrapRepository.java +++ b/be/src/main/java/com/yumst/be/user/repository/UserRestaurantScrapRepository.java @@ -3,6 +3,7 @@ import com.yumst.be.user.domain.UserRestaurantScrap; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -18,4 +19,12 @@ public interface UserRestaurantScrapRepository extends JpaRepository findAllRestaurantIdByUserId(String userId); + + @Query(""" + SELECT s.restaurantId + FROM UserRestaurantScrap s + WHERE s.userId = :userId + AND s.restaurantId IN :restaurantIds + """) + List findScrappedRestaurantIds(@Param("userId") String userId, @Param("restaurantIds") List restaurantIds); } diff --git a/be/src/main/java/com/yumst/be/user/service/UserService.java b/be/src/main/java/com/yumst/be/user/service/UserService.java index 5394882..f9decb9 100644 --- a/be/src/main/java/com/yumst/be/user/service/UserService.java +++ b/be/src/main/java/com/yumst/be/user/service/UserService.java @@ -52,6 +52,12 @@ public UserDto findUserWithScrappedRestaurant(String userId) { return userDto; } + @Transactional(readOnly = true) + public void validateUserExists(String userId) { + userRepository.findByUserId(userId) + .orElseThrow(() -> new AuthException(USER_NOT_FOUND)); + } + @Transactional public UserDto deleteUser(String userId) { diff --git a/be/src/main/java/com/yumst/be/vote/controller/UserRestaurantVoteController.java b/be/src/main/java/com/yumst/be/vote/controller/UserRestaurantVoteController.java new file mode 100644 index 0000000..3714f7b --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/controller/UserRestaurantVoteController.java @@ -0,0 +1,37 @@ +package com.yumst.be.vote.controller; + +import com.yumst.be.restaurant.vo.ResponseRestaurant; +import com.yumst.be.vote.dto.RestaurantRequest; +import com.yumst.be.vote.dto.VoteRequest; +import com.yumst.be.vote.dto.VoteResponse; +import com.yumst.be.vote.service.UserRestaurantVoteService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/vote/v1") +@RequiredArgsConstructor +public class UserRestaurantVoteController { + private final UserRestaurantVoteService userRestaurantVoteService; + + @GetMapping("/restaurants") + public ResponseEntity> getVotableRestaurants( + @RequestHeader("userId") String userId, + @Valid @ModelAttribute RestaurantRequest restaurantRequest) { + List restaurants = + userRestaurantVoteService.getVotableRestaurants(userId, restaurantRequest); + return ResponseEntity.ok(restaurants); + } + + @PatchMapping("/restaurants/{restaurantId}") + public ResponseEntity vote( + @RequestHeader("userId") String userId, + @PathVariable String restaurantId, + @RequestBody VoteRequest request) { + return ResponseEntity.ok(userRestaurantVoteService.vote(userId, restaurantId, request.getVoteType())); + } +} \ No newline at end of file diff --git a/be/src/main/java/com/yumst/be/vote/domain/UserRestaurantVote.java b/be/src/main/java/com/yumst/be/vote/domain/UserRestaurantVote.java index e6c4068..ac587cd 100644 --- a/be/src/main/java/com/yumst/be/vote/domain/UserRestaurantVote.java +++ b/be/src/main/java/com/yumst/be/vote/domain/UserRestaurantVote.java @@ -1,6 +1,7 @@ package com.yumst.be.vote.domain; import com.yumst.be.global.entity.BaseTimeEntity; +import com.yumst.be.vote.domain.VoteType; import jakarta.persistence.*; import lombok.Builder; import lombok.Getter; @@ -27,6 +28,9 @@ public class UserRestaurantVote extends BaseTimeEntity { @Enumerated(EnumType.STRING) @Column(name = "vote_type", nullable = false) private VoteType voteType; + + @Version + private Long version; @Builder public UserRestaurantVote(String userId, String restaurantId, VoteType voteType) { @@ -35,4 +39,8 @@ public UserRestaurantVote(String userId, String restaurantId, VoteType voteType) this.voteType = voteType; } + public void updateVote(VoteType voteType) { + this.voteType = voteType; + } } + diff --git a/be/src/main/java/com/yumst/be/vote/dto/RestaurantRequest.java b/be/src/main/java/com/yumst/be/vote/dto/RestaurantRequest.java new file mode 100644 index 0000000..b77a04e --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/dto/RestaurantRequest.java @@ -0,0 +1,24 @@ +package com.yumst.be.vote.dto; + +import lombok.Data; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import org.hibernate.validator.constraints.Range; + +@Data +public class RestaurantRequest { + @NotNull(message = "Latitude is required") + private Double latitude; + + @NotNull(message = "Longitude is required") + private Double longitude; + + @NotNull(message = "Radius is required") + @Range(min = 0, max = 10, message = "Radius must be between 0 and 10") + private Double radius; + + private String sort; + + @Min(value = 0, message = "Page must be at least 0") + private Integer page; +} \ No newline at end of file diff --git a/be/src/main/java/com/yumst/be/vote/dto/VoteRequest.java b/be/src/main/java/com/yumst/be/vote/dto/VoteRequest.java new file mode 100644 index 0000000..ebc6087 --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/dto/VoteRequest.java @@ -0,0 +1,9 @@ +package com.yumst.be.vote.dto; + +import lombok.Getter; +import com.yumst.be.vote.domain.VoteType; + +@Getter +public class VoteRequest { + private VoteType voteType; +} \ No newline at end of file diff --git a/be/src/main/java/com/yumst/be/vote/dto/VoteResponse.java b/be/src/main/java/com/yumst/be/vote/dto/VoteResponse.java new file mode 100644 index 0000000..c0adfb3 --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/dto/VoteResponse.java @@ -0,0 +1,13 @@ +package com.yumst.be.vote.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class VoteResponse { + private String message; + private String restaurantId; + private Long likes; + private Long dislikes; +} \ No newline at end of file diff --git a/be/src/main/java/com/yumst/be/vote/exception/VoteErrorCode.java b/be/src/main/java/com/yumst/be/vote/exception/VoteErrorCode.java new file mode 100644 index 0000000..9533b2f --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/exception/VoteErrorCode.java @@ -0,0 +1,27 @@ +package com.yumst.be.vote.exception; + +import com.yumst.be.global.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum VoteErrorCode implements ErrorCode { + INVALID_VOTE_TYPE(HttpStatus.BAD_REQUEST, "잘못된 투표 타입입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."), + TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요."); + + private final HttpStatus status; + private final String message; + + @Override + public HttpStatus getHttpStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } +} \ No newline at end of file diff --git a/be/src/main/java/com/yumst/be/vote/exception/VoteException.java b/be/src/main/java/com/yumst/be/vote/exception/VoteException.java new file mode 100644 index 0000000..8a7c341 --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/exception/VoteException.java @@ -0,0 +1,14 @@ +package com.yumst.be.vote.exception; + +import lombok.Getter; + +@Getter +public class VoteException extends RuntimeException { + + private final VoteErrorCode errorCode; + + public VoteException(VoteErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } +} \ No newline at end of file diff --git a/be/src/main/java/com/yumst/be/vote/exception/VoteExceptionHandler.java b/be/src/main/java/com/yumst/be/vote/exception/VoteExceptionHandler.java new file mode 100644 index 0000000..8b8769a --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/exception/VoteExceptionHandler.java @@ -0,0 +1,17 @@ +package com.yumst.be.vote.exception; + +import com.yumst.be.global.exception.ErrorResponse; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class VoteExceptionHandler { + + @ExceptionHandler(VoteException.class) + public ResponseEntity handleVoteException(VoteException exception) { + VoteErrorCode errorCode = exception.getErrorCode(); + ErrorResponse errorResponse = new ErrorResponse(errorCode); + return ResponseEntity.status(errorCode.getHttpStatus()).body(errorResponse); + } +} \ No newline at end of file diff --git a/be/src/main/java/com/yumst/be/vote/repository/UserRestaurantVoteRepository.java b/be/src/main/java/com/yumst/be/vote/repository/UserRestaurantVoteRepository.java index b4ecf70..8104321 100644 --- a/be/src/main/java/com/yumst/be/vote/repository/UserRestaurantVoteRepository.java +++ b/be/src/main/java/com/yumst/be/vote/repository/UserRestaurantVoteRepository.java @@ -1,8 +1,26 @@ package com.yumst.be.vote.repository; import com.yumst.be.vote.domain.UserRestaurantVote; +import com.yumst.be.vote.domain.VoteType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; public interface UserRestaurantVoteRepository extends JpaRepository { - Long countByRestaurantId(String restaurantId); + @Query("SELECT v FROM UserRestaurantVote v WHERE v.userId = :userId AND v.restaurantId = :restaurantId") + Optional findByUserIdAndRestaurantId(@Param("userId") String userId, @Param("restaurantId") String restaurantId); + + @Query("SELECT COUNT(v) FROM UserRestaurantVote v WHERE v.restaurantId = :restaurantId AND v.voteType = :voteType") + Integer countByRestaurantIdAndVoteType(@Param("restaurantId") String restaurantId, @Param("voteType") VoteType voteType); + + @Query(""" + SELECT v.restaurantId, v.voteType, COUNT(v) as count + FROM UserRestaurantVote v + WHERE v.restaurantId IN :restaurantIds + GROUP BY v.restaurantId, v.voteType + """) + List countVotesByRestaurantIds(@Param("restaurantIds") List restaurantIds); } diff --git a/be/src/main/java/com/yumst/be/vote/service/UserRestaurantVoteService.java b/be/src/main/java/com/yumst/be/vote/service/UserRestaurantVoteService.java new file mode 100644 index 0000000..2c67dc2 --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/service/UserRestaurantVoteService.java @@ -0,0 +1,108 @@ +package com.yumst.be.vote.service; + +import com.yumst.be.restaurant.service.RestaurantService; +import com.yumst.be.restaurant.vo.ResponseRestaurant; +import com.yumst.be.user.service.UserService; +import com.yumst.be.vote.domain.UserRestaurantVote; +import com.yumst.be.vote.dto.RestaurantRequest; +import com.yumst.be.vote.dto.VoteResponse; +import com.yumst.be.vote.domain.VoteType; +import com.yumst.be.vote.exception.VoteErrorCode; +import com.yumst.be.vote.exception.VoteException; +import com.yumst.be.vote.repository.UserRestaurantVoteRepository; +import com.yumst.be.vote.util.VoteRateLimiter; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class UserRestaurantVoteService { + private final UserRestaurantVoteRepository userRestaurantVoteRepository; + private final RestaurantService restaurantService; + private final UserService userService; + private final VoteRateLimiter voteRateLimiter; + + @Transactional(readOnly = true) + public List getVotableRestaurants(String userId, RestaurantRequest request) { + userService.validateUserExists(userId); + + Double latitude = request.getLatitude(); + Double longitude = request.getLongitude(); + Double radius = request.getRadius(); + String sort = (request.getSort() != null && !request.getSort().isEmpty()) ? request.getSort() : "distance"; + int page = (request.getPage() != null && request.getPage() >= 0) ? request.getPage() : 0; + + Pageable pageable = PageRequest.of(page, 10); + + return restaurantService.findNearbyRestaurants(userId, latitude, longitude, radius, sort, pageable); + } + + @Transactional + public VoteResponse vote(String userId, String restaurantId, VoteType voteType) { + // 요청 레이트 체크 + if (!voteRateLimiter.allowRequest(userId)) { + throw new VoteException(VoteErrorCode.TOO_MANY_REQUESTS); + } + + try { + // 사용자 존재 여부 확인 + userService.validateUserExists(userId); + + // 식당 존재 여부 확인 + restaurantService.validateRestaurantExists(restaurantId); + + Optional existingVote = userRestaurantVoteRepository + .findByUserIdAndRestaurantId(userId, restaurantId); + + // voteType이 null인 경우 투표 취소 처리 + if (voteType == null) { + if (existingVote.isPresent()) { + userRestaurantVoteRepository.delete(existingVote.get()); + return createVoteResponse("Vote removed", restaurantId); + } + return createVoteResponse("No vote to remove", restaurantId); + } + + if (existingVote.isPresent()) { + UserRestaurantVote vote = existingVote.get(); + if (vote.getVoteType() == voteType) { + userRestaurantVoteRepository.delete(vote); + return createVoteResponse("Vote removed", restaurantId); + } else { + vote.updateVote(voteType); + return createVoteResponse("Vote updated", restaurantId); + } + } else { + UserRestaurantVote newVote = UserRestaurantVote.builder() + .userId(userId) + .restaurantId(restaurantId) + .voteType(voteType) + .build(); + userRestaurantVoteRepository.save(newVote); + return createVoteResponse("Vote added", restaurantId); + } + } catch (ObjectOptimisticLockingFailureException e) { + // 낙관적 락 실패 시 재시도 로직 + return vote(userId, restaurantId, voteType); + } + } + + private VoteResponse createVoteResponse(String message, String restaurantId) { + Integer likes = userRestaurantVoteRepository.countByRestaurantIdAndVoteType(restaurantId, VoteType.LIKE); + Integer dislikes = userRestaurantVoteRepository.countByRestaurantIdAndVoteType(restaurantId, VoteType.DISLIKE); + + return VoteResponse.builder() + .message(message) + .restaurantId(restaurantId) + .likes(likes.longValue()) + .dislikes(dislikes.longValue()) + .build(); + } +} \ No newline at end of file diff --git a/be/src/main/java/com/yumst/be/vote/util/VoteRateLimiter.java b/be/src/main/java/com/yumst/be/vote/util/VoteRateLimiter.java new file mode 100644 index 0000000..f628b97 --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/util/VoteRateLimiter.java @@ -0,0 +1,32 @@ +package com.yumst.be.vote.util; + +import org.springframework.stereotype.Component; + +import java.util.LinkedList; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; + +@Component +public class VoteRateLimiter { + private final Map> userRequests = new ConcurrentHashMap<>(); + private static final int MAX_REQUESTS = 20; + private static final long TIME_WINDOW_MS = 60 * 1000; // 1분 + + public boolean allowRequest(String userId) { + Queue requests = userRequests.computeIfAbsent(userId, k -> new LinkedList<>()); + long now = System.currentTimeMillis(); + + // 요청 시간 큐에서 TIME_WINDOW_MS보다 오래된 요청 제거 + while (!requests.isEmpty() && requests.peek() < now - TIME_WINDOW_MS) { + requests.poll(); + } + + if (requests.size() >= MAX_REQUESTS) { + return false; // 제한 초과 + } + + requests.add(now); + return true; + } +} \ No newline at end of file diff --git a/fe/ios/Podfile.lock b/fe/ios/Podfile.lock index 5e9d70f..afd2e82 100644 --- a/fe/ios/Podfile.lock +++ b/fe/ios/Podfile.lock @@ -31,6 +31,9 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS - sign_in_with_apple (0.0.1): - Flutter - sqflite_darwin (0.0.4): @@ -43,6 +46,7 @@ DEPENDENCIES: - geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`) - google_sign_in_ios (from `.symlinks/plugins/google_sign_in_ios/darwin`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) @@ -64,6 +68,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/google_sign_in_ios/darwin" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" sign_in_with_apple: :path: ".symlinks/plugins/sign_in_with_apple/ios" sqflite_darwin: @@ -79,6 +85,7 @@ SPEC CHECKSUMS: GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 + shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 sign_in_with_apple: c5dcc141574c8c54d5ac99dd2163c0c72ad22418 sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 diff --git a/fe/lib/data/token_interceptor.g.dart b/fe/lib/data/token_interceptor.g.dart index ca1eeb6..75bb291 100644 --- a/fe/lib/data/token_interceptor.g.dart +++ b/fe/lib/data/token_interceptor.g.dart @@ -6,7 +6,7 @@ part of 'token_interceptor.dart'; // RiverpodGenerator // ************************************************************************** -String _$dioHash() => r'2ff592ca6b3df8d117672984b9282af9f7267db3'; +String _$dioHash() => r'6cb2b0516b136198488faab933336266206e2003'; /// See also [dio]. @ProviderFor(dio) diff --git a/fe/lib/main.dart b/fe/lib/main.dart index 8faa1d6..21ac34e 100644 --- a/fe/lib/main.dart +++ b/fe/lib/main.dart @@ -8,16 +8,23 @@ final GlobalKey navigatorKey = GlobalKey(); void main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: ".env"); - runApp(const ProviderScope(child: MyApp())); + + // 앱 실행 + runApp( + const ProviderScope( + child: MyApp(), + ), + ); } -class MyApp extends StatelessWidget { +class MyApp extends ConsumerWidget { const MyApp({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return MaterialApp( debugShowCheckedModeBanner: false, + navigatorKey: navigatorKey, home: const SplashScreen() ); } diff --git a/fe/lib/model/restaurant.dart b/fe/lib/model/restaurant.dart index 04825a8..8047ede 100644 --- a/fe/lib/model/restaurant.dart +++ b/fe/lib/model/restaurant.dart @@ -48,7 +48,11 @@ class Restaurant { roadNameFullAddress = json['roadNameFullAddress']; phoneNumber = json['phoneNumber']; todayOpening = json['todayOpening']; - top2Features = json['top2Features'].cast(); + if (json['top2Features'] != null && json['top2Features'] is List) { + top2Features = List.from(json['top2Features']); + } else { + top2Features = null; + } isScrapped = json['scrapped']; likeCount = json['likeCount']; dislikeCount = json['dislikeCount']; @@ -64,6 +68,8 @@ class Restaurant { data['thumbnailUrl'] = thumbnailUrl; data['fullAddress'] = fullAddress; data['roadNameFullAddress'] = roadNameFullAddress; + data['likeCount'] = likeCount; + data['dislikeCount'] = dislikeCount; return data; } diff --git a/fe/lib/model/vote_restaurant.dart b/fe/lib/model/vote_restaurant.dart new file mode 100644 index 0000000..3dddda6 --- /dev/null +++ b/fe/lib/model/vote_restaurant.dart @@ -0,0 +1,99 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'vote_restaurant.g.dart'; + +@JsonSerializable(explicitToJson: true) +class VoteRestaurant { + final String restaurantId; + final String name; + final String? category; + final String? thumbnailUrl; // Assuming thumbnail might exist based on recommendation page + final double? latitude; + final double? longitude; + final String? businessHours; // Assuming this maps from todayOpening + @JsonKey(name: 'top2Features') + final List? topFeatures; + final int? likeCount; + final int? dislikeCount; + final double? distance; + @JsonKey(name: 'scrapped') + final bool? isScrapped; + @JsonKey(name: 'userVoteStatus') + final String? userVoteStatus; // 추가: 사용자의 투표 상태 (LIKE, DISLIKE, null) + + VoteRestaurant({ + required this.restaurantId, + required this.name, + this.category, + this.thumbnailUrl, // Added based on UI assumption + this.latitude, + this.longitude, + this.businessHours, + this.topFeatures, + this.likeCount, + this.dislikeCount, + this.distance, + this.isScrapped, + this.userVoteStatus, + }); + + factory VoteRestaurant.fromJson(Map json) { + // JSON이 null인 경우 방어 코드 + if (json == null) return VoteRestaurant(restaurantId: '', name: ''); + + // 직접 변환하여 명시적 타입 처리 (필드가 누락되었거나 잘못된 타입인 경우 대비) + final Map parsedJson = {...json}; + + // isScrapped 필드 처리 (서버에서 'isScrapped'로 오는 경우와 'scrapped'로 오는 경우 모두 처리) + bool? isScrapped; + if (parsedJson.containsKey('isScrapped')) { + isScrapped = parsedJson['isScrapped'] is bool ? parsedJson['isScrapped'] : false; + } else if (parsedJson.containsKey('scrapped')) { + isScrapped = parsedJson['scrapped'] is bool ? parsedJson['scrapped'] : false; + } else { + isScrapped = false; + } + parsedJson['scrapped'] = isScrapped; + + // userVoteStatus 필드 명시적 처리 (서버에서 직접 참조) + final userVoteStatus = json['userVoteStatus'] as String?; + parsedJson['userVoteStatus'] = userVoteStatus; + + return _$VoteRestaurantFromJson(parsedJson); + } + + Map toJson() => _$VoteRestaurantToJson(this); + + // copyWith 메소드 추가 + VoteRestaurant copyWith({ + String? restaurantId, + String? name, + String? category, + String? thumbnailUrl, + double? latitude, + double? longitude, + String? businessHours, + List? topFeatures, + int? likeCount, + int? dislikeCount, + double? distance, + bool? isScrapped, + String? userVoteStatus, + }) { + return VoteRestaurant( + restaurantId: restaurantId ?? this.restaurantId, + name: name ?? this.name, + category: category ?? this.category, + thumbnailUrl: thumbnailUrl ?? this.thumbnailUrl, + latitude: latitude ?? this.latitude, + longitude: longitude ?? this.longitude, + businessHours: businessHours ?? this.businessHours, + topFeatures: topFeatures ?? this.topFeatures, + likeCount: likeCount ?? this.likeCount, + dislikeCount: dislikeCount ?? this.dislikeCount, + distance: distance ?? this.distance, + isScrapped: isScrapped ?? this.isScrapped, + userVoteStatus: userVoteStatus ?? this.userVoteStatus, + ); + } +} \ No newline at end of file diff --git a/fe/lib/model/vote_restaurant.g.dart b/fe/lib/model/vote_restaurant.g.dart new file mode 100644 index 0000000..333ea36 --- /dev/null +++ b/fe/lib/model/vote_restaurant.g.dart @@ -0,0 +1,43 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'vote_restaurant.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +VoteRestaurant _$VoteRestaurantFromJson(Map json) => + VoteRestaurant( + restaurantId: json['restaurantId'] as String, + name: json['name'] as String, + category: json['category'] as String?, + thumbnailUrl: json['thumbnailUrl'] as String?, + latitude: (json['latitude'] as num?)?.toDouble(), + longitude: (json['longitude'] as num?)?.toDouble(), + businessHours: json['businessHours'] as String?, + topFeatures: (json['top2Features'] as List?) + ?.map((e) => e as String) + .toList(), + likeCount: (json['likeCount'] as num?)?.toInt(), + dislikeCount: (json['dislikeCount'] as num?)?.toInt(), + distance: (json['distance'] as num?)?.toDouble(), + isScrapped: json['scrapped'] as bool?, + userVoteStatus: json['userVoteStatus'] as String?, + ); + +Map _$VoteRestaurantToJson(VoteRestaurant instance) => + { + 'restaurantId': instance.restaurantId, + 'name': instance.name, + 'category': instance.category, + 'thumbnailUrl': instance.thumbnailUrl, + 'latitude': instance.latitude, + 'longitude': instance.longitude, + 'businessHours': instance.businessHours, + 'top2Features': instance.topFeatures, + 'likeCount': instance.likeCount, + 'dislikeCount': instance.dislikeCount, + 'distance': instance.distance, + 'scrapped': instance.isScrapped, + 'userVoteStatus': instance.userVoteStatus, + }; diff --git a/fe/lib/model/vote_types.dart b/fe/lib/model/vote_types.dart new file mode 100644 index 0000000..ac97a1c --- /dev/null +++ b/fe/lib/model/vote_types.dart @@ -0,0 +1,4 @@ +enum VoteType { + LIKE, + DISLIKE, +} \ No newline at end of file diff --git a/fe/lib/provider/vote_state_provider.dart b/fe/lib/provider/vote_state_provider.dart new file mode 100644 index 0000000..c3b5a08 --- /dev/null +++ b/fe/lib/provider/vote_state_provider.dart @@ -0,0 +1,404 @@ +import 'package:fe/data/location_service.dart'; +import 'package:fe/model/vote_restaurant.dart'; +import 'package:fe/repository/vote_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:geolocator/geolocator.dart' hide LocationServiceDisabledException, LocationPermissionDeniedException, LocationPermissionPermanentlyDeniedException, LocationRetrievalException; +import 'dart:async'; +import 'package:dio/dio.dart'; +import 'package:fe/model/vote_types.dart'; +import 'dart:math'; + +// 1. 상태 모델 정의 +class VoteRestaurantState { + final VoteRestaurant restaurant; + final VoteType? userVote; + final bool isVoting; + final bool isScraped; // 스크랩 상태를 별도 필드로 관리 + + VoteRestaurantState({ + required this.restaurant, + this.userVote, + this.isVoting = false, + required this.isScraped, // 초기화 필요 + }); + + VoteRestaurantState copyWith({ + VoteRestaurant? restaurant, + VoteType? userVote, + bool? isVoting, + bool? isScraped, // 스크랩 상태 복사 추가 + bool forceNullUserVote = false, + }) { + return VoteRestaurantState( + restaurant: restaurant ?? this.restaurant, + userVote: forceNullUserVote ? null : (userVote ?? this.userVote), + isVoting: isVoting ?? this.isVoting, + isScraped: isScraped ?? this.isScraped, // 스크랩 상태 복사 + ); + } + + // 임시 초기 데이터 변환 (실제 VoteRestaurant 모델 업데이트 필요) + factory VoteRestaurantState.fromRestaurant(VoteRestaurant restaurant) { + // API에서 받은 userVoteStatus 파싱 + VoteType? initialVote; + String? voteStatusFromApi = restaurant.userVoteStatus?.trim().toUpperCase(); + + if (voteStatusFromApi == 'LIKE') { + initialVote = VoteType.LIKE; + } else if (voteStatusFromApi == 'DISLIKE') { + initialVote = VoteType.DISLIKE; + } + + VoteRestaurant updatedRestaurant = restaurant.copyWith( + userVoteStatus: restaurant.userVoteStatus + ); + + return VoteRestaurantState( + restaurant: updatedRestaurant, + userVote: initialVote, + isVoting: false, + isScraped: restaurant.isScrapped ?? false, // 스크랩 상태 초기화 + ); + } +} + +// 페이지네이션 포함된 상태 클래스 +@immutable +class VotePageCombinedState { + final List restaurants; + final bool isLoadingInitial; // 초기 로딩 중? + final bool isLoadingNextPage; // 다음 페이지 로딩 중? + final bool hasMore; // 더 불러올 페이지가 있는가? + final Object? error; // 에러 객체 + final String? errorMessage; // 에러 메시지 + final StackTrace? stackTrace; // 에러 스택 트레이스 + final String currentSort; // 현재 정렬 기준 + + const VotePageCombinedState({ + this.restaurants = const [], + this.isLoadingInitial = true, + this.isLoadingNextPage = false, + this.hasMore = true, + this.error, + this.errorMessage, + this.stackTrace, + this.currentSort = 'distance', + }); + + VotePageCombinedState copyWith({ + List? restaurants, + bool? isLoadingInitial, + bool? isLoadingNextPage, + bool? hasMore, + Object? error, + String? errorMessage, + StackTrace? stackTrace, + String? currentSort, + bool clearError = false, // 에러를 명시적으로 지울지 여부 + }) { + return VotePageCombinedState( + restaurants: restaurants ?? this.restaurants, + isLoadingInitial: isLoadingInitial ?? this.isLoadingInitial, + isLoadingNextPage: isLoadingNextPage ?? this.isLoadingNextPage, + hasMore: hasMore ?? this.hasMore, + error: clearError ? null : error ?? this.error, + errorMessage: clearError ? null : errorMessage ?? this.errorMessage, + stackTrace: clearError ? null : stackTrace ?? this.stackTrace, + currentSort: currentSort ?? this.currentSort, + ); + } +} + +// StateNotifier 리팩토링 +class VotePageStateNotifier extends StateNotifier { + final dynamic _read; + final VoteRepository _voteRepository; + final LocationService? _locationService; + int _currentPage = 0; + final int _pageSize = 10; // 페이지 당 아이템 수 + + // 위치 정보를 저장할 변수 + Position? _lastPosition; + + VotePageStateNotifier({ + required VoteRepository voteRepository, + required dynamic read, + LocationService? locationService, + }) : _voteRepository = voteRepository, + _read = read, + _locationService = locationService, + super(const VotePageCombinedState()) { + _fetchInitialRestaurants(); + } + + // 초기 데이터 또는 새로고침 시 호출 + Future _fetchInitialRestaurants() async { + try { + if (_locationService == null) { + throw Exception('위치 서비스를 사용할 수 없습니다.'); + } + + state = state.copyWith( + isLoadingInitial: true, + clearError: true, + ); + + // 위치 정보 요청 + _lastPosition = await _locationService!.getPosition(); + + if (_lastPosition == null) { + throw Exception('위치 정보를 가져올 수 없습니다.'); + } + + // 정렬 기준 설정하여 API 요청 + final restaurants = await _voteRepository.getVotableRestaurants( + latitude: _lastPosition!.latitude, + longitude: _lastPosition!.longitude, + sort: state.currentSort, + page: 0, // 초기화 시에는 항상 첫 페이지 + ); + + _currentPage = 0; // 페이지 카운터 리셋 + + // API 응답을 VoteRestaurantState 목록으로 변환 + final initialRestaurantStates = restaurants.map((r) { + // 서버에서 받은 VoteRestaurant 객체로 초기 상태 생성 + return VoteRestaurantState.fromRestaurant(r); + }).toList(); + + // 상태 업데이트 + state = state.copyWith( + isLoadingInitial: false, + restaurants: initialRestaurantStates, + hasMore: restaurants.length >= _pageSize, + ); + } catch (e, stackTrace) { + print("API 요청 오류: $e"); + state = state.copyWith( + isLoadingInitial: false, + error: e, + stackTrace: stackTrace, + hasMore: false, + ); + } + } + + // 투표 상태 변경에 따른 좋아요/싫어요 개수 조정 메소드 + VoteRestaurant adjustVoteCounts( + VoteRestaurant restaurant, + VoteType? oldVote, + VoteType? newVote + ) { + int likeCount = restaurant.likeCount ?? 0; + int dislikeCount = restaurant.dislikeCount ?? 0; + + // 이전 투표 상태에 따른 카운트 조정 + if (oldVote == VoteType.LIKE) { + likeCount = max(0, likeCount - 1); // 이전에 좋아요했으면 좋아요 -1 + } else if (oldVote == VoteType.DISLIKE) { + dislikeCount = max(0, dislikeCount - 1); // 이전에 싫어요했으면 싫어요 -1 + } + + // 새 투표 상태에 따른 카운트 조정 + if (newVote == VoteType.LIKE) { + likeCount += 1; // 새 투표가 좋아요면 좋아요 +1 + } else if (newVote == VoteType.DISLIKE) { + dislikeCount += 1; // 새 투표가 싫어요면 싫어요 +1 + } + + return restaurant.copyWith( + likeCount: likeCount, + dislikeCount: dislikeCount, + ); + } + + // 투표 처리 함수 + Future vote(String restaurantId, VoteType? newVoteType) async { + final index = state.restaurants.indexWhere((r) => r.restaurant.restaurantId == restaurantId); + if (index == -1) return; // 해당 식당이 목록에 없으면 처리하지 않음 + + final currentRestaurant = state.restaurants[index].restaurant; + final currentVote = state.restaurants[index].userVote; + + // 이미 같은 투표 상태면 중복 요청 방지 + if (currentVote == newVoteType) return; + + // 낙관적 UI 업데이트 + final updatedRestaurants = List.from(state.restaurants); + updatedRestaurants[index] = updatedRestaurants[index].copyWith( + userVote: newVoteType, + restaurant: adjustVoteCounts(currentRestaurant, currentVote, newVoteType), + forceNullUserVote: newVoteType == null, + isVoting: true, + ); + + state = state.copyWith(restaurants: updatedRestaurants); + + try { + // 서버에 직접 투표 요청 + await _voteRepository.voteRestaurant( + restaurantId: restaurantId, + voteType: newVoteType + ); + + // 투표 상태를 "로딩 아님"으로 업데이트 + final finalRestaurants = List.from(state.restaurants); + final finalIndex = finalRestaurants.indexWhere((r) => r.restaurant.restaurantId == restaurantId); + + if (finalIndex != -1) { + finalRestaurants[finalIndex] = finalRestaurants[finalIndex].copyWith( + isVoting: false, + ); + + state = state.copyWith(restaurants: finalRestaurants); + } + } catch (e) { + print("투표 처리 오류: $e"); + // 오류 발생 시에도 isVoting 상태 해제 + final rollbackRestaurants = List.from(state.restaurants); + final rollbackIndex = rollbackRestaurants.indexWhere((r) => r.restaurant.restaurantId == restaurantId); + + if (rollbackIndex != -1) { + rollbackRestaurants[rollbackIndex] = rollbackRestaurants[rollbackIndex].copyWith( + isVoting: false, + // 원래 투표 상태로 롤백 + userVote: currentVote, + forceNullUserVote: currentVote == null, + restaurant: adjustVoteCounts( + rollbackRestaurants[rollbackIndex].restaurant, + newVoteType, + currentVote + ), + ); + + state = state.copyWith(restaurants: rollbackRestaurants); + } + } + } + + // 스크랩 토글 함수 + Future toggleScrap({required String restaurantId}) async { + final index = state.restaurants.indexWhere((rs) => rs.restaurant.restaurantId == restaurantId); + if (index == -1) return false; + + final restaurant = state.restaurants[index].restaurant; + final currentScrapStatus = state.restaurants[index].isScraped; + final newScrapStatus = !currentScrapStatus; + + // 낙관적 UI 업데이트 + final updatedRestaurants = List.from(state.restaurants); + updatedRestaurants[index] = updatedRestaurants[index].copyWith( + isScraped: newScrapStatus, + restaurant: restaurant.copyWith( + isScrapped: newScrapStatus + ), + ); + + state = state.copyWith(restaurants: updatedRestaurants); + + try { + // API 호출 + final res = await _voteRepository.toggleScrap(restaurantId); + print("스크랩 토글 응답: $res"); + return res; + } catch (e) { + // API 호출 실패 시 UI 롤백 + final rollbackRestaurants = List.from(state.restaurants); + rollbackRestaurants[index] = rollbackRestaurants[index].copyWith( + isScraped: currentScrapStatus, + restaurant: rollbackRestaurants[index].restaurant.copyWith( + isScrapped: currentScrapStatus + ), + ); + + state = state.copyWith(restaurants: rollbackRestaurants); + print("스크랩 토글 실패: $e"); + throw e; + } + } + + // 새로고침 기능 + Future refresh() async { + await _fetchInitialRestaurants(); + } + + // 정렬 기준 변경 + Future changeSort(String sortCriteria) async { + // 현재 정렬과 동일하면 무시 + if (sortCriteria == state.currentSort) return; + + state = state.copyWith(currentSort: sortCriteria); + await _fetchInitialRestaurants(); + } + + // 다음 페이지 로드 + Future loadNextPage() async { + if (state.isLoadingNextPage || !state.hasMore) return; + + try { + state = state.copyWith(isLoadingNextPage: true); + + _currentPage++; + + // 위치 정보 없으면 마지막 위치 재사용 + if (_lastPosition == null) { + throw Exception('위치 정보가 없습니다.'); + } + + // 다음 페이지 레스토랑 목록 가져오기 + final nextRestaurants = await _voteRepository.getVotableRestaurants( + latitude: _lastPosition!.latitude, + longitude: _lastPosition!.longitude, + sort: state.currentSort, + page: _currentPage, + ); + + // 이미 로드된 식당 ID 목록 + final existingRestaurantIds = state.restaurants.map((r) => r.restaurant.restaurantId).toSet(); + + // 새 응답에서 중복되지 않은 항목만 필터링 + final uniqueNewRestaurants = nextRestaurants.where( + (r) => !existingRestaurantIds.contains(r.restaurantId) + ).toList(); + + // 새 레스토랑 상태 객체 생성 + final newRestaurantStates = uniqueNewRestaurants.map((r) { + return VoteRestaurantState.fromRestaurant(r); + }).toList(); + + // 기존 목록에 새 레스토랑 추가 + final allRestaurantStates = [...state.restaurants, ...newRestaurantStates]; + + state = state.copyWith( + isLoadingNextPage: false, + restaurants: allRestaurantStates, + hasMore: nextRestaurants.length >= _pageSize, + ); + } catch (e, stackTrace) { + print("다음 페이지 로드 오류: $e"); + state = state.copyWith( + isLoadingNextPage: false, + error: e, + stackTrace: stackTrace, + ); + } + } +} + +// StateNotifierProvider +final votePageStateProvider = StateNotifierProvider((ref) { + final voteRepository = ref.watch(voteRepositoryProvider); + final locationService = ref.watch(locationServiceProvider); + + return VotePageStateNotifier( + voteRepository: voteRepository, + read: ref.read, + locationService: locationService, + ); +}); + +// 네비게이션 키 provider +final navigatorKeyProvider = Provider>((ref) { + return GlobalKey(); +}); \ No newline at end of file diff --git a/fe/lib/repository/auth_repository.dart b/fe/lib/repository/auth_repository.dart index 09cd2cd..cb0550b 100644 --- a/fe/lib/repository/auth_repository.dart +++ b/fe/lib/repository/auth_repository.dart @@ -1,4 +1,3 @@ - import 'package:dio/dio.dart'; import 'package:fe/data/secure_storage.dart'; import 'package:fe/data/token_interceptor.dart'; diff --git a/fe/lib/repository/vote_repository.dart b/fe/lib/repository/vote_repository.dart new file mode 100644 index 0000000..c647e22 --- /dev/null +++ b/fe/lib/repository/vote_repository.dart @@ -0,0 +1,138 @@ +import 'package:dio/dio.dart'; +import 'package:fe/data/secure_storage.dart'; +import 'package:fe/data/token_interceptor.dart'; // Assuming Dio setup is similar +import 'package:fe/model/vote_restaurant.dart'; +import 'package:fe/model/vote_types.dart'; // VoteType만 가져오기 +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'vote_repository.g.dart'; + +@Riverpod(keepAlive: true) +VoteRepository voteRepository(VoteRepositoryRef ref) { + final dio = ref.watch(dioProvider); // Assuming you have a dioProvider + final storage = ref.watch(secureStorageProvider); + return VoteRepository(dio: dio, storage: storage); +} + +class VoteRepository { + final Dio dio; + final SecureStorage storage; + + VoteRepository({required this.dio, required this.storage}); + + Future> getVotableRestaurants({ + required double latitude, + required double longitude, + double radius = 1.0, // km 단위 사용 + int page = 0, + int size = 10, + required String sort, // required로 변경 또는 기본값 설정 유지 + }) async { + try { + final userId = await storage.readUserId(); + if (userId == null) { + throw Exception('User ID not found'); + } + + final response = await dio.get( + 'http://localhost:8080/api/vote/v1/restaurants', + options: Options(headers: {'userId': userId}), + queryParameters: { + 'latitude': latitude, + 'longitude': longitude, + 'radius': radius, // km 단위 그대로 전달 + 'page': page, + 'size': size, + 'sort': sort, // 전달받은 sort 값 사용 + }, + ); + + if (response.statusCode == 200 && response.data is List) { + final List responseData = response.data; + return responseData + .map((data) => VoteRestaurant.fromJson(data as Map)) + .toList(); + } else { + throw Exception('Failed to load votable restaurants: Status code ${response.statusCode}'); + } + } on DioException catch (e) { + print('DioException fetching votable restaurants: ${e.message}'); + print('Error response: ${e.response?.data}'); + throw Exception('Failed to load votable restaurants: ${e.message}'); + } catch (e) { + print('Error fetching votable restaurants: $e'); + throw Exception('Failed to load votable restaurants.'); + } + } + + // 투표 API 호출 메소드 + Future> voteRestaurant({ + required String restaurantId, + required VoteType? voteType, // 좋아요(LIKE), 싫어요(DISLIKE) 또는 null(투표 취소) + }) async { + try { + final userId = await storage.readUserId(); + if (userId == null) { + throw Exception('User ID not found'); + } + + // PATCH 메서드로 투표 요청 + final response = await dio.patch( + 'http://localhost:8080/api/vote/v1/restaurants/$restaurantId', + options: Options(headers: {'userId': userId}), + data: { + 'voteType': voteType?.name, // Enum 이름을 문자열로 변환 (LIKE, DISLIKE) 또는 null + }, + ); + + if (response.statusCode == 200 && response.data is Map) { + // 성공 시 응답 데이터 반환 (message, likes, dislikes 포함) + return response.data as Map; + } else { + throw Exception('Failed to vote: Status code ${response.statusCode}'); + } + } on DioException catch (e) { + print('DioException voting: ${e.message}'); + print('Error response: ${e.response?.data}'); + // API 에러 메시지를 전달하도록 수정 (백엔드 응답 구조에 따라 조정) + final errorMessage = e.response?.data?['message'] ?? e.message; + throw Exception('Failed to vote: $errorMessage'); + } catch (e) { + print('Error voting: $e'); + throw Exception('Failed to vote.'); + } + } + + // 스크랩 토글 메소드 + Future toggleScrap(String restaurantId) async { + try { + final userId = await storage.readUserId(); + if (userId == null) { + throw Exception('User ID not found'); + } + + // PATCH 메소드 사용 + final response = await dio.patch( + 'http://localhost:8080/api/user/v1/scrap/$restaurantId', + options: Options(headers: {'userId': userId}), + ); + + if (response.statusCode == 200 && response.data is Map) { + // 스크랩 상태 반환 (true: 스크랩됨, false: 스크랩 해제됨) + final scrapped = response.data['scrapped'] as bool? ?? false; + return scrapped; + } else { + throw Exception('Failed to set scrap: Status code ${response.statusCode}'); + } + } on DioException catch (e) { + print('DioException setting scrap: ${e.message}'); + print('Error response: ${e.response?.data}'); + final errorMessage = e.response?.data?['message'] ?? e.message; + throw Exception('Failed to set scrap: $errorMessage'); + } catch (e) { + print('Error setting scrap: $e'); + throw Exception('Failed to set scrap.'); + } + } +} \ No newline at end of file diff --git a/fe/lib/repository/vote_repository.g.dart b/fe/lib/repository/vote_repository.g.dart new file mode 100644 index 0000000..1c0d6c3 --- /dev/null +++ b/fe/lib/repository/vote_repository.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'vote_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$voteRepositoryHash() => r'a60abca090f14c5426233b7ac7bea46f94d920d4'; + +/// See also [voteRepository]. +@ProviderFor(voteRepository) +final voteRepositoryProvider = Provider.internal( + voteRepository, + name: r'voteRepositoryProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$voteRepositoryHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef VoteRepositoryRef = ProviderRef; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/fe/lib/view/screen/main_page.dart b/fe/lib/view/screen/main_page.dart index 26efb2b..7336ef4 100644 --- a/fe/lib/view/screen/main_page.dart +++ b/fe/lib/view/screen/main_page.dart @@ -1,9 +1,11 @@ import 'package:fe/view/screen/my_page.dart'; import 'package:fe/view/screen/recommendation_page.dart'; +import 'package:fe/view/screen/vote_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../data/restaurant_paginator.dart'; +import '../../provider/vote_state_provider.dart'; class MainScreen extends ConsumerStatefulWidget { const MainScreen({super.key}); @@ -19,7 +21,7 @@ class _MainScreenState extends ConsumerState { static final List _pages = [ RecommendationPage(), - Center(child: Text('투표 기능은 준비중입니다')), + VotePage(), MyPageScreen(), ]; @@ -32,12 +34,28 @@ class _MainScreenState extends ConsumerState { // 더블 탭 처리 if (index == 0 && _selectedIndex == 0) { ref.read(restaurantPaginationProvider.notifier).loadInitial(); + } else if (index == 1 && _selectedIndex == 1) { + ref.read(votePageStateProvider.notifier).refresh(); } _lastTapTime = null; _lastTappedIndex = null; } else { // 싱글 탭 처리 setState(() => _selectedIndex = index); + + // 탭 변경 시 데이터 로드 + Future.delayed(const Duration(milliseconds: 100), () { + if (!mounted) return; + + if (index == 0) { + // 추천 페이지로 이동 시 + ref.read(restaurantPaginationProvider.notifier).loadInitial(); + } else if (index == 1) { + // 투표 페이지로 이동 시 + ref.read(votePageStateProvider.notifier).refresh(); + } + }); + _lastTapTime = currentTime; _lastTappedIndex = index; } diff --git a/fe/lib/view/screen/recommendation_page.dart b/fe/lib/view/screen/recommendation_page.dart index ee626e5..b266408 100644 --- a/fe/lib/view/screen/recommendation_page.dart +++ b/fe/lib/view/screen/recommendation_page.dart @@ -8,9 +8,9 @@ import '../../model/restaurant.dart'; import '../widget/reels_style_card.dart'; final restaurantsFutureProvider = FutureProvider>((ref) async { - final repository = ref.watch(restaurantRepositoryProvider); final locationService = ref.watch(locationServiceProvider); final position = await locationService.getPosition(); + final repository = ref.watch(restaurantRepositoryProvider); return repository.getRestaurantsV0(position); }); diff --git a/fe/lib/view/screen/vote_page.dart b/fe/lib/view/screen/vote_page.dart new file mode 100644 index 0000000..45999fa --- /dev/null +++ b/fe/lib/view/screen/vote_page.dart @@ -0,0 +1,216 @@ +import 'package:fe/provider/vote_state_provider.dart'; +import 'package:fe/repository/vote_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../widget/vote_restaurant_card.dart'; + +// 4. VotePage 위젯 +class VotePage extends ConsumerStatefulWidget { + const VotePage({super.key}); + + @override + ConsumerState createState() => _VotePageState(); +} + +class _VotePageState extends ConsumerState { + final ScrollController _scrollController = ScrollController(); + String _selectedSort = 'distance'; + + final Map _sortOptions = { + 'distance': '거리순', + 'likes': '좋아요 순', + 'dislikes': '싫어요 순', + }; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_scrollListener); + + // 페이지 진입 시 명시적으로 데이터 로드 + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(votePageStateProvider.notifier).changeSort(_selectedSort); + }); + } + + @override + void dispose() { + _scrollController.removeListener(_scrollListener); + _scrollController.dispose(); + super.dispose(); + } + + void _scrollListener() { + final double maxScroll = _scrollController.position.maxScrollExtent; + final double currentScroll = _scrollController.position.pixels; + final double triggerThreshold = maxScroll * 0.8; + + if (currentScroll >= triggerThreshold) { + ref.read(votePageStateProvider.notifier).loadNextPage(); + } + } + + @override + Widget build(BuildContext context) { + final votePageState = ref.watch(votePageStateProvider); + + return Scaffold( + body: SafeArea( + child: Column( + children: [ + // 정렬 버튼 영역 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + DropdownButton( + value: _selectedSort, + icon: const Icon(Icons.arrow_drop_down, size: 20), + elevation: 16, + style: TextStyle(color: Colors.deepPurple, fontSize: 14), + underline: Container( height: 0, color: Colors.transparent,), + onChanged: (String? newValue) { + if (newValue != null && newValue != _selectedSort) { + setState(() { + _selectedSort = newValue; + }); + ref.read(votePageStateProvider.notifier).changeSort(newValue); + } + }, + items: _sortOptions.entries + .map>((MapEntry entry) { + return DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + ); + }).toList(), + ), + ], + ), + ), + // 식당 목록 영역 + Expanded( + child: _buildRestaurantList(votePageState), + ), + ], + ), + ), + ); + } + + // 식당 목록 UI 빌드 로직 분리 + Widget _buildRestaurantList(VotePageCombinedState stateData) { + // 1. 초기 로딩 중일 때 + if (stateData.isLoadingInitial) { + return const Center(child: CircularProgressIndicator()); + } + // 2. 에러가 있고, 데이터가 없을 때 (초기 로딩 실패) + if (stateData.error != null && stateData.restaurants.isEmpty) { + String errorMessage = stateData.error.toString(); + // Exception 메시지에서 'Exception:' 부분 제거 + if (errorMessage.startsWith('Exception: ')) { + errorMessage = errorMessage.substring(11); + } + + return Center( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, size: 48, color: Colors.grey), + const SizedBox(height: 16), + Text( + errorMessage, + style: const TextStyle(fontSize: 16, color: Colors.black87), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: () => ref.read(votePageStateProvider.notifier).refresh(), + icon: const Icon(Icons.refresh), + label: const Text('다시 시도'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + ) + ], + ), + ) + ); + } + // 3. 데이터가 있을 때 (로딩 성공 또는 페이지네이션 중) + return Theme( + // 스크롤바 스타일 테마 설정 + data: Theme.of(context).copyWith( + scrollbarTheme: ScrollbarThemeData( + thumbColor: MaterialStateProperty.all(Colors.white.withOpacity(0.5)), + thickness: MaterialStateProperty.all(6.0), + radius: const Radius.circular(8.0), + thumbVisibility: MaterialStateProperty.all(true), + ), + ), + child: Scrollbar( + controller: _scrollController, + child: RefreshIndicator( + onRefresh: () => ref.read(votePageStateProvider.notifier).refresh(), + child: GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 4, + mainAxisSpacing: 4, + childAspectRatio: 0.7, + ), + itemCount: stateData.restaurants.length + (stateData.isLoadingNextPage || (stateData.error != null && !stateData.isLoadingInitial) ? 1 : 0), + itemBuilder: (context, index) { + if (index == stateData.restaurants.length) { + if (stateData.isLoadingNextPage) { + return const Center(child: Padding( + padding: EdgeInsets.all(8.0), + child: CircularProgressIndicator(strokeWidth: 2), + )); + } else if (stateData.error != null && !stateData.isLoadingInitial) { + return Center(child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text("다음 페이지 로딩 실패", style: TextStyle(color: Colors.red)), + SizedBox(height: 4), + // 여기서 '다시 시도' 버튼을 추가할 수도 있습니다. + // ElevatedButton(onPressed: () => ref.read(votePageStateProvider.notifier).loadNextPage(), child: Text('다음 페이지 재시도')) + ], + ) + )); + } + return const SizedBox.shrink(); + } + final restaurantState = stateData.restaurants[index]; + return VoteRestaurantCard( + key: ValueKey(restaurantState.restaurant.restaurantId), + restaurant: restaurantState.restaurant, + userVote: restaurantState.userVote, + isVoting: restaurantState.isVoting, + isScraped: restaurantState.isScraped, + onVotePressed: (restaurant, voteType) { + ref.read(votePageStateProvider.notifier).vote( + restaurant.restaurantId, + voteType, + ); + }, + onScrapPressed: (restaurant) async { + return await ref.read(votePageStateProvider.notifier).toggleScrap( + restaurantId: restaurant.restaurantId, + ); + }, + ); + }, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/fe/lib/view/widget/vote_restaurant_card.dart b/fe/lib/view/widget/vote_restaurant_card.dart new file mode 100644 index 0000000..e3fc1ae --- /dev/null +++ b/fe/lib/view/widget/vote_restaurant_card.dart @@ -0,0 +1,239 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:fe/model/vote_restaurant.dart'; +import 'package:fe/model/vote_types.dart'; // VoteType을 여기서 가져옴 +// votePageStateProvider import 제거 +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +// VoteRestaurantCard 위젯 정의 수정 (StatelessWidget으로 변경 가능하나 일단 유지) +class VoteRestaurantCard extends ConsumerStatefulWidget { + final VoteRestaurant restaurant; + final VoteType? userVote; + final bool isVoting; + final bool isScraped; // 스크랩 상태 추가 + final Function(VoteRestaurant, VoteType?) onVotePressed; + final Function(VoteRestaurant)? onScrapPressed; + + const VoteRestaurantCard({ + super.key, + required this.restaurant, + required this.userVote, + required this.isVoting, + required this.isScraped, // 스크랩 상태 필수 파라미터로 추가 + required this.onVotePressed, + this.onScrapPressed, + }); + + @override + ConsumerState createState() => _VoteRestaurantCardState(); +} + +class _VoteRestaurantCardState extends ConsumerState { + // 로딩 상태만 관리 + bool _isLoadingScrap = false; + + // 투표 버튼 클릭 이벤트 핸들러 + void _handleVoteButtonPress(VoteType voteType) { + if (widget.onVotePressed != null) { + // 현재 투표 상태와 같은 버튼을 누르면 '취소'로 처리 + final VoteType? newVoteType = widget.userVote == voteType ? null : voteType; + widget.onVotePressed(widget.restaurant, newVoteType); + } + } + + // 스크랩 버튼 클릭 이벤트 핸들러 (간소화) + void _handleScrapButtonPress() async { + if (_isLoadingScrap) return; // 중복 클릭 방지 + + setState(() { + _isLoadingScrap = true; + }); + + try { + if (widget.onScrapPressed != null) { + // 스크랩 토글 호출 + await widget.onScrapPressed!(widget.restaurant); + } + } catch (e) { + print('스크랩 처리 중 오류: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('스크랩 처리 중 오류가 발생했습니다.')) + ); + } + } finally { + if (mounted) { + setState(() { + _isLoadingScrap = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + final restaurant = widget.restaurant; + final likeCount = restaurant.likeCount ?? 0; + final dislikeCount = restaurant.dislikeCount ?? 0; + final distance = restaurant.distance != null ? '${restaurant.distance!.round()}m' : ''; + + return Card( + clipBehavior: Clip.antiAlias, + elevation: 2, + child: Stack( + fit: StackFit.expand, + children: [ + // 배경 이미지 + if (restaurant.thumbnailUrl?.isNotEmpty == true) + CachedNetworkImage( + imageUrl: restaurant.thumbnailUrl!, + fit: BoxFit.cover, + placeholder: (context, url) => const Center( + child: CircularProgressIndicator(strokeWidth: 2)), + errorWidget: (context, url, error) => + const Center(child: Icon(Icons.image_not_supported, color: Colors.grey)), + ) + else + Container( + color: Colors.grey[300], + child: const Center( + child: Icon(Icons.image_not_supported, color: Colors.grey)), + ), + + // 하단 정보 패널 (식당 이름, 거리, 카테고리만) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withOpacity(0.9), + Colors.transparent + ], + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // 식당 이름 + Text( + restaurant.name, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 16), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + // 거리 + Padding( + padding: const EdgeInsets.only(top: 2, bottom: 2), + child: Text( + distance, + style: const TextStyle( + color: Colors.white70, fontSize: 13), + ), + ), + // 카테고리 + Text( + restaurant.category ?? '', + style: const TextStyle( + color: Colors.white70, fontSize: 13), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + + // 투표 및 스크랩 버튼 (우측 정렬) + Positioned( + bottom: 45, + right: 0, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 좋아요 버튼 + IconButton( + icon: Icon( + widget.userVote == VoteType.LIKE + ? Icons.thumb_up + : Icons.thumb_up_outlined, + color: Colors.white, + size: 22, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => _handleVoteButtonPress(VoteType.LIKE), + ), + Text( + likeCount.toString(), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + const SizedBox(height: 2), + // 싫어요 버튼 + IconButton( + icon: Icon( + widget.userVote == VoteType.DISLIKE + ? Icons.thumb_down + : Icons.thumb_down_outlined, + color: Colors.white, + size: 22, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () => _handleVoteButtonPress(VoteType.DISLIKE), + ), + Text( + dislikeCount.toString(), + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + const SizedBox(height: 2), + // 스크랩 버튼 - isScraped 상태 사용 + IconButton( + icon: _isLoadingScrap + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : Icon( + widget.isScraped ? Icons.bookmark : Icons.bookmark_outline, + color: Colors.white, + size: 22, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: _handleScrapButtonPress, + ), + ], + ), + ), + + // 로딩 인디케이터 + if (widget.isVoting) // 스크랩 로딩(_isLoadingScrap)과 별개로 투표 로딩 상태 + Positioned.fill( + child: Container( + color: Colors.black.withOpacity(0.3), + child: const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ), + ), + ), + ), + ], + ), + ); + } +}