diff --git a/lib/feature/match/MatchScreen.dart b/lib/feature/match/MatchScreen.dart index 1035f3e..387d8a5 100644 --- a/lib/feature/match/MatchScreen.dart +++ b/lib/feature/match/MatchScreen.dart @@ -4,10 +4,7 @@ import 'widget/MatchProfileListWidget.dart'; import 'widget/MatchFilterWidget.dart'; /// MatchScreen - 프로필 스와이프 매칭 화면 -/// -/// 주요 구성 요소: -/// - SwipeCardWidget: 프로필 카드를 스와이프할 수 있는 위젯 -/// - SwipeButtonWidget: 수동으로 좌/우 스와이프를 할 수 있는 버튼 +/// 완성 8월 18일 상현 class MatchScreen extends StatelessWidget { static const String name = 'MatchScreen'; diff --git a/lib/feature/match/ProfileScreen.dart b/lib/feature/match/ProfileScreen.dart index 454f147..54d4228 100644 --- a/lib/feature/match/ProfileScreen.dart +++ b/lib/feature/match/ProfileScreen.dart @@ -6,6 +6,9 @@ import 'package:flutter/material.dart'; import 'package:blueberry_flutter_template/model/PetProfileModel.dart'; import '../../utils/AppStrings.dart'; +/// ProfileScreen - ProfileDetail 스크린으로 대체할 임시 화면 +/// 완성 8월 18일 상현 + class ProfileScreen extends StatelessWidget { final PetProfileModel petProfile; diff --git a/lib/feature/match/provider/MatchProvider.dart b/lib/feature/match/provider/MatchProvider.dart index c65fda0..c9de235 100644 --- a/lib/feature/match/provider/MatchProvider.dart +++ b/lib/feature/match/provider/MatchProvider.dart @@ -1,5 +1,5 @@ import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../model/PetProfileModel.dart'; @@ -12,144 +12,209 @@ class MatchScreenNotifier extends StateNotifier> { } bool isLoading = false; - String? errorMessage; - static const userId = "eztqDqrvEXDc8nqnnrB8"; // 로그인 상황을 가정한 userId + static const userId = "eztqDqrvEXDc8nqnnrB8"; // 로그인 상황을 가정 + // 펫 데이터를 로드하고 필터링하는 함수 Future loadPets({String? location, String? gender}) async { - // 상태 초기화 - state = []; - isLoading = true; - - final firestore = FirebaseFirestore.instance; - final userDoc = await firestore.collection('users_test').doc(userId).get(); - - List ignoredPets = []; - if (userDoc.exists) { - ignoredPets = userDoc.data()?['ignoredPets'] ?? []; - } + _setLoading(true); try { - // 모든 펫 정보를 가져오기 - final snapshot = await firestore.collection('pet').get(); - final pets = snapshot.docs - .map((doc) => PetProfileModel.fromJson(doc.data())) - .toList(); + final ignoredPets = await _getIgnoredPets(); + final pets = await _getPetsFromFirestore(); // 매칭 조건 필터 적용 - List filteredPets = pets.where((pet) { - final matchesLocation = location == null || pet.location == location; - final matchesGender = gender == null || pet.gender == gender; - final notIgnored = !ignoredPets.contains(pet.petID); - return matchesLocation && matchesGender && notIgnored; + final filteredPets = pets.where((pet) { + return _matchesFilter(pet, ignoredPets, location, gender); }).toList(); - // 필터링된 결과가 있을 경우 상태를 업데이트 - if (filteredPets.isNotEmpty) { - state = filteredPets; - } else { + // 상태를 업데이트하여 필터된 펫 데이터를 설정 + state = filteredPets.isNotEmpty ? filteredPets : []; + + if (filteredPets.isEmpty) { talker.info(AppStrings.noFilteredResult); } } catch (e) { talker.error('${AppStrings.dbLoadError}$e'); } finally { - isLoading = false; + _setLoading(false); + } + } + + void _setLoading(bool value) { + isLoading = value; + } + + // ignore한 펫 데이터 가져오기 + Future> _getIgnoredPets() async { + final userDoc = await FirebaseFirestore.instance + .collection('users_test') + .doc(userId) + .get(); + final data = userDoc.data(); + + if (data != null && data.containsKey('ignoredPets')) { + return data['ignoredPets'] ?? []; + } else { + return []; } } - // Firebase DB field 명을 받아서 해당 필드에 petId를 추가하는 함수 - Future _updatePetList( - String userId, String petId, String fieldName) async { + // 모든 pet 데이터 가져오기 + Future> _getPetsFromFirestore() async { + final snapshot = await FirebaseFirestore.instance.collection('pet').get(); + return snapshot.docs + .map((doc) => PetProfileModel.fromJson(doc.data())) + .toList(); + } + + // 조건에 따라 펫 데이터 필터링 + bool _matchesFilter(PetProfileModel pet, List ignoredPets, + String? location, String? gender) { + final matchesLocation = location == null || pet.location == location; + final matchesGender = gender == null || pet.gender == gender; + final notIgnored = !ignoredPets.contains(pet.petID); + final notMyPet = pet.ownerUserID != userId; + return matchesLocation && matchesGender && notIgnored && notMyPet; + } + + // 펫 좋아요 기능 + Future addPetToLikes( + BuildContext context, String userId, String petId) async { + await _updatePetList(context, userId, petId, 'likedPets'); + _checkForMatchAndAddFriend(context, userId, petId, 'like'); + } + + // 펫 즐겨찾기 기능 + Future addPetToSuperLikes( + BuildContext context, String userId, String petId) async { + await _updatePetList(context, userId, petId, 'superLikedPets'); + _checkForMatchAndAddFriend(context, userId, petId, 'superlike'); + } + + // 펫 추천 안함 기능 + Future addPetToIgnored( + BuildContext context, String userId, String petId) async { + await _updatePetList(context, userId, petId, 'ignoredPets'); + loadPets(); + _showSnackbar(context, AppStrings.ignoreSuccessMessage); + } + + // 특정 필드에 펫 ID 추가 기능 + Future _updatePetList(BuildContext context, String userId, String petId, + String fieldName) async { final firestore = FirebaseFirestore.instance; final userDoc = firestore.collection('users_test').doc(userId); + final snapshot = await userDoc.get(); + List petList = snapshot.data()![fieldName]; - try { - final snapshot = await userDoc.get(); - List petList = snapshot.data()![fieldName]; - - if (!petList.contains(petId)) { - petList.add(petId); - await userDoc.update({ - fieldName: petList, - }); - talker.info("${AppStrings.dbUpdateSuccess}: $petList"); - } else { - talker.info(AppStrings.dbUpdateFail); - } - } catch (e) { - talker.error('${AppStrings.dbUpdateError}$e'); + if (!petList.contains(petId)) { + petList.add(petId); + await userDoc.update({fieldName: petList}); } } - Future addPetToLikes(String userId, String petId) async { - talker.info("match provider petId: $petId"); - await _updatePetList(userId, petId, 'likedPets'); - await _checkForMatch(userId, petId); + // 펫 좋아요 후 매칭 여부 확인 + Future _isMatchFound(String userId, String petId) async { + final petOwnerId = await _getPetOwnerId(petId); + final likedPetsByOwner = await _getLikedPets(petOwnerId); + final myPets = await _getMyPets(userId); + + // 매칭된 경우 반환 + return likedPetsByOwner.contains(myPets[0]); } - Future addPetToSuperLikes(String userId, String petId) async { - await _updatePetList(userId, petId, 'superLikedPets'); - await _checkForMatch(userId, petId); +// 펫 소유자의 ID 가져오기 + Future _getPetOwnerId(String petId) async { + final firestore = FirebaseFirestore.instance; + final petDoc = await firestore.collection('pet').doc(petId).get(); + return petDoc.data()!['ownerUserID']; } - Future addPetToIgnored(String userId, String petId) async { - await _updatePetList(userId, petId, 'ignoredPets'); - loadPets(); // 무시한 펫이 카드에 안나오도록 데이터를 리로드 +// 특정 사용자의 좋아요 목록 가져오기 + Future> _getLikedPets(String userId) async { + final firestore = FirebaseFirestore.instance; + final userDoc = await firestore.collection('users_test').doc(userId).get(); + return userDoc.data()!['likedPets'] ?? []; } - Future _checkForMatch(String userId, String petId) async { - // 하트를 누른 펫의 petOwnerId 가져오기 +// 특정 사용자의 펫 목록 가져오기 + Future> _getMyPets(String userId) async { final firestore = FirebaseFirestore.instance; - final petDoc = await firestore.collection('pet').doc(petId).get(); - final petOwnerId = petDoc.data()!['ownerUserID']; - - // 상대방의 likedPets 목록 가져오기 - final userDoc = - await firestore.collection('users_test').doc(petOwnerId).get(); - List likedPetsByOwner = userDoc.data()!['likedPets'] ?? []; - - // 내 Pets 목록 가져오기 (로그인 가능해진 후에는 내 펫 정보를 가져오는 방법을 변경해야 함) - final myUserDoc = - await firestore.collection('users_test').doc(userId).get(); - List myPets = myUserDoc.data()!['pets'] ?? []; - - // likedPetsByOwner 에 내 petID 가 있을 경우 서로 좋아요한 것으로 간주하여 friends 서브 컬렉션 서로의 정보를 등록 - if (likedPetsByOwner.contains(myPets[0])) { - talker.info("Match found between $likedPetsByOwner and $myPets"); - await _addFriend(userId, petOwnerId); // 내 친구 목록에 상대방을 친구로 추가 - await _addFriend(petOwnerId, userId); // 상대방 친구 목록에 나를 친구로 추가 + final userDoc = await firestore.collection('users_test').doc(userId).get(); + return userDoc.data()!['pets'] ?? []; + } + +// 매칭 여부 확인 후 친구 추가 및 메세지 출력 + Future _checkForMatchAndAddFriend(BuildContext context, String userId, + String petId, String matchType) async { + if (await _isMatchFound(userId, petId)) { + await _handleSuccessfulMatch(context, userId, petId, matchType); } else { - talker.info("No match found between $likedPetsByOwner and $myPets"); + _handleFailedMatch(context, matchType); + } + } + +// 매칭 성공 시 처리 + Future _handleSuccessfulMatch(BuildContext context, String userId, + String petId, String matchType) async { + final petOwnerId = await _getPetOwnerId(petId); + + await _addFriend(userId, petOwnerId); // 내 친구 목록에 상대방을 추가 + await _addFriend(petOwnerId, userId); // 상대방 친구 목록에 나를 추가 + + // 매칭 성공 메세지 전달 + if (context.mounted) { + final message = (matchType == 'like') + ? AppStrings.matchSuccessMessageLike + : AppStrings.matchSuccessMessageSuperLike; + _showSnackbar(context, message); + } + } + +// 매칭 실패 시 처리 + void _handleFailedMatch(BuildContext context, String matchType) { + if (context.mounted) { + final message = (matchType == 'like') + ? AppStrings.matchFailMessageLike + : AppStrings.matchFailMessageSuperLike; + _showSnackbar(context, message); } } +// 친구 추가 기능 Future _addFriend(String userId, String friendId) async { final firestore = FirebaseFirestore.instance; final userDoc = firestore.collection('users_test').doc(userId); + await userDoc.collection('friends').doc(friendId).set({ + 'userId': friendId, + 'addedDate': Timestamp.now(), + }); + } - try { - await userDoc.collection('friends').doc(friendId).set({ - 'userId': friendId, - 'addedDate': Timestamp.now(), - }); - talker.info("Friend added between $userId and $friendId"); - } catch (e) { - talker.error("Error adding friend: $e"); - } +// 안내 메세지 출력 + void _showSnackbar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 2), + ), + ); } - // 해당 유저를 추천 안함 기능 (ProfileScreen 에서 호출) + // ProfileScreen 에서 호출하는 함수 Future handleIgnoreProfile({ required BuildContext context, required PetProfileModel petProfile, }) async { - await addPetToIgnored(userId, petProfile.petID); + await addPetToIgnored(context, userId, petProfile.petID); if (context.mounted) { Navigator.of(context).pop(); // 프로필 화면 닫기 } } } -final matchScreenProvider = - StateNotifierProvider>( +final matchScreenProvider = StateNotifierProvider>( (ref) => MatchScreenNotifier(), ); + + diff --git a/lib/feature/match/provider/PetImageProvider.dart b/lib/feature/match/provider/PetImageProvider.dart new file mode 100644 index 0000000..676e793 --- /dev/null +++ b/lib/feature/match/provider/PetImageProvider.dart @@ -0,0 +1,9 @@ +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +// Pet 이미지 URL을 제공하는 Provider +final petImageProvider = FutureProvider.family((ref, imageName) async { + final storageRef = FirebaseStorage.instance.ref('pet/$imageName'); + return await storageRef.getDownloadURL(); +}); + diff --git a/lib/feature/match/widget/MatchFilterOptionWidget.dart b/lib/feature/match/widget/MatchFilterOptionWidget.dart index 96e58ff..f768915 100644 --- a/lib/feature/match/widget/MatchFilterOptionWidget.dart +++ b/lib/feature/match/widget/MatchFilterOptionWidget.dart @@ -26,10 +26,15 @@ class MatchFilterOptionWidget extends ConsumerWidget { value: 'ignore', child: Row( children: [ - Icon(Icons.block, color: Colors.red[400], size: 14), + Icon(Icons.block, color: Colors.red[400], size: 16), const SizedBox(width: 8), - Text(AppStrings.ignoreThisPet, - style: TextStyle(color: Colors.red[400])), + const Text( + AppStrings.ignoreThisPet, + style: TextStyle( + color: Colors.black87, + fontSize: 14, // 글자 크기 줄이기 + ), + ), ], ), ), diff --git a/lib/feature/match/widget/MatchProfileListWidget.dart b/lib/feature/match/widget/MatchProfileListWidget.dart index fba13a0..b90721b 100644 --- a/lib/feature/match/widget/MatchProfileListWidget.dart +++ b/lib/feature/match/widget/MatchProfileListWidget.dart @@ -1,12 +1,14 @@ import 'package:blueberry_flutter_template/model/PetProfileModel.dart'; -import 'package:blueberry_flutter_template/utils/Talker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_card_swiper/flutter_card_swiper.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shimmer/shimmer.dart'; import '../../../utils/AppStrings.dart'; +import '../../../utils/Talker.dart'; import '../ProfileScreen.dart'; import '../provider/MatchProvider.dart'; +import '../provider/PetImageProvider.dart'; import 'SwipeButtonWidget.dart'; import 'SwipeCardWidget.dart'; @@ -15,19 +17,65 @@ class MatchProfileListWidget extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final list = ref.watch(matchScreenProvider); + final listState = ref.watch(matchScreenProvider); + final isLoading = ref.watch(matchScreenProvider.notifier).isLoading; - // 스와이퍼 numberOfCardsDisplayed 설정으로 인해 데이터가 1일 때도 에러 메시지 출력 - return list.isEmpty || list.length == 1 - ? const Center(child: Text(AppStrings.noPetsMessage)) - : _buildCardView(context, ref, list); + if (isLoading) { + return _buildLoadingView(); // 데이터 로딩 중일 때 Shimmer UI를 표시 + } else if (listState.isEmpty) { + return const Center(child: Text(AppStrings.noPetsMessage)); + } else { + return _buildCardView(context, ref, listState); + } + } + + Widget _buildLoadingView() { + return ListView.builder( + itemCount: 1, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: Shimmer.fromColors( + baseColor: Colors.grey[300]!, + highlightColor: Colors.grey[100]!, + child: Container( + height: 430, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ); + }, + ); } Widget _buildCardView( BuildContext context, WidgetRef ref, List data) { final cardSwiperController = CardSwiperController(); int currentIndex = 0; - final cards = data.map(SwipeCardWidget.new).toList(); + final cards = data.map((petProfile) { + final imageUrl = ref.watch(petImageProvider(petProfile.imageName)); + return imageUrl.when( + loading: () => _buildLoadingView(), // 카드 이미지가 로딩중일 때 Shimmer UI를 표시 + error: (err, stack) { + talker.error(petProfile.imageName, err, stack); + return _buildLoadingView(); + }, + data: (imageUrl) { + return GestureDetector( + onTap: () { + cardSwiperController.swipe(CardSwiperDirection.right); + }, + child: SwipeCardWidget( + petProfiles: petProfile, + imageUrl: imageUrl, + ), + ); + }, + ); + }).toList(); return Column( children: [ @@ -50,12 +98,12 @@ class MatchProfileListWidget extends ConsumerWidget { return true; }, cardBuilder: ( - context, - index, - horizontalThresholdPercentage, - verticalThresholdPercentage, - ) => - cards[index], + context, + index, + horizontalThresholdPercentage, + verticalThresholdPercentage, + ) => + cards[index], ), ), Container( @@ -79,7 +127,7 @@ class MatchProfileListWidget extends ConsumerWidget { final petId = data[currentIndex].petID; await ref .read(matchScreenProvider.notifier) - .addPetToSuperLikes(userId, petId); + .addPetToSuperLikes(context, userId, petId); cardSwiperController.swipe(CardSwiperDirection.right); }, icon: Icons.star, @@ -91,10 +139,10 @@ class MatchProfileListWidget extends ConsumerWidget { onPressed: () async { const userId = "eztqDqrvEXDc8nqnnrB8"; final petId = data[currentIndex].petID; - talker.info("match profile widget petId: $petId"); + await ref .read(matchScreenProvider.notifier) - .addPetToLikes(userId, petId); + .addPetToLikes(context, userId, petId); cardSwiperController.swipe(CardSwiperDirection.right); }, icon: Icons.favorite, diff --git a/lib/feature/match/widget/SwipeCardWidget.dart b/lib/feature/match/widget/SwipeCardWidget.dart index c3a95d4..f92f31c 100644 --- a/lib/feature/match/widget/SwipeCardWidget.dart +++ b/lib/feature/match/widget/SwipeCardWidget.dart @@ -1,3 +1,4 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import '../../../model/PetProfileModel.dart'; @@ -8,11 +9,9 @@ import '../../../utils/AppTextStyle.dart'; class SwipeCardWidget extends StatelessWidget { final PetProfileModel petProfiles; + final String imageUrl; - const SwipeCardWidget( - this.petProfiles, { - super.key, - }); + const SwipeCardWidget({super.key, required this.petProfiles, required this.imageUrl,}); @override Widget build(BuildContext context) { @@ -28,7 +27,7 @@ class SwipeCardWidget extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(10), image: DecorationImage( - image: NetworkImage(petProfiles.imageUrl), + image: CachedNetworkImageProvider(imageUrl), fit: BoxFit.cover, ), ), diff --git a/lib/model/PetProfileModel.dart b/lib/model/PetProfileModel.dart index a3372a2..23341d6 100644 --- a/lib/model/PetProfileModel.dart +++ b/lib/model/PetProfileModel.dart @@ -11,6 +11,7 @@ class PetProfileModel with _$PetProfileModel { required String breed, required String bio, required String imageUrl, + required String imageName, required String location, required String petID, required String ownerUserID, diff --git a/lib/utils/AppStrings.dart b/lib/utils/AppStrings.dart index 2cbd631..8df3fe0 100644 --- a/lib/utils/AppStrings.dart +++ b/lib/utils/AppStrings.dart @@ -12,15 +12,28 @@ class AppStrings { static const String locationJapan = 'Japan'; //MatchProvider.dart - static const String docNotExistError = 'DB 업데이트 실패: 문서가 존재하지 않습니다.'; static const String dbUpdateSuccess = "DB 업데이트 성공"; - static const String dbUpdateFail = "DB 업데이트 실패 : 이미 존재하는 petId"; + static const String dbUpdateFail = "DB 업데이트 실패 : 이미 등록된 petId 입니다."; static const String dbUpdateError = 'DB 업데이트 중 오류 발생: '; static const String dbLoadError = '펫 데이터를 로드하는 중 오류 발생: '; static const String noFilteredResult = '필터링된 결과가 없습니다'; + static const String matchFound = '서로 좋아요한 상태입니다.'; + static const String noMatch = '서로 좋아요한 상태가 아닙니다. 친구로 추가하지 않습니다.'; + static const String addedFriendSuccess = '친구 추가 확인 : '; + static const String addedFriendError = '친구 추가 중 오류 발생: '; + //MatchProvider.dart snackbar + static const String matchSuccessMessageLike = '좋아요 목록에 추가 했어요. 친구가 되었어요.'; + static const String matchSuccessMessageSuperLike = + '즐겨찾기 목록에 추가 했어요. 친구가 되었어요.'; + static const String matchFailMessageLike = + '좋아요 목록에 추가 했어요. 상대도 좋아요를 누르면 친구가 됩니다.'; + static const String matchFailMessageSuperLike = + '즐겨찾기 목록에 추가 했어요. 상대도 좋아요를 누르면 친구가 됩니다.'; + static const String ignoreSuccessMessage = '이제 다시 매칭 되지 않아요.'; + static const String dbUpdateFailMessage = '이미 추가된 펫이에요.'; // MatchProfileListWidget.dart - static const String noPetsMessage = '추천할 펫이 1마리이거나 없습니다.'; + static const String noPetsMessage = '추천해 줄 펫이 없어요. 조건을 다시 한번 확인해 주세요.'; //ProfileScreen.dart static const String petGender = '성별'; diff --git a/pubspec.yaml b/pubspec.yaml index 0234ec9..c223035 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: flutter_rating_bar: ^4.0.1 pinput: ^4.0.0 google_fonts: ^6.2.1 + shimmer: ^3.0.0 # Calender flutter_svg: ^2.0.10+1