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/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..2d92b55 --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/controller/UserRestaurantVoteController.java @@ -0,0 +1,46 @@ +package com.yumst.be.vote.controller; + +import com.yumst.be.restaurant.vo.ResponseRestaurant; +import com.yumst.be.vote.dto.BatchVoteRequest; +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, + @Valid @RequestBody VoteRequest request) { + return ResponseEntity.ok(userRestaurantVoteService.vote(userId, restaurantId, request.getVoteType())); + } + + @PostMapping("/batch") + public ResponseEntity> batchVote( + @RequestHeader("userId") String userId, + @Valid @RequestBody BatchVoteRequest request) { + List responses = userRestaurantVoteService.batchVote(userId, request.getVotes()); + return ResponseEntity.ok(responses); + } +} \ 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/BatchVoteRequest.java b/be/src/main/java/com/yumst/be/vote/dto/BatchVoteRequest.java new file mode 100644 index 0000000..a23445e --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/dto/BatchVoteRequest.java @@ -0,0 +1,30 @@ +package com.yumst.be.vote.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +import com.yumst.be.vote.domain.VoteType; + +@Getter +@NoArgsConstructor +public class BatchVoteRequest { + + @NotEmpty(message = "투표 요청은 최소 1개 이상이어야 합니다.") + @Size(max = 20, message = "한 번에 최대 20개까지 투표 요청을 처리할 수 있습니다.") + private List<@Valid SingleVoteRequest> votes; + + @Getter + @NoArgsConstructor + public static class SingleVoteRequest { + @NotNull(message = "레스토랑 ID는 필수입니다.") + private String restaurantId; + + private VoteType voteType; + } +} \ No newline at end of file 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..f00e528 --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/dto/VoteRequest.java @@ -0,0 +1,11 @@ +package com.yumst.be.vote.dto; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import com.yumst.be.vote.domain.VoteType; + +@Getter +public class VoteRequest { + @NotNull(message = "투표 타입은 필수입니다.") + 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..7c588f5 --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/exception/VoteErrorCode.java @@ -0,0 +1,28 @@ +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, "요청이 너무 많습니다. 잠시 후 다시 시도해 주세요."), + BATCH_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "배치 요청 개수가 제한을 초과했습니다."); + + 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..1c1ce07 --- /dev/null +++ b/be/src/main/java/com/yumst/be/vote/service/UserRestaurantVoteService.java @@ -0,0 +1,183 @@ +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.BatchVoteRequest; +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 com.yumst.be.restaurant.exception.RestaurantException; +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.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +@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); + + 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); + } + } + + @Transactional + public List batchVote(String userId, List requests) { + // 요청 레이트 체크 + if (!voteRateLimiter.allowRequest(userId)) { + throw new VoteException(VoteErrorCode.TOO_MANY_REQUESTS); + } + + // 배치 요청 개수 검증 (컨트롤러에서도 @Valid로 검증되지만 추가 검증) + if (requests.size() > 20) { + throw new VoteException(VoteErrorCode.BATCH_SIZE_EXCEEDED); + } + + // 사용자 존재 여부 확인 (한 번만 검증) + userService.validateUserExists(userId); + + List responses = new ArrayList<>(); + + // 각 요청에 대해 투표 처리 + for (BatchVoteRequest.SingleVoteRequest request : requests) { + String restaurantId = request.getRestaurantId(); + VoteType requestedVoteType = request.getVoteType(); + String message; + + try { + // 식당 존재 여부 확인 + restaurantService.validateRestaurantExists(restaurantId); + + // 투표 처리 + Optional existingVoteOpt = userRestaurantVoteRepository + .findByUserIdAndRestaurantId(userId, restaurantId); + + if (requestedVoteType == null) { + // 요청된 타입이 null (투표 취소) + if (existingVoteOpt.isPresent()) { + userRestaurantVoteRepository.delete(existingVoteOpt.get()); + message = "Vote removed"; + } else { + message = "Vote already absent"; // 이미 투표가 없는 상태 + } + } else { + // 요청된 타입이 null이 아님 (LIKE 또는 DISLIKE) + if (existingVoteOpt.isPresent()) { + UserRestaurantVote vote = existingVoteOpt.get(); + if (vote.getVoteType() == requestedVoteType) { + message = "Vote unchanged"; // 이미 같은 타입으로 투표됨 + } else { + vote.updateVote(requestedVoteType); + message = "Vote updated"; + } + } else { + UserRestaurantVote newVote = UserRestaurantVote.builder() + .userId(userId) + .restaurantId(restaurantId) + .voteType(requestedVoteType) + .build(); + userRestaurantVoteRepository.save(newVote); + message = "Vote added"; + } + } + + // 응답 생성 + responses.add(createVoteResponse(message, restaurantId)); + } catch (Exception e) { + // 개별 요청 실패는 전체 배치를 실패시키지 않음 + // 대신 에러 메시지를 반환 + VoteResponse errorResponse = VoteResponse.builder() + .message("Error processing vote: " + e.getMessage()) // 에러 메시지 개선 + .restaurantId(restaurantId) + .likes(0L) // 에러 시 카운트는 0으로 반환 + .dislikes(0L) + .build(); + responses.add(errorResponse); + } + } + + return responses; + } + + 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..3173477 100644 --- a/fe/lib/main.dart +++ b/fe/lib/main.dart @@ -1,3 +1,4 @@ +import 'package:fe/provider/managers_provider.dart'; import 'package:fe/view/screen/splash_page.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; @@ -8,16 +9,35 @@ final GlobalKey navigatorKey = GlobalKey(); void main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: ".env"); - runApp(const ProviderScope(child: MyApp())); + + // ProviderScope 내부에서 초기화 작업을 수행하기 위한 ProviderContainer 생성 + final container = ProviderContainer(); + + // 앱 실행 + runApp( + UncontrolledProviderScope( + container: container, + child: const MyApp(), + ), + ); + + // 앱이 시작된 후 백그라운드에서 매니저 초기화 실행 + container.read(managerInitializerProvider.future).then((_) { + print('매니저 초기화 완료'); + }); } -class MyApp extends StatelessWidget { +class MyApp extends ConsumerWidget { const MyApp({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + // 매니저 초기화 상태 관찰 + final initStatus = ref.watch(managerInitializerProvider); + return MaterialApp( debugShowCheckedModeBanner: false, + navigatorKey: navigatorKey, home: const SplashScreen() ); } diff --git a/fe/lib/managers/scrap_manager.dart b/fe/lib/managers/scrap_manager.dart new file mode 100644 index 0000000..d2ea2e9 --- /dev/null +++ b/fe/lib/managers/scrap_manager.dart @@ -0,0 +1,51 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; + +class ScrapManager { + final Map _scrappedRestaurants = {}; + + Future init() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString('scrapped_restaurants'); + if (jsonString != null) { + final Map decoded = jsonDecode(jsonString); + _scrappedRestaurants.clear(); + _scrappedRestaurants.addAll( + decoded.map((key, value) => MapEntry(key, value == true)) + ); + } + print('ScrapManager: 초기화 완료. 스크랩된 레스토랑 수: ${_scrappedRestaurants.length}'); + } catch (e) { + print('ScrapManager: 초기화 중 오류 발생: $e'); + } + } + + Future setScrapState(String restaurantId, bool isScraped) async { + try { + _scrappedRestaurants[restaurantId] = isScraped; + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('scrapped_restaurants', jsonEncode(_scrappedRestaurants)); + print('ScrapManager: 스크랩 상태 저장 완료. restaurantId: $restaurantId, isScraped: $isScraped'); + } catch (e) { + print('ScrapManager: 스크랩 상태 저장 오류: $e'); + } + } + + bool? getScrapState(String restaurantId) { + return _scrappedRestaurants[restaurantId]; + } + + // 모든 스크랩 상태 지우기 (로그아웃 시 사용) + Future clearAllScraps() async { + try { + _scrappedRestaurants.clear(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('scrapped_restaurants'); + print('ScrapManager: 모든 스크랩 상태 삭제됨'); + } catch (e) { + print('ScrapManager: 스크랩 상태 삭제 오류: $e'); + } + } +} \ No newline at end of file diff --git a/fe/lib/managers/vote_manager.dart b/fe/lib/managers/vote_manager.dart new file mode 100644 index 0000000..5538145 --- /dev/null +++ b/fe/lib/managers/vote_manager.dart @@ -0,0 +1,76 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:fe/model/vote_types.dart'; +import 'dart:convert'; + +class VoteManager { + final Map _userVotes = {}; // restaurantId: voteType (LIKE, DISLIKE, null) + + Future init() async { + try { + final prefs = await SharedPreferences.getInstance(); + final jsonString = prefs.getString('user_votes'); + if (jsonString != null) { + final decoded = jsonDecode(jsonString) as Map; + _userVotes.clear(); + + decoded.forEach((restaurantId, voteTypeValue) { + if (voteTypeValue == null) { + _userVotes[restaurantId] = null; + } else { + try { + // 문자열을 VoteType으로 변환 + final voteTypeStr = voteTypeValue as String; + if (voteTypeStr == 'LIKE') { + _userVotes[restaurantId] = VoteType.LIKE; + } else if (voteTypeStr == 'DISLIKE') { + _userVotes[restaurantId] = VoteType.DISLIKE; + } else { + _userVotes[restaurantId] = null; + } + } catch (e) { + print('VoteManager: 투표 타입 변환 오류: $e'); + _userVotes[restaurantId] = null; + } + } + }); + } + print('VoteManager: 초기화 완료. 저장된 투표 수: ${_userVotes.length}'); + } catch (e) { + print('VoteManager: 초기화 중 오류 발생: $e'); + } + } + + Future setVoteState(String restaurantId, VoteType? voteType) async { + try { + _userVotes[restaurantId] = voteType; + + final prefs = await SharedPreferences.getInstance(); + // VoteType을 직렬화 가능한 형태로 변환 + final serializableVotes = {}; + _userVotes.forEach((key, value) { + serializableVotes[key] = value?.name; + }); + + await prefs.setString('user_votes', jsonEncode(serializableVotes)); + print('VoteManager: 투표 상태 저장 완료. restaurantId: $restaurantId, voteType: $voteType'); + } catch (e) { + print('VoteManager: 투표 상태 저장 오류: $e'); + } + } + + VoteType? getVoteState(String restaurantId) { + return _userVotes[restaurantId]; + } + + // 모든 투표 상태 지우기 (로그아웃 시 사용) + Future clearAllVotes() async { + try { + _userVotes.clear(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('user_votes'); + print('VoteManager: 모든 투표 상태 삭제됨'); + } catch (e) { + print('VoteManager: 투표 상태 삭제 오류: $e'); + } + } +} \ No newline at end of file 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..a8161cc --- /dev/null +++ b/fe/lib/model/vote_restaurant.dart @@ -0,0 +1,93 @@ +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 필드 처리 (null인 경우 false로 설정) + final isScrapped = parsedJson['isScrapped'] is bool ? parsedJson['isScrapped'] : false; + parsedJson['isScrapped'] = isScrapped; + + // userVoteStatus 필드 명시적 처리 (서버에서 직접 참조) + final userVoteStatus = json['userVoteStatus'] as String?; + print("[FROMJSON] Raw userVoteStatus: $userVoteStatus (타입: ${userVoteStatus?.runtimeType})"); // 디버그용 + 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..719a1f2 --- /dev/null +++ b/fe/lib/model/vote_types.dart @@ -0,0 +1,18 @@ +enum VoteType { + LIKE, + DISLIKE, +} + +class VoteQueueItem { + final String restaurantId; + final VoteType? voteType; + + VoteQueueItem(this.restaurantId, this.voteType); + + Map toJson() { + return { + 'restaurantId': restaurantId, + 'voteType': voteType?.name, + }; + } +} \ No newline at end of file diff --git a/fe/lib/provider/managers_provider.dart b/fe/lib/provider/managers_provider.dart new file mode 100644 index 0000000..83ea293 --- /dev/null +++ b/fe/lib/provider/managers_provider.dart @@ -0,0 +1,55 @@ +import 'package:fe/managers/scrap_manager.dart'; +import 'package:fe/managers/vote_manager.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'managers_provider.g.dart'; + +// VoteManager 프로바이더 +@Riverpod(keepAlive: true) +VoteManager voteManager(VoteManagerRef ref) { + final manager = VoteManager(); + // 초기화는 메인에서 별도로 처리 + return manager; +} + +// ScrapManager 프로바이더 +@Riverpod(keepAlive: true) +ScrapManager scrapManager(ScrapManagerRef ref) { + final manager = ScrapManager(); + // 초기화는 메인에서 별도로 처리 + return manager; +} + +// 어플리케이션 초기화 관련 프로바이더 - 매니저 초기화 상태 관리 +@Riverpod(keepAlive: true) +class ManagerInitializer extends _$ManagerInitializer { + @override + Future build() async { + return _initializeManagers(); + } + + Future _initializeManagers() async { + try { + // 모든 매니저 초기화 + final voteManager = ref.read(voteManagerProvider); + final scrapManager = ref.read(scrapManagerProvider); + + await Future.wait([ + voteManager.init(), + scrapManager.init(), + ]); + + print('모든 매니저 초기화 완료'); + return true; + } catch (e) { + print('매니저 초기화 중 오류: $e'); + return false; + } + } + + Future reinitialize() async { + state = const AsyncValue.loading(); + state = AsyncValue.data(await _initializeManagers()); + } +} \ No newline at end of file diff --git a/fe/lib/provider/managers_provider.g.dart b/fe/lib/provider/managers_provider.g.dart new file mode 100644 index 0000000..eaaa4f7 --- /dev/null +++ b/fe/lib/provider/managers_provider.g.dart @@ -0,0 +1,59 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'managers_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$voteManagerHash() => r'd6fb4051f1df4c1515075d8f5393303a325bde5a'; + +/// See also [voteManager]. +@ProviderFor(voteManager) +final voteManagerProvider = Provider.internal( + voteManager, + name: r'voteManagerProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$voteManagerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef VoteManagerRef = ProviderRef; +String _$scrapManagerHash() => r'02f6cfa243016b2665334faaab792d90473d7d2d'; + +/// See also [scrapManager]. +@ProviderFor(scrapManager) +final scrapManagerProvider = Provider.internal( + scrapManager, + name: r'scrapManagerProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') ? null : _$scrapManagerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef ScrapManagerRef = ProviderRef; +String _$managerInitializerHash() => + r'70b987e558574f0cee34a7b608054c39a52a40f0'; + +/// See also [ManagerInitializer]. +@ProviderFor(ManagerInitializer) +final managerInitializerProvider = + AsyncNotifierProvider.internal( + ManagerInitializer.new, + name: r'managerInitializerProvider', + debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') + ? null + : _$managerInitializerHash, + dependencies: null, + allTransitiveDependencies: null, +); + +typedef _$ManagerInitializer = AsyncNotifier; +// 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/provider/vote_state_provider.dart b/fe/lib/provider/vote_state_provider.dart new file mode 100644 index 0000000..bbac057 --- /dev/null +++ b/fe/lib/provider/vote_state_provider.dart @@ -0,0 +1,639 @@ +import 'package:fe/data/location_service.dart'; +import 'package:fe/managers/scrap_manager.dart'; +import 'package:fe/managers/vote_manager.dart'; +import 'package:fe/model/vote_restaurant.dart'; +import 'package:fe/provider/managers_provider.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; + + VoteRestaurantState({ + required this.restaurant, + this.userVote, + this.isVoting = false, + }); + + VoteRestaurantState copyWith({ + VoteRestaurant? restaurant, + VoteType? userVote, + bool? isVoting, + bool forceNullUserVote = false, + }) { + return VoteRestaurantState( + restaurant: restaurant ?? this.restaurant, + userVote: forceNullUserVote ? null : (userVote ?? this.userVote), + isVoting: isVoting ?? this.isVoting, + ); + } + + // 임시 초기 데이터 변환 (실제 VoteRestaurant 모델 업데이트 필요) + factory VoteRestaurantState.fromRestaurant(VoteRestaurant restaurant) { + // Debug: Log the incoming userVoteStatus from the API for this restaurant + print("[DEBUG] Restaurant: ${restaurant.name}, API userVoteStatus: '${restaurant.userVoteStatus}', JSON Key: 'userVoteStatus'"); + + VoteType? initialVote; + String? voteStatusFromApi = restaurant.userVoteStatus?.trim().toUpperCase(); + + if (voteStatusFromApi == 'LIKE') { + initialVote = VoteType.LIKE; + } else if (voteStatusFromApi == 'DISLIKE') { + initialVote = VoteType.DISLIKE; + } + // 그 외의 경우 (null, 빈 문자열, 인식할 수 없는 값) initialVote는 null로 유지됩니다. + + // Debug: Log the parsed initialVote for this restaurant + print("[DEBUG] Restaurant: ${restaurant.name}, Parsed initialVote: $initialVote (API 값: '$voteStatusFromApi')"); + + VoteRestaurant updatedRestaurant = restaurant.copyWith( + isScrapped: restaurant.isScrapped ?? false, + // userVoteStatus도 유지되도록 명시적으로 설정 + userVoteStatus: restaurant.userVoteStatus + ); + + return VoteRestaurantState( + restaurant: updatedRestaurant, + userVote: initialVote, + isVoting: false + ); + } +} + +// 1-1. 페이지네이션 포함된 새로운 상태 클래스 +@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, + ); + } +} + +// VoteQueue 클래스 수정 +class VoteQueue { + final VoteRepository _voteRepository; // API 통신을 위한 repository + final List _queue = []; // 큐 구현 + final Map _currentVotes = {}; // 식당별 현재 투표 상태 추적 + + VoteQueue(this._voteRepository); + + // 큐에 투표 항목 추가 + void addVote(String restaurantId, VoteType? voteType) { + _queue.add(VoteQueueItem(restaurantId, voteType)); + // 현재 상태 맵에도 저장 + _currentVotes[restaurantId] = voteType; + } + + // 현재 식당의 투표 상태 반환 (새로고침 시 사용) + VoteType? getCurrentVote(String restaurantId) { + return _currentVotes[restaurantId]; + } + + // 큐 및 현재 상태 맵 초기화 (앱 재시작 시 호출) + void reset() { + _queue.clear(); + _currentVotes.clear(); + print("VoteQueue: 큐와 상태 맵 초기화됨"); + } + + // 서버에 배치 요청 보내는 메소드 + Future processBatch() async { + if (_queue.isEmpty) return; + + // 큐 아이템 복사 후 클리어 (API 호출 중 추가되는 항목은 다음 배치에 처리) + final batch = [..._queue]; + _queue.clear(); + + try { + // 배치 요청 실행 + await _voteRepository.batchVote(batch); + } catch (e) { + // 오류시 큐 복원 + print("투표 요청 처리 실패: $e"); + _queue.insertAll(0, batch); + } + } + + // 리소스 정리 + void dispose() { + _queue.clear(); + _currentVotes.clear(); + } +} + +// 2. StateNotifier 리팩토링 (VotePageCombinedState 사용) +class VotePageStateNotifier extends StateNotifier { + final dynamic _read; + final VoteRepository _voteRepository; + final LocationService? _locationService; // optional로 변경 + late final VoteQueue _voteQueue; + final VoteManager _voteManager; + final ScrapManager _scrapManager; + int _currentPage = 0; + final int _pageSize = 10; // 페이지 당 아이템 수 (API와 일치시켜야 함) + + // 위치 정보를 저장할 변수 추가 + Position? _lastPosition; + + VotePageStateNotifier({ + required VoteRepository voteRepository, + required dynamic read, + LocationService? locationService, // optional로 변경 + VoteQueue? voteQueue, // null 허용 + required VoteManager voteManager, + required ScrapManager scrapManager, + }) : _voteRepository = voteRepository, + _read = read, + _locationService = locationService, + _voteManager = voteManager, + _scrapManager = scrapManager, + super(const VotePageCombinedState()) { + _voteQueue = voteQueue ?? VoteQueue(voteRepository); + // VoteQueue를 초기화하고 앱 시작 시 명시적으로 리셋 + _voteQueue.reset(); + _fetchInitialRestaurants(); + } + + @override + void dispose() { + _voteQueue.dispose(); + super.dispose(); + } + + // 초기 데이터 또는 새로고침 시 호출 + 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; // 페이지 카운터 리셋 + + // 이전 로딩된 상태 정보를 Map으로 변환 (restaurant ID로 빠른 조회) + final existingStatesMap = { + for (var item in state.restaurants) + item.restaurant.restaurantId: item + }; + + // API 응답을 VoteRestaurantState 목록으로 변환 + final initialRestaurantStates = restaurants.map((r) { + // 1. 서버에서 받은 VoteRestaurant 객체로 초기 상태 생성 + VoteRestaurantState currentItemState = VoteRestaurantState.fromRestaurant(r); + + // 2. 이미 큐에 있는 항목이라면 큐의 투표 상태를 우선 적용 (아직 처리되지 않은 투표 처리) + // 서버 응답에서 해석된 투표 상태 - fromRestaurant에서 파싱됨 + final VoteType? serverInterpretedVote = currentItemState.userVote; + + // 로컬 저장소에서 투표 상태 확인 + final VoteType? localVoteStatus = _voteManager.getVoteState(r.restaurantId); + + // 로컬 저장소의 투표 상태가 있으면 우선 적용 + if (localVoteStatus != null) { + currentItemState = currentItemState.copyWith( + userVote: localVoteStatus + ); + + // 투표 카운트 조정 (서버 값과 로컬 값이 다를 경우) + if (serverInterpretedVote != localVoteStatus) { + currentItemState = currentItemState.copyWith( + restaurant: adjustVoteCounts( + currentItemState.restaurant, + serverInterpretedVote, + localVoteStatus + ) + ); + } + } + + // VoteQueue에 보류 중인 투표가 있다면 적용 + if (_voteQueue._currentVotes.containsKey(r.restaurantId)) { + final VoteType? pendingVote = _voteQueue.getCurrentVote(r.restaurantId); + + // 현재 적용된 투표와 큐의 보류 투표가 다를 경우 상태 업데이트 + final VoteType? currentVote = localVoteStatus ?? serverInterpretedVote; + if (currentVote != pendingVote) { + currentItemState = currentItemState.copyWith( + userVote: pendingVote, // 큐의 보류 투표로 UI 상태 변경 + forceNullUserVote: pendingVote == null, // 취소 액션이면 null로 설정 + restaurant: adjustVoteCounts( + currentItemState.restaurant, + currentVote, + pendingVote + ) + ); + } + } + + // 3. 스크랩 상태 및 isVoting 같은 다른 UI 관련 상태 병합 + final existingStateFromPreviousLoad = existingStatesMap[r.restaurantId]; + + // 스크랩 상태 결정 우선순위: API 응답 > 로컬 저장소 > 이전 상태 > 기본값 + bool finalIsScrapped = r.isScrapped ?? // API 응답 우선 + _scrapManager.getScrapState(r.restaurantId) ?? // 그 다음 로컬 스크랩 매니저 + existingStateFromPreviousLoad?.restaurant.isScrapped ?? // 이전 리스트에 있던 상태 + false; // 기본값 + + bool finalIsVoting = existingStateFromPreviousLoad?.isVoting ?? false; // 이전 로딩 상태 유지 (예: 정렬 변경 시) + + currentItemState = currentItemState.copyWith( + isVoting: finalIsVoting, + restaurant: currentItemState.restaurant.copyWith( + isScrapped: finalIsScrapped + ), + ); + + // isScrapped가 최종적으로 null이 아니도록 보장 + if (currentItemState.restaurant.isScrapped == null) { + currentItemState = currentItemState.copyWith(restaurant: currentItemState.restaurant.copyWith(isScrapped: false)); + } + + return currentItemState; + }).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); + + // 로컬 투표 상태 저장 + await _voteManager.setVoteState(restaurantId, newVoteType); + + try { + // 큐에 투표 요청 추가 + _voteQueue.addVote(restaurantId, newVoteType); + + // 투표 큐 처리 (API 전송) + await _voteQueue.processBatch(); + + // 투표 상태를 "로딩 아님"으로 업데이트 + 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, + ); + + 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 = restaurant.isScrapped ?? false; + final newScrapStatus = !currentScrapStatus; + + // 낙관적 UI 업데이트 + _updateRestaurantField(index, 'isScrapped', newScrapStatus); + + // 로컬 스크랩 상태 저장 + await _scrapManager.setScrapState(restaurantId, newScrapStatus); + + try { + // API 호출 + final res = await _voteRepository.toggleScrap(restaurantId, newScrapStatus); + print("스크랩 토글 응답: $res"); + return res; + } catch (e) { + // API 호출 실패 시 UI 롤백 + _updateRestaurantField(index, 'isScrapped', currentScrapStatus); + await _scrapManager.setScrapState(restaurantId, currentScrapStatus); // 로컬 상태도 롤백 + print("스크랩 토글 실패: $e"); + throw e; + } + } + + void _updateRestaurantField(int index, String field, dynamic value) { + if (index >= 0 && index < state.restaurants.length) { + final updatedRestaurant = state.restaurants[index].restaurant.copyWith( + isScrapped: field == 'isScrapped' ? value : state.restaurants[index].restaurant.isScrapped, + ); + final updatedList = List.from(state.restaurants); + updatedList[index] = state.restaurants[index].copyWith( + restaurant: updatedRestaurant, + ); + state = state.copyWith(restaurants: updatedList); + } + } + + // 새로고침 기능 + 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) { + VoteRestaurantState initialState = VoteRestaurantState.fromRestaurant(r); + + // 로컬 저장소에서 투표 상태 확인 + final VoteType? localVoteStatus = _voteManager.getVoteState(r.restaurantId); + + if (localVoteStatus != null) { + initialState = initialState.copyWith( + userVote: localVoteStatus, + restaurant: adjustVoteCounts( + initialState.restaurant, + initialState.userVote, // 서버 투표 상태 + localVoteStatus // 로컬 투표 상태 + ) + ); + } + + // 스크랩 상태 확인 + final bool? localScrapStatus = _scrapManager.getScrapState(r.restaurantId); + if (localScrapStatus != null) { + initialState = initialState.copyWith( + restaurant: initialState.restaurant.copyWith( + isScrapped: localScrapStatus + ) + ); + } + + return initialState; + }).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, + ); + } + } +} + +// 3. StateNotifierProvider 수정 (상태 타입 변경) +final votePageStateProvider = StateNotifierProvider((ref) { + final voteRepository = ref.watch(voteRepositoryProvider); + final locationService = ref.watch(locationServiceProvider); + final voteQueue = VoteQueue(voteRepository); + final scrapManager = ref.watch(scrapManagerProvider); + final voteManager = ref.watch(voteManagerProvider); + + return VotePageStateNotifier( + voteRepository: voteRepository, + read: ref.read, + locationService: locationService, + voteQueue: voteQueue, + scrapManager: scrapManager, + voteManager: voteManager + ); +}); + +// 개별 식당 상태를 관리하는 StateNotifier +class VoteRestaurantStateNotifier extends StateNotifier { + final String restaurantId; + final Ref ref; + + VoteRestaurantStateNotifier(this.restaurantId, this.ref) + : super(VoteRestaurantState( + restaurant: VoteRestaurant( + restaurantId: restaurantId, + name: '', + ), + )) { + _init(); + } + + void _init() { + final restaurants = ref.read(votePageStateProvider).restaurants; + final restaurant = restaurants.firstWhere( + (r) => r.restaurant.restaurantId == restaurantId, + orElse: () => throw Exception('Restaurant not found: $restaurantId'), + ); + + // 기존 상태의 스크랩 정보를 유지하면서 새로운 상태로 업데이트 + state = state.copyWith( + restaurant: restaurant.restaurant.copyWith( + isScrapped: restaurant.restaurant.isScrapped ?? state.restaurant.isScrapped, + ), + userVote: restaurant.userVote, + isVoting: restaurant.isVoting, + ); + } + + void updateRestaurant(VoteRestaurant restaurant) { + if (restaurant.restaurantId != restaurantId) return; + state = state.copyWith( + restaurant: restaurant.copyWith( + isScrapped: state.restaurant.isScrapped ?? restaurant.isScrapped, + ), + ); + } + + void vote(VoteType? voteType) { + final notifier = ref.read(votePageStateProvider.notifier); + notifier.vote(restaurantId, voteType); + } + + Future toggleScrap() async { + final notifier = ref.read(votePageStateProvider.notifier); + return notifier.toggleScrap(restaurantId: restaurantId); + } +} + +// 개별 식당 상태를 관리하는 provider +final voteRestaurantProvider = StateNotifierProvider.family((ref, restaurantId) { + return VoteRestaurantStateNotifier(restaurantId, ref); +}); + +// 네비게이션 키 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..500978d --- /dev/null +++ b/fe/lib/repository/vote_repository.dart @@ -0,0 +1,251 @@ +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과 VoteQueueItem을 가져오기 +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; + +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); +} + +// VoteQueueItem 클래스는 이제 vote_types.dart에서 import + +class VoteRepository { + final Dio dio; + final SecureStorage storage; + late SharedPreferences _prefs; + + VoteRepository({required this.dio, required this.storage}) { + SharedPreferences.getInstance().then((prefs) => _prefs = prefs); + } + + Future _savePendingVotes(List> votes) async { + final pendingVotes = _prefs.getStringList('pending_votes') ?? []; + final newVotes = votes.map((vote) => jsonEncode(vote)).toList(); + await _prefs.setStringList('pending_votes', [...pendingVotes, ...newVotes]); + } + + Future syncPendingVotes() async { + final pendingVotes = _prefs.getStringList('pending_votes') ?? []; + if (pendingVotes.isNotEmpty) { + try { + final votes = pendingVotes + .map((vote) => jsonDecode(vote) as Map) + .toList(); + + // Map을 VoteQueueItem으로 변환 + final voteQueueItems = votes.map((vote) => VoteQueueItem( + vote['restaurantId'] as String, + vote['voteType'] != null + ? VoteType.values.firstWhere( + (v) => v.name == vote['voteType'], + orElse: () => throw Exception('Invalid vote type: ${vote['voteType']}') + ) + : null + )).toList(); + + await batchVote(voteQueueItems); + await _prefs.remove('pending_votes'); + } catch (e) { + print('Failed to sync pending votes: $e'); + } + } + } + + 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) + }) async { + try { + final userId = await storage.readUserId(); + if (userId == null) { + throw Exception('User ID not found'); + } + + // POST에서 PATCH로 변경하고 URL 경로 수정 + 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) + }, + ); + + 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.'); + } + } + + // 배치 투표 API 호출 메소드 수정 + Future>> batchVote(List votes) async { + try { + final userId = await storage.readUserId(); + if (userId == null) { + throw Exception('User ID not found'); + } + + // VoteQueueItem 목록을 API 요청에 맞는 형식으로 변환 + final voteData = votes.map((item) => { + 'restaurantId': item.restaurantId, + 'voteType': item.voteType?.name, // null이면 null로 전송 (투표 취소) + }).toList(); + + final response = await dio.post( + 'http://localhost:8080/api/vote/v1/batch', + options: Options(headers: {'userId': userId}), + data: { + 'votes': voteData, + }, + ); + + if (response.statusCode == 200 && response.data is List) { + // 성공 시 응답 데이터 반환 + return (response.data as List).cast>(); + } else { + throw Exception('Failed to batch vote: Status code ${response.statusCode}'); + } + } on DioException catch (e) { + print('DioException batch voting: ${e.message}'); + print('Error response: ${e.response?.data}'); + final errorMessage = e.response?.data?['message'] ?? e.message; + throw Exception('Failed to batch vote: $errorMessage'); + } catch (e) { + print('Error batch voting: $e'); + throw Exception('Failed to batch vote.'); + } + } + + // 스크랩 토글 메소드 (기존) + Future scrapRestaurant(String restaurantId) async { + try { + final userId = await storage.readUserId(); + if (userId == null) { + throw Exception('User ID not found'); + } + + 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 toggle scrap: Status code ${response.statusCode}'); + } + } on DioException catch (e) { + print('DioException scrapping: ${e.message}'); + print('Error response: ${e.response?.data}'); + final errorMessage = e.response?.data?['message'] ?? e.message; + throw Exception('Failed to toggle scrap: $errorMessage'); + } catch (e) { + print('Error scrapping: $e'); + throw Exception('Failed to toggle scrap.'); + } + } + + // 명시적 스크랩 상태 설정 메소드 (새로 추가) + Future toggleScrap(String restaurantId, bool isScraped) async { + try { + final userId = await storage.readUserId(); + if (userId == null) { + throw Exception('User ID not found'); + } + + // PUT 대신 PATCH 메소드 사용 (서버 API에 맞게 수정) + final response = await dio.patch( + 'http://localhost:8080/api/user/v1/scrap/$restaurantId', + options: Options(headers: {'userId': userId}), + data: { + 'scrapped': isScraped + }, + ); + + 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..08662c7 --- /dev/null +++ b/fe/lib/view/screen/vote_page.dart @@ -0,0 +1,215 @@ +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, + 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..e36b4ae --- /dev/null +++ b/fe/lib/view/widget/vote_restaurant_card.dart @@ -0,0 +1,334 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:fe/model/vote_restaurant.dart'; +import 'package:fe/model/vote_types.dart'; // VoteType을 여기서 가져옴 +import 'package:fe/provider/vote_state_provider.dart'; // votePageStateProvider를 사용하기 위해 필요 +// import 'package:fe/repository/vote_repository.dart'; // Repository 직접 사용 안 함 +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 Function(VoteRestaurant, VoteType?) onVotePressed; + final Function(VoteRestaurant)? onScrapPressed; + + const VoteRestaurantCard({ + super.key, + required this.restaurant, + required this.userVote, + required this.isVoting, + required this.onVotePressed, + this.onScrapPressed, + }); + + @override + ConsumerState createState() => _VoteRestaurantCardState(); +} + +class _VoteRestaurantCardState extends ConsumerState { + // 로컬 상태로 스크랩 상태와 로딩 상태 관리 + bool? _localIsScrapped; // 초기값은 null로 설정하여 위젯 restaurant 값으로 초기화 + bool _isLoadingScrap = false; + + @override + void initState() { + super.initState(); + // 위젯이 처음 생성될 때 restaurant 객체의 스크랩 상태로 로컬 상태 초기화 + _localIsScrapped = widget.restaurant.isScrapped; + + // 스크랩 상태가 null이면 기본값으로 false 설정 + if (_localIsScrapped == null) { + _localIsScrapped = false; + } + } + + // props로 전달된 restaurant 객체의 isScrapped 상태가 변경될 때 로컬 상태도 동기화 + @override + void didUpdateWidget(covariant VoteRestaurantCard oldWidget) { + super.didUpdateWidget(oldWidget); + + // 위젯의 isScrapped 값이 변경되었고, null이 아닌 경우에만 업데이트 + if (widget.restaurant.isScrapped != oldWidget.restaurant.isScrapped && + widget.restaurant.isScrapped != null) { + if (mounted) { + setState(() { + _localIsScrapped = widget.restaurant.isScrapped; + }); + } + } + } + + Future _toggleScrap() async { + if (_isLoadingScrap) return; // 이미 로딩 중이면 중복 호출 방지 + + final originalScrapStatus = _localIsScrapped; // 롤백을 위한 원래 상태 저장 + final restaurantId = widget.restaurant.restaurantId; // 식당 ID 저장 + + if (mounted) { + setState(() { + _localIsScrapped = !(_localIsScrapped ?? false); // 낙관적 업데이트 + _isLoadingScrap = true; + }); + } + + try { + // votePageStateProvider를 통해 스크랩 상태 변경 + final result = await ref + .read(votePageStateProvider.notifier) + .toggleScrap(restaurantId: restaurantId); + + // 서버 응답과 상태가 일치하지 않으면 서버 응답으로 상태 조정 + if (mounted && result != _localIsScrapped) { + setState(() { + _localIsScrapped = result; + }); + } + } catch (e) { + // API 호출 실패 시 롤백 및 오류 표시 + if (mounted) { + setState(() { + _localIsScrapped = originalScrapStatus; // 원래 상태로 되돌림 + }); + + // 오류 메시지 표시 + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("스크랩 처리 중 오류가 발생했습니다"), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + action: SnackBarAction( + label: '확인', + onPressed: () { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + }, + ), + ), + ); + } + } finally { + // 작업 완료 후 로딩 상태 해제 + if (mounted) { + setState(() { + _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) { + // 스크랩 토글 결과를 받아서 로컬 상태 업데이트 + final newScrapStatus = await widget.onScrapPressed!(widget.restaurant); + + // 외부에서 상태가 업데이트되므로 여기서는 별도로 setState 호출 불필요 + // 대신 로컬 상태를 동기화해서 UI가 즉시 반응하도록 함 + if (mounted) { + setState(() { + _localIsScrapped = newScrapStatus; + _isLoadingScrap = false; + }); + } + } + } catch (e) { + print('스크랩 처리 중 오류: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('스크랩 처리 중 오류가 발생했습니다.')) + ); + + 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' : ''; + + // 스크랩 상태는 무조건 _localIsScrapped를 사용 + final displayScrapStatus = _localIsScrapped ?? false; + + 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), + // 스크랩 버튼 + IconButton( + icon: _isLoadingScrap + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + : Icon( + displayScrapStatus ? Icons.bookmark : Icons.bookmark_outline, + color: Colors.white, + size: 22, + ), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: _handleScrapButtonPress, // _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), + ), + ), + ), + ), + ], + ), + ); + } +}