From 0cb67de66dc8b2d8611f647fb75e1ac07a41e14c Mon Sep 17 00:00:00 2001 From: bhindor <82446373+bhindor@users.noreply.github.com> Date: Thu, 27 Aug 2026 09:56:26 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=EB=B0=8F=20=EC=9E=91=EC=84=B1=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mumu/data/datasource/CommunityService.kt | 39 ++++ .../anu/mumu/data/model/CommunityPostDto.kt | 34 +++ .../repository/CommunityRepositoryImpl.kt | 86 ++++++- .../ac/anu/mumu/domain/model/CommunityPost.kt | 19 +- .../domain/repository/CommunityRepository.kt | 12 + .../main/CommunityDetailFragment.kt | 96 ++++++++ .../main/CommunityDetailViewModel.kt | 93 ++++++++ .../main/CommunityWriteFragment.kt | 82 +++++++ .../main/CommunityWriteViewModel.kt | 49 ++++ .../mumu/presentation/main/HomeFragment.kt | 4 + .../mumu/presentation/main/MainActivity.kt | 18 +- .../mumu/presentation/main/PostFragment.kt | 9 +- .../main/adapter/CommunityCommentAdapter.kt | 41 ++++ .../presentation/main/adapter/PostAdapter.kt | 9 +- .../res/layout/fragment_community_detail.xml | 214 ++++++++++++++++++ .../res/layout/fragment_community_write.xml | 115 ++++++++++ .../res/layout/item_community_comment.xml | 34 +++ .../main/res/navigation/nav_graph_main.xml | 17 ++ 18 files changed, 952 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt create mode 100644 app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailViewModel.kt create mode 100644 app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteFragment.kt create mode 100644 app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteViewModel.kt create mode 100644 app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt create mode 100644 app/src/main/res/layout/fragment_community_detail.xml create mode 100644 app/src/main/res/layout/fragment_community_write.xml create mode 100644 app/src/main/res/layout/item_community_comment.xml diff --git a/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt b/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt index b6e2926..d2b5d2c 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/datasource/CommunityService.kt @@ -1,10 +1,18 @@ package kr.ac.anu.mumu.data.datasource import kr.ac.anu.mumu.data.model.BaseResponse +import kr.ac.anu.mumu.data.model.BookmarkDto +import kr.ac.anu.mumu.data.model.CommentDto +import kr.ac.anu.mumu.data.model.CommentRequestDto import kr.ac.anu.mumu.data.model.CommunityPostDto +import kr.ac.anu.mumu.data.model.CommunityRequestDto +import kr.ac.anu.mumu.data.model.LikeDto import kr.ac.anu.mumu.data.model.PaginatedData import retrofit2.Response +import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path import retrofit2.http.Query interface CommunityService { @@ -13,4 +21,35 @@ interface CommunityService { @Query("page") page: Int = 0, @Query("size") size: Int = 50 ): Response>> + + @GET("/api/community/{communityId}") + suspend fun getPost( + @Path("communityId") communityId: Long + ): Response> + + @POST("/api/community") + suspend fun createPost( + @Body request: CommunityRequestDto + ): Response> + + @POST("/api/likes/community/{communityId}") + suspend fun toggleLike( + @Path("communityId") communityId: Long + ): Response> + + @POST("/api/bookmarks/{communityId}") + suspend fun toggleBookmark( + @Path("communityId") communityId: Long + ): Response> + + @GET("/api/community/{communityId}/comments") + suspend fun getComments( + @Path("communityId") communityId: Long + ): Response>> + + @POST("/api/community/{communityId}/comments") + suspend fun createComment( + @Path("communityId") communityId: Long, + @Body request: CommentRequestDto + ): Response> } diff --git a/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt b/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt index da47c2c..2712a47 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/model/CommunityPostDto.kt @@ -17,3 +17,37 @@ data class CommunityPostDto( val updatedAt: String, val thumbnailUrl: String? ) + +data class CommunityRequestDto( + val category: String, + val title: String, + val content: String, + val hashtags: List, + val petId: Long? = null +) + +data class CommentDto( + val commentId: Long, + val communityId: Long, + val userId: Long, + val parentCommentId: Long?, + val content: String, + val likeCount: Int, + val createdAt: String, + val updatedAt: String +) + +data class CommentRequestDto( + val content: String, + val parentCommentId: Long? = null +) + +data class LikeDto( + val liked: Boolean, + val likeCount: Int +) + +data class BookmarkDto( + val bookmarked: Boolean, + val bookmarkCount: Int +) diff --git a/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt b/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt index d5a9b58..b82c781 100644 --- a/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt +++ b/app/src/main/java/kr/ac/anu/mumu/data/repository/CommunityRepositoryImpl.kt @@ -1,6 +1,11 @@ package kr.ac.anu.mumu.data.repository import kr.ac.anu.mumu.data.datasource.CommunityService +import kr.ac.anu.mumu.data.model.CommentDto +import kr.ac.anu.mumu.data.model.CommentRequestDto +import kr.ac.anu.mumu.data.model.CommunityPostDto +import kr.ac.anu.mumu.data.model.CommunityRequestDto +import kr.ac.anu.mumu.domain.model.CommunityComment import kr.ac.anu.mumu.domain.model.CommunityPost import kr.ac.anu.mumu.domain.repository.CommunityRepository import javax.inject.Inject @@ -15,12 +20,81 @@ class CommunityRepositoryImpl @Inject constructor( if (!response.isSuccessful || body?.success != true) { error(body?.message ?: "게시글을 불러오지 못했습니다. (${response.code()})") } - body.data?.content.orEmpty().map { post -> - CommunityPost( - id = post.communityId, - title = post.title, - thumbnailUrl = post.thumbnailUrl - ) + body.data?.content.orEmpty().map { it.toDomain() } + } + + override suspend fun getPost(postId: Long): Result = runCatching { + val response = communityService.getPost(postId) + response.requireData("게시글을 불러오지 못했습니다.").toDomain() + } + + override suspend fun createPost( + category: String, + title: String, + content: String, + hashtags: List + ): Result = runCatching { + val response = communityService.createPost( + CommunityRequestDto(category, title, content, hashtags) + ) + response.requireData("게시글을 등록하지 못했습니다.").toDomain() + } + + override suspend fun toggleLike(postId: Long): Result> = runCatching { + val data = communityService.toggleLike(postId).requireData("좋아요 처리에 실패했습니다.") + data.liked to data.likeCount + } + + override suspend fun toggleBookmark(postId: Long): Result> = runCatching { + val data = communityService.toggleBookmark(postId).requireData("북마크 처리에 실패했습니다.") + data.bookmarked to data.bookmarkCount + } + + override suspend fun getComments(postId: Long): Result> = runCatching { + val response = communityService.getComments(postId) + val body = response.body() + if (!response.isSuccessful || body?.success != true) { + error(body?.message ?: "댓글을 불러오지 못했습니다. (${response.code()})") + } + body.data.orEmpty().map { it.toDomain() } + } + + override suspend fun createComment(postId: Long, content: String): Result = runCatching { + communityService.createComment(postId, CommentRequestDto(content)) + .requireData("댓글을 등록하지 못했습니다.") + .toDomain() + } + + private fun CommunityPostDto.toDomain() = CommunityPost( + id = communityId, + userId = userId, + title = title, + content = content, + category = category, + hashtags = hashtags.orEmpty(), + thumbnailUrl = thumbnailUrl, + viewCount = viewCount, + likeCount = likeCount, + commentCount = commentCount, + bookmarkCount = bookmarkCount, + createdAt = createdAt + ) + + private fun CommentDto.toDomain() = CommunityComment( + id = commentId, + userId = userId, + content = content, + likeCount = likeCount, + createdAt = createdAt + ) + + private fun retrofit2.Response>.requireData( + fallbackMessage: String + ): T { + val body = body() + if (!isSuccessful || body?.success != true) { + error(body?.message ?: "$fallbackMessage (${code()})") } + return body.data ?: error("서버 응답이 비어 있습니다.") } } diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt b/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt index 08cb008..9d5e27b 100644 --- a/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt +++ b/app/src/main/java/kr/ac/anu/mumu/domain/model/CommunityPost.kt @@ -2,6 +2,23 @@ package kr.ac.anu.mumu.domain.model data class CommunityPost( val id: Long, + val userId: Long, val title: String, - val thumbnailUrl: String? + val content: String, + val category: String, + val hashtags: List, + val thumbnailUrl: String?, + val viewCount: Int, + val likeCount: Int, + val commentCount: Int, + val bookmarkCount: Int, + val createdAt: String +) + +data class CommunityComment( + val id: Long, + val userId: Long, + val content: String, + val likeCount: Int, + val createdAt: String ) diff --git a/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt b/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt index b5c4745..4507073 100644 --- a/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt +++ b/app/src/main/java/kr/ac/anu/mumu/domain/repository/CommunityRepository.kt @@ -1,7 +1,19 @@ package kr.ac.anu.mumu.domain.repository +import kr.ac.anu.mumu.domain.model.CommunityComment import kr.ac.anu.mumu.domain.model.CommunityPost interface CommunityRepository { suspend fun getPosts(): Result> + suspend fun getPost(postId: Long): Result + suspend fun createPost( + category: String, + title: String, + content: String, + hashtags: List + ): Result + suspend fun toggleLike(postId: Long): Result> + suspend fun toggleBookmark(postId: Long): Result> + suspend fun getComments(postId: Long): Result> + suspend fun createComment(postId: Long, content: String): Result } diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt new file mode 100644 index 0000000..ae449ec --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt @@ -0,0 +1,96 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import coil3.load +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.databinding.FragmentCommunityDetailBinding +import kr.ac.anu.mumu.presentation.main.adapter.CommunityCommentAdapter + +@AndroidEntryPoint +class CommunityDetailFragment : Fragment() { + + private var _binding: FragmentCommunityDetailBinding? = null + private val binding get() = _binding!! + private val viewModel: CommunityDetailViewModel by viewModels() + private val commentAdapter = CommunityCommentAdapter() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentCommunityDetailBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.rvComments.apply { + adapter = commentAdapter + layoutManager = LinearLayoutManager(requireContext()) + isNestedScrollingEnabled = false + } + binding.btnRetry.setOnClickListener { viewModel.load() } + binding.btnLike.setOnClickListener { viewModel.toggleLike() } + binding.btnBookmark.setOnClickListener { viewModel.toggleBookmark() } + binding.btnComment.setOnClickListener { + val content = binding.etComment.text?.toString().orEmpty() + if (content.isNotBlank()) { + viewModel.addComment(content) + binding.etComment.text?.clear() + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::render) + } + } + } + + private fun render(state: CommunityDetailUiState) { + binding.progressDetail.visibility = if (state is CommunityDetailUiState.Loading) View.VISIBLE else View.GONE + binding.layoutError.visibility = if (state is CommunityDetailUiState.Error) View.VISIBLE else View.GONE + binding.scrollContent.visibility = if (state is CommunityDetailUiState.Success) View.VISIBLE else View.GONE + + when (state) { + CommunityDetailUiState.Loading -> Unit + is CommunityDetailUiState.Error -> binding.tvError.text = state.message + is CommunityDetailUiState.Success -> { + val post = state.post + binding.tvCategory.text = post.category + binding.tvTitle.text = post.title + binding.tvContent.text = post.content + binding.tvMeta.text = "조회 ${post.viewCount} · 댓글 ${post.commentCount} · ${post.createdAt.take(10)}" + binding.tvHashtags.text = post.hashtags.joinToString(" ") { "#$it" } + binding.tvHashtags.visibility = if (post.hashtags.isEmpty()) View.GONE else View.VISIBLE + binding.btnLike.text = if (state.liked) "♥ ${post.likeCount}" else "♡ ${post.likeCount}" + binding.btnBookmark.text = if (state.bookmarked) "북마크됨" else "북마크 ${post.bookmarkCount}" + if (post.thumbnailUrl.isNullOrBlank()) { + binding.ivThumbnail.visibility = View.GONE + } else { + binding.ivThumbnail.visibility = View.VISIBLE + binding.ivThumbnail.load(post.thumbnailUrl) + } + commentAdapter.submitList(state.comments) + binding.tvCommentEmpty.visibility = if (state.comments.isEmpty()) View.VISIBLE else View.GONE + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailViewModel.kt new file mode 100644 index 0000000..e4e3189 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailViewModel.kt @@ -0,0 +1,93 @@ +package kr.ac.anu.mumu.presentation.main + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.domain.model.CommunityComment +import kr.ac.anu.mumu.domain.model.CommunityPost +import kr.ac.anu.mumu.domain.repository.CommunityRepository +import javax.inject.Inject + +sealed interface CommunityDetailUiState { + data object Loading : CommunityDetailUiState + data class Success( + val post: CommunityPost, + val comments: List, + val liked: Boolean = false, + val bookmarked: Boolean = false + ) : CommunityDetailUiState + data class Error(val message: String) : CommunityDetailUiState +} + +@HiltViewModel +class CommunityDetailViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val repository: CommunityRepository +) : ViewModel() { + + private val postId: Long = checkNotNull(savedStateHandle["postId"]) + private val _uiState = MutableStateFlow(CommunityDetailUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + load() + } + + fun load() { + viewModelScope.launch { + _uiState.value = CommunityDetailUiState.Loading + repository.getPost(postId) + .onSuccess { post -> + val comments = repository.getComments(postId).getOrDefault(emptyList()) + _uiState.value = CommunityDetailUiState.Success(post, comments) + } + .onFailure { error -> + _uiState.value = CommunityDetailUiState.Error( + error.message ?: "게시글을 불러오지 못했습니다." + ) + } + } + } + + fun toggleLike() { + val current = _uiState.value as? CommunityDetailUiState.Success ?: return + viewModelScope.launch { + repository.toggleLike(postId).onSuccess { (liked, count) -> + _uiState.value = current.copy( + post = current.post.copy(likeCount = count), + liked = liked + ) + } + } + } + + fun toggleBookmark() { + val current = _uiState.value as? CommunityDetailUiState.Success ?: return + viewModelScope.launch { + repository.toggleBookmark(postId).onSuccess { (bookmarked, count) -> + _uiState.value = current.copy( + post = current.post.copy(bookmarkCount = count), + bookmarked = bookmarked + ) + } + } + } + + fun addComment(content: String) { + val current = _uiState.value as? CommunityDetailUiState.Success ?: return + if (content.isBlank()) return + viewModelScope.launch { + repository.createComment(postId, content.trim()).onSuccess { comment -> + _uiState.value = current.copy( + post = current.post.copy(commentCount = current.post.commentCount + 1), + comments = current.comments + comment + ) + } + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteFragment.kt new file mode 100644 index 0000000..374dbf7 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteFragment.kt @@ -0,0 +1,82 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.FragmentCommunityWriteBinding + +@AndroidEntryPoint +class CommunityWriteFragment : Fragment() { + + private var _binding: FragmentCommunityWriteBinding? = null + private val binding get() = _binding!! + private val viewModel: CommunityWriteViewModel by viewModels() + private val categories = listOf("FREE", "QUESTION", "INFO", "BRAG", "REVIEW") + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentCommunityWriteBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.spinnerCategory.adapter = ArrayAdapter( + requireContext(), + android.R.layout.simple_spinner_dropdown_item, + listOf("자유", "질문", "정보", "자랑", "후기") + ) + binding.btnSubmit.setOnClickListener { + viewModel.submit( + category = categories[binding.spinnerCategory.selectedItemPosition], + title = binding.etTitle.text?.toString().orEmpty(), + content = binding.etContent.text?.toString().orEmpty(), + hashtags = binding.etHashtags.text?.toString().orEmpty() + ) + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::render) + } + } + } + + private fun render(state: CommunityWriteUiState) { + binding.progressSubmit.visibility = if (state is CommunityWriteUiState.Loading) View.VISIBLE else View.GONE + binding.btnSubmit.isEnabled = state !is CommunityWriteUiState.Loading + binding.tvError.visibility = if (state is CommunityWriteUiState.Error) View.VISIBLE else View.GONE + + when (state) { + CommunityWriteUiState.Idle, + CommunityWriteUiState.Loading -> Unit + is CommunityWriteUiState.Error -> binding.tvError.text = state.message + is CommunityWriteUiState.Success -> { + findNavController().navigate( + R.id.communityDetailFragment, + Bundle().apply { putLong("postId", state.postId) } + ) + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteViewModel.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteViewModel.kt new file mode 100644 index 0000000..10b94eb --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityWriteViewModel.kt @@ -0,0 +1,49 @@ +package kr.ac.anu.mumu.presentation.main + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.domain.repository.CommunityRepository +import javax.inject.Inject + +sealed interface CommunityWriteUiState { + data object Idle : CommunityWriteUiState + data object Loading : CommunityWriteUiState + data class Success(val postId: Long) : CommunityWriteUiState + data class Error(val message: String) : CommunityWriteUiState +} + +@HiltViewModel +class CommunityWriteViewModel @Inject constructor( + private val repository: CommunityRepository +) : ViewModel() { + + private val _uiState = MutableStateFlow(CommunityWriteUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + fun submit(category: String, title: String, content: String, hashtags: String) { + if (title.isBlank() || content.isBlank()) { + _uiState.value = CommunityWriteUiState.Error("제목과 내용을 입력해 주세요.") + return + } + val hashtagList = hashtags.split(',', ' ', '#') + .map(String::trim) + .filter(String::isNotBlank) + .distinct() + + viewModelScope.launch { + _uiState.value = CommunityWriteUiState.Loading + repository.createPost(category, title.trim(), content.trim(), hashtagList) + .onSuccess { post -> _uiState.value = CommunityWriteUiState.Success(post.id) } + .onFailure { error -> + _uiState.value = CommunityWriteUiState.Error( + error.message ?: "게시글을 등록하지 못했습니다." + ) + } + } + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HomeFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HomeFragment.kt index 03301e2..7f394b7 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/HomeFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/HomeFragment.kt @@ -49,6 +49,10 @@ class HomeFragment : Fragment() { binding.btnAnalyze.setOnClickListener { findNavController().navigate(R.id.action_homeFragment_to_analysisStartFragment) } + + binding.btnWrite.setOnClickListener { + findNavController().navigate(R.id.action_homeFragment_to_communityWriteFragment) + } } private fun replaceFragment(fragment: Fragment) { diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt index ccccbb1..48762a5 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt @@ -34,13 +34,15 @@ class MainActivity : AppCompatActivity() { binding.bottomNav.setupWithNavController(navController) - val analysisDestinations = setOf( + val detailDestinations = setOf( R.id.analysisStartFragment, R.id.analysisCaptureFragment, R.id.analysisUploadFragment, R.id.analysisLoadingFragment, R.id.analysisResultFragment, - R.id.analysisHistoryDetailFragment + R.id.analysisHistoryDetailFragment, + R.id.communityDetailFragment, + R.id.communityWriteFragment ) binding.btnBack.setOnClickListener { @@ -48,12 +50,12 @@ class MainActivity : AppCompatActivity() { } navController.addOnDestinationChangedListener { _, destination, _ -> - val isAnalysisDestination = destination.id in analysisDestinations - binding.btnBack.visibility = if (isAnalysisDestination) View.VISIBLE else View.GONE - binding.ivLogo.visibility = if (isAnalysisDestination) View.GONE else View.VISIBLE - binding.tvMumu.visibility = if (isAnalysisDestination) View.GONE else View.VISIBLE - binding.tvAnalysisMumu.visibility = if (isAnalysisDestination) View.VISIBLE else View.GONE - binding.bottomNav.visibility = if (isAnalysisDestination) View.GONE else View.VISIBLE + val isDetailDestination = destination.id in detailDestinations + binding.btnBack.visibility = if (isDetailDestination) View.VISIBLE else View.GONE + binding.ivLogo.visibility = if (isDetailDestination) View.GONE else View.VISIBLE + binding.tvMumu.visibility = if (isDetailDestination) View.GONE else View.VISIBLE + binding.tvAnalysisMumu.visibility = if (isDetailDestination) View.VISIBLE else View.GONE + binding.bottomNav.visibility = if (isDetailDestination) View.GONE else View.VISIBLE } ViewCompat.setOnApplyWindowInsetsListener(binding.bottomNav) { view, windowInsets -> diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostFragment.kt index 89733b7..12633d6 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/PostFragment.kt @@ -9,9 +9,11 @@ import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController import androidx.recyclerview.widget.GridLayoutManager import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch +import kr.ac.anu.mumu.R import kr.ac.anu.mumu.databinding.FragmentPostBinding import kr.ac.anu.mumu.presentation.main.adapter.PostAdapter import kr.ac.anu.mumu.presentation.main.adapter.PostGridSpacingDecoration @@ -22,7 +24,12 @@ class PostFragment : Fragment() { private var _binding: FragmentPostBinding? = null private val binding get() = _binding!! private val viewModel: PostViewModel by viewModels() - private val postAdapter = PostAdapter() + private val postAdapter = PostAdapter { post -> + findNavController().navigate( + R.id.communityDetailFragment, + Bundle().apply { putLong("postId", post.id) } + ) + } override fun onCreateView( inflater: LayoutInflater, diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt new file mode 100644 index 0000000..dbae14f --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt @@ -0,0 +1,41 @@ +package kr.ac.anu.mumu.presentation.main.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import kr.ac.anu.mumu.databinding.ItemCommunityCommentBinding +import kr.ac.anu.mumu.domain.model.CommunityComment + +class CommunityCommentAdapter : + ListAdapter(DiffCallback) { + + class CommentViewHolder( + private val binding: ItemCommunityCommentBinding + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(comment: CommunityComment) { + binding.tvAuthor.text = "사용자 ${comment.userId}" + binding.tvContent.text = comment.content + binding.tvMeta.text = "좋아요 ${comment.likeCount} · ${comment.createdAt.take(10)}" + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CommentViewHolder { + val binding = ItemCommunityCommentBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + return CommentViewHolder(binding) + } + + override fun onBindViewHolder(holder: CommentViewHolder, position: Int) { + holder.bind(getItem(position)) + } + + private object DiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CommunityComment, newItem: CommunityComment) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: CommunityComment, newItem: CommunityComment) = oldItem == newItem + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostAdapter.kt index e4245aa..224fb37 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostAdapter.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/PostAdapter.kt @@ -10,19 +10,22 @@ import kr.ac.anu.mumu.R import kr.ac.anu.mumu.databinding.ItemPostBinding import kr.ac.anu.mumu.domain.model.CommunityPost -class PostAdapter : ListAdapter(PostDiffCallback) { +class PostAdapter( + private val onClick: (CommunityPost) -> Unit +) : ListAdapter(PostDiffCallback) { class PostViewHolder( private val binding: ItemPostBinding ) : RecyclerView.ViewHolder(binding.root) { - fun bind(post: CommunityPost) { + fun bind(post: CommunityPost, onClick: (CommunityPost) -> Unit) { binding.ivPostImage.contentDescription = post.title if (post.thumbnailUrl.isNullOrBlank()) { binding.ivPostImage.setImageResource(R.drawable.ic_diary) } else { binding.ivPostImage.load(post.thumbnailUrl) } + binding.root.setOnClickListener { onClick(post) } } } @@ -36,7 +39,7 @@ class PostAdapter : ListAdapter(PostD } override fun onBindViewHolder(holder: PostViewHolder, position: Int) { - holder.bind(getItem(position)) + holder.bind(getItem(position), onClick) } private object PostDiffCallback : DiffUtil.ItemCallback() { diff --git a/app/src/main/res/layout/fragment_community_detail.xml b/app/src/main/res/layout/fragment_community_detail.xml new file mode 100644 index 0000000..d52d23a --- /dev/null +++ b/app/src/main/res/layout/fragment_community_detail.xml @@ -0,0 +1,214 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_community_write.xml b/app/src/main/res/layout/fragment_community_write.xml new file mode 100644 index 0000000..82e2365 --- /dev/null +++ b/app/src/main/res/layout/fragment_community_write.xml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_community_comment.xml b/app/src/main/res/layout/item_community_comment.xml new file mode 100644 index 0000000..8d0014c --- /dev/null +++ b/app/src/main/res/layout/item_community_comment.xml @@ -0,0 +1,34 @@ + + + + + + + + + diff --git a/app/src/main/res/navigation/nav_graph_main.xml b/app/src/main/res/navigation/nav_graph_main.xml index 9b560f2..ead9609 100644 --- a/app/src/main/res/navigation/nav_graph_main.xml +++ b/app/src/main/res/navigation/nav_graph_main.xml @@ -23,6 +23,9 @@ + + + + + + + From 56f41aa9b229a33f6298cc7397f023c158639e6c Mon Sep 17 00:00:00 2001 From: bhindor Date: Thu, 27 Aug 2026 10:16:59 +0900 Subject: [PATCH 2/5] =?UTF-8?q?design:=20=EC=BB=A4=EB=AE=A4=EB=8B=88?= =?UTF-8?q?=ED=8B=B0=20=ED=99=94=EB=A9=B4=20Figma=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=98=EC=98=81=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/CommunityDetailFragment.kt | 15 +- .../presentation/main/CommunityFragment.kt | 96 ++++++ .../main/CommunityPostListFragment.kt | 109 ++++++ .../mumu/presentation/main/MainActivity.kt | 16 +- .../main/adapter/CommunityCommentAdapter.kt | 3 +- .../main/adapter/CommunityFeedAdapter.kt | 69 ++++ .../adapter/CommunityPopularPostAdapter.kt | 49 +++ .../main/res/drawable/bg_community_action.xml | 6 + .../main/res/drawable/bg_community_avatar.xml | 5 + .../main/res/drawable/bg_community_card.xml | 6 + .../main/res/drawable/bg_community_chip.xml | 5 + .../res/drawable/bg_community_feed_panel.xml | 6 + .../main/res/drawable/bg_community_input.xml | 6 + .../main/res/drawable/bg_community_search.xml | 6 + .../main/res/drawable/ic_community_heart.xml | 10 + .../main/res/drawable/ic_community_search.xml | 10 + .../main/res/drawable/ic_community_write.xml | 10 + app/src/main/res/layout/activity_main.xml | 17 +- .../main/res/layout/fragment_community.xml | 322 ++++++++++++++++++ .../res/layout/fragment_community_detail.xml | 280 ++++++++++----- .../layout/fragment_community_post_list.xml | 136 ++++++++ .../res/layout/fragment_community_write.xml | 200 ++++++++--- .../res/layout/item_community_comment.xml | 62 +++- .../layout/item_community_guardian_rank.xml | 26 ++ .../layout/item_community_popular_post.xml | 47 +++ .../main/res/layout/item_community_post.xml | 161 +++++++++ .../res/layout/item_community_quick_menu.xml | 24 ++ app/src/main/res/menu/main_menu.xml | 3 +- .../main/res/navigation/nav_graph_main.xml | 15 + app/src/main/res/values/themes.xml | 4 + 30 files changed, 1563 insertions(+), 161 deletions(-) create mode 100644 app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityFragment.kt create mode 100644 app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityPostListFragment.kt create mode 100644 app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityFeedAdapter.kt create mode 100644 app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityPopularPostAdapter.kt create mode 100644 app/src/main/res/drawable/bg_community_action.xml create mode 100644 app/src/main/res/drawable/bg_community_avatar.xml create mode 100644 app/src/main/res/drawable/bg_community_card.xml create mode 100644 app/src/main/res/drawable/bg_community_chip.xml create mode 100644 app/src/main/res/drawable/bg_community_feed_panel.xml create mode 100644 app/src/main/res/drawable/bg_community_input.xml create mode 100644 app/src/main/res/drawable/bg_community_search.xml create mode 100644 app/src/main/res/drawable/ic_community_heart.xml create mode 100644 app/src/main/res/drawable/ic_community_search.xml create mode 100644 app/src/main/res/drawable/ic_community_write.xml create mode 100644 app/src/main/res/layout/fragment_community.xml create mode 100644 app/src/main/res/layout/fragment_community_post_list.xml create mode 100644 app/src/main/res/layout/item_community_guardian_rank.xml create mode 100644 app/src/main/res/layout/item_community_popular_post.xml create mode 100644 app/src/main/res/layout/item_community_post.xml create mode 100644 app/src/main/res/layout/item_community_quick_menu.xml diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt index ae449ec..c09bdfa 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityDetailFragment.kt @@ -69,10 +69,12 @@ class CommunityDetailFragment : Fragment() { is CommunityDetailUiState.Error -> binding.tvError.text = state.message is CommunityDetailUiState.Success -> { val post = state.post - binding.tvCategory.text = post.category + binding.tvPostAuthor.text = "사용자 ${post.userId}" + binding.tvCategory.text = post.category.toCategoryLabel() binding.tvTitle.text = post.title binding.tvContent.text = post.content - binding.tvMeta.text = "조회 ${post.viewCount} · 댓글 ${post.commentCount} · ${post.createdAt.take(10)}" + binding.tvMeta.text = + "${post.createdAt.take(10).replace('-', '.')} · 조회 ${post.viewCount} · 댓글 ${post.commentCount}" binding.tvHashtags.text = post.hashtags.joinToString(" ") { "#$it" } binding.tvHashtags.visibility = if (post.hashtags.isEmpty()) View.GONE else View.VISIBLE binding.btnLike.text = if (state.liked) "♥ ${post.likeCount}" else "♡ ${post.likeCount}" @@ -94,3 +96,12 @@ class CommunityDetailFragment : Fragment() { _binding = null } } + +private fun String.toCategoryLabel(): String = when (uppercase()) { + "FREE" -> "자유" + "QUESTION" -> "질문" + "INFO" -> "정보" + "BRAG" -> "자랑" + "REVIEW" -> "후기" + else -> this +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityFragment.kt new file mode 100644 index 0000000..4f52056 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityFragment.kt @@ -0,0 +1,96 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.inputmethod.EditorInfo +import android.widget.Toast +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.FragmentCommunityBinding +import kr.ac.anu.mumu.presentation.main.adapter.CommunityPopularPostAdapter + +@AndroidEntryPoint +class CommunityFragment : Fragment() { + + private var _binding: FragmentCommunityBinding? = null + private val binding get() = _binding!! + private val viewModel: PostViewModel by viewModels() + private val popularPostAdapter = CommunityPopularPostAdapter { post -> + findNavController().navigate( + R.id.communityDetailFragment, + Bundle().apply { putLong("postId", post.id) } + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentCommunityBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.rvPopularPosts.apply { + adapter = popularPostAdapter + layoutManager = LinearLayoutManager(requireContext()) + isNestedScrollingEnabled = false + } + binding.btnMorePosts.setOnClickListener { navigateToPostList() } + binding.btnMorePets.setOnClickListener { + Toast.makeText(requireContext(), "인기 아이 기능은 준비중이에요.", Toast.LENGTH_SHORT).show() + } + binding.etSearch.setOnEditorActionListener { _, actionId, _ -> + if (actionId == EditorInfo.IME_ACTION_SEARCH) { + navigateToPostList(binding.etSearch.text?.toString().orEmpty()) + true + } else { + false + } + } + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::render) + } + } + } + + private fun render(state: PostUiState) { + binding.progressPopularPosts.visibility = if (state is PostUiState.Loading) View.VISIBLE else View.GONE + when (state) { + PostUiState.Loading -> popularPostAdapter.submitList(emptyList()) + is PostUiState.Success -> { + popularPostAdapter.submitList( + state.posts.sortedByDescending { it.likeCount }.take(9) + ) + } + is PostUiState.Error -> popularPostAdapter.submitList(emptyList()) + } + } + + private fun navigateToPostList(query: String = "") { + findNavController().navigate( + R.id.communityPostListFragment, + Bundle().apply { putString("query", query.trim()) } + ) + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityPostListFragment.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityPostListFragment.kt new file mode 100644 index 0000000..2c73ca9 --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/CommunityPostListFragment.kt @@ -0,0 +1,109 @@ +package kr.ac.anu.mumu.presentation.main + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.widget.doAfterTextChanged +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.navigation.fragment.findNavController +import androidx.recyclerview.widget.LinearLayoutManager +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.FragmentCommunityPostListBinding +import kr.ac.anu.mumu.domain.model.CommunityPost +import kr.ac.anu.mumu.presentation.main.adapter.CommunityFeedAdapter + +@AndroidEntryPoint +class CommunityPostListFragment : Fragment() { + + private var _binding: FragmentCommunityPostListBinding? = null + private val binding get() = _binding!! + private val viewModel: PostViewModel by viewModels() + private var allPosts: List = emptyList() + private val postAdapter = CommunityFeedAdapter { post -> + findNavController().navigate( + R.id.communityDetailFragment, + Bundle().apply { putLong("postId", post.id) } + ) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = FragmentCommunityPostListBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.rvPosts.apply { + adapter = postAdapter + layoutManager = LinearLayoutManager(requireContext()) + } + binding.btnRetry.setOnClickListener { viewModel.loadPosts() } + binding.etSearch.doAfterTextChanged { filterPosts(it?.toString().orEmpty()) } + binding.etSearch.setText(arguments?.getString("query").orEmpty()) + + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.uiState.collect(::render) + } + } + } + + private fun render(state: PostUiState) { + binding.progressPosts.visibility = if (state is PostUiState.Loading) View.VISIBLE else View.GONE + binding.layoutError.visibility = if (state is PostUiState.Error) View.VISIBLE else View.GONE + + when (state) { + PostUiState.Loading -> { + binding.rvPosts.visibility = View.GONE + binding.layoutEmpty.visibility = View.GONE + } + is PostUiState.Success -> { + allPosts = state.posts + binding.rvPosts.visibility = View.VISIBLE + filterPosts(binding.etSearch.text?.toString().orEmpty()) + } + is PostUiState.Error -> { + binding.rvPosts.visibility = View.GONE + binding.layoutEmpty.visibility = View.GONE + binding.tvError.text = state.message + } + } + } + + private fun filterPosts(query: String) { + val keyword = query.trim() + val filtered = if (keyword.isEmpty()) { + allPosts + } else { + allPosts.filter { post -> + post.title.contains(keyword, ignoreCase = true) || + post.content.contains(keyword, ignoreCase = true) || + post.hashtags.any { it.contains(keyword.removePrefix("#"), ignoreCase = true) } + } + } + postAdapter.submitList(filtered) + binding.layoutEmpty.visibility = if (filtered.isEmpty()) View.VISIBLE else View.GONE + binding.tvEmpty.text = if (allPosts.isEmpty()) { + "아직 등록된 게시글이 없어요.\n첫 이야기를 남겨보세요!" + } else { + "검색 결과가 없어요.\n다른 단어로 찾아보세요." + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt index 48762a5..c085a39 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/MainActivity.kt @@ -41,6 +41,7 @@ class MainActivity : AppCompatActivity() { R.id.analysisLoadingFragment, R.id.analysisResultFragment, R.id.analysisHistoryDetailFragment, + R.id.communityPostListFragment, R.id.communityDetailFragment, R.id.communityWriteFragment ) @@ -49,12 +50,19 @@ class MainActivity : AppCompatActivity() { navController.navigateUp() } + binding.btnCommunityWrite.setOnClickListener { + navController.navigate(R.id.communityWriteFragment) + } + navController.addOnDestinationChangedListener { _, destination, _ -> val isDetailDestination = destination.id in detailDestinations - binding.btnBack.visibility = if (isDetailDestination) View.VISIBLE else View.GONE - binding.ivLogo.visibility = if (isDetailDestination) View.GONE else View.VISIBLE - binding.tvMumu.visibility = if (isDetailDestination) View.GONE else View.VISIBLE - binding.tvAnalysisMumu.visibility = if (isDetailDestination) View.VISIBLE else View.GONE + val isCommunityDestination = destination.id == R.id.communityFragment + val usesBackHeader = isDetailDestination + binding.btnBack.visibility = if (usesBackHeader) View.VISIBLE else View.GONE + binding.ivLogo.visibility = if (usesBackHeader) View.GONE else View.VISIBLE + binding.tvMumu.visibility = if (usesBackHeader) View.GONE else View.VISIBLE + binding.tvAnalysisMumu.visibility = if (usesBackHeader) View.VISIBLE else View.GONE + binding.btnCommunityWrite.visibility = if (isCommunityDestination) View.VISIBLE else View.GONE binding.bottomNav.visibility = if (isDetailDestination) View.GONE else View.VISIBLE } diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt index dbae14f..3b8a5f6 100644 --- a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityCommentAdapter.kt @@ -17,7 +17,8 @@ class CommunityCommentAdapter : fun bind(comment: CommunityComment) { binding.tvAuthor.text = "사용자 ${comment.userId}" binding.tvContent.text = comment.content - binding.tvMeta.text = "좋아요 ${comment.likeCount} · ${comment.createdAt.take(10)}" + binding.tvMeta.text = + "${comment.createdAt.take(10).replace('-', '.')} · 좋아요 ${comment.likeCount}" } } diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityFeedAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityFeedAdapter.kt new file mode 100644 index 0000000..8f2350b --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityFeedAdapter.kt @@ -0,0 +1,69 @@ +package kr.ac.anu.mumu.presentation.main.adapter + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import coil3.load +import kr.ac.anu.mumu.databinding.ItemCommunityPostBinding +import kr.ac.anu.mumu.domain.model.CommunityPost + +class CommunityFeedAdapter( + private val onClick: (CommunityPost) -> Unit +) : ListAdapter(PostDiffCallback) { + + class PostViewHolder( + private val binding: ItemCommunityPostBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(post: CommunityPost, onClick: (CommunityPost) -> Unit) = with(binding) { + tvAuthor.text = "사용자 ${post.userId}" + tvDate.text = post.createdAt.take(10).replace('-', '.') + tvCategory.text = post.category.toCategoryLabel() + tvTitle.text = post.title + tvContent.text = post.content + tvHashtags.text = post.hashtags.joinToString(" ") { "#$it" } + tvHashtags.visibility = if (post.hashtags.isEmpty()) View.INVISIBLE else View.VISIBLE + tvLikeCount.text = post.likeCount.toString() + ivPostImage.contentDescription = post.title + + if (post.thumbnailUrl.isNullOrBlank()) { + ivPostImage.visibility = View.GONE + } else { + ivPostImage.visibility = View.VISIBLE + ivPostImage.load(post.thumbnailUrl) + } + root.setOnClickListener { onClick(post) } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder { + return PostViewHolder( + ItemCommunityPostBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: PostViewHolder, position: Int) { + holder.bind(getItem(position), onClick) + } + + private object PostDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CommunityPost, newItem: CommunityPost) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: CommunityPost, newItem: CommunityPost) = oldItem == newItem + } +} + +private fun String.toCategoryLabel(): String = when (uppercase()) { + "FREE" -> "자유" + "QUESTION" -> "질문" + "INFO" -> "정보" + "BRAG" -> "자랑" + "REVIEW" -> "후기" + else -> this +} diff --git a/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityPopularPostAdapter.kt b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityPopularPostAdapter.kt new file mode 100644 index 0000000..3a33c9d --- /dev/null +++ b/app/src/main/java/kr/ac/anu/mumu/presentation/main/adapter/CommunityPopularPostAdapter.kt @@ -0,0 +1,49 @@ +package kr.ac.anu.mumu.presentation.main.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter +import androidx.recyclerview.widget.RecyclerView +import kr.ac.anu.mumu.R +import kr.ac.anu.mumu.databinding.ItemCommunityPopularPostBinding +import kr.ac.anu.mumu.domain.model.CommunityPost + +class CommunityPopularPostAdapter( + private val onClick: (CommunityPost) -> Unit +) : ListAdapter(PostDiffCallback) { + + class PostViewHolder( + private val binding: ItemCommunityPopularPostBinding + ) : RecyclerView.ViewHolder(binding.root) { + + fun bind(post: CommunityPost, onClick: (CommunityPost) -> Unit) = with(binding) { + tvTitle.text = post.title + tvLikeCount.text = post.likeCount.toString() + val heartColor = if (post.likeCount > 0) R.color.mumured else R.color.mumugray_150 + ivHeart.setColorFilter(ContextCompat.getColor(root.context, heartColor)) + tvLikeCount.setTextColor(ContextCompat.getColor(root.context, heartColor)) + root.setOnClickListener { onClick(post) } + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder { + return PostViewHolder( + ItemCommunityPopularPostBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + override fun onBindViewHolder(holder: PostViewHolder, position: Int) { + holder.bind(getItem(position), onClick) + } + + private object PostDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: CommunityPost, newItem: CommunityPost) = oldItem.id == newItem.id + override fun areContentsTheSame(oldItem: CommunityPost, newItem: CommunityPost) = oldItem == newItem + } +} diff --git a/app/src/main/res/drawable/bg_community_action.xml b/app/src/main/res/drawable/bg_community_action.xml new file mode 100644 index 0000000..337161b --- /dev/null +++ b/app/src/main/res/drawable/bg_community_action.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_community_avatar.xml b/app/src/main/res/drawable/bg_community_avatar.xml new file mode 100644 index 0000000..71f25ca --- /dev/null +++ b/app/src/main/res/drawable/bg_community_avatar.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_community_card.xml b/app/src/main/res/drawable/bg_community_card.xml new file mode 100644 index 0000000..59f3f02 --- /dev/null +++ b/app/src/main/res/drawable/bg_community_card.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_community_chip.xml b/app/src/main/res/drawable/bg_community_chip.xml new file mode 100644 index 0000000..4518565 --- /dev/null +++ b/app/src/main/res/drawable/bg_community_chip.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_community_feed_panel.xml b/app/src/main/res/drawable/bg_community_feed_panel.xml new file mode 100644 index 0000000..e04d4cf --- /dev/null +++ b/app/src/main/res/drawable/bg_community_feed_panel.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_community_input.xml b/app/src/main/res/drawable/bg_community_input.xml new file mode 100644 index 0000000..041a528 --- /dev/null +++ b/app/src/main/res/drawable/bg_community_input.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_community_search.xml b/app/src/main/res/drawable/bg_community_search.xml new file mode 100644 index 0000000..35b853f --- /dev/null +++ b/app/src/main/res/drawable/bg_community_search.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_community_heart.xml b/app/src/main/res/drawable/ic_community_heart.xml new file mode 100644 index 0000000..60f0ba3 --- /dev/null +++ b/app/src/main/res/drawable/ic_community_heart.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_community_search.xml b/app/src/main/res/drawable/ic_community_search.xml new file mode 100644 index 0000000..acef3ca --- /dev/null +++ b/app/src/main/res/drawable/ic_community_search.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/drawable/ic_community_write.xml b/app/src/main/res/drawable/ic_community_write.xml new file mode 100644 index 0000000..a29681c --- /dev/null +++ b/app/src/main/res/drawable/ic_community_write.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 6c7d021..c5901d3 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -56,7 +56,7 @@ android:id="@+id/tv_analysis_mumu" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="8dp" + android:layout_marginStart="12dp" android:fontFamily="@font/pretendard_semibold" android:text="Mumu" android:textColor="@color/mumu_300" @@ -67,6 +67,21 @@ app:layout_constraintTop_toTopOf="parent" tools:visibility="gone" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_community_detail.xml b/app/src/main/res/layout/fragment_community_detail.xml index d52d23a..5dfefa7 100644 --- a/app/src/main/res/layout/fragment_community_detail.xml +++ b/app/src/main/res/layout/fragment_community_detail.xml @@ -1,6 +1,7 @@ @@ -11,8 +12,6 @@ android:layout_height="0dp" android:clipToPadding="false" android:fillViewport="true" - android:paddingHorizontal="16dp" - android:paddingBottom="24dp" android:visibility="gone" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" @@ -22,106 +21,193 @@ + android:orientation="vertical" + android:paddingHorizontal="16dp" + android:paddingTop="16dp" + android:paddingBottom="28dp"> - - - + android:background="@drawable/bg_community_card" + android:orientation="vertical" + android:padding="16dp"> - + - + - + - + - + + - + + + + android:textSize="20sp" + tools:text="오늘 산책에서 만난 작은 행복" /> - + + + + + android:textSize="14sp" + tools:text="우리 아이와 함께한 오늘의 소중한 순간을 나눠요." /> + + + + + + + + + + + + + - + android:textColorHint="@color/mumugray_200" + android:textSize="12sp" /> @@ -159,7 +251,7 @@ android:layout_height="wrap_content" android:layout_marginTop="20dp" android:gravity="center" - android:text="아직 댓글이 없어요." + android:text="아직 댓글이 없어요. 첫 댓글을 남겨보세요!" android:textColor="@color/mumugray_200" android:textSize="12sp" /> @@ -167,7 +259,11 @@ android:id="@+id/rv_comments" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="8dp" /> + android:layout_marginTop="10dp" + android:background="@drawable/bg_community_card" + android:clipToPadding="false" + android:paddingVertical="4dp" + tools:listitem="@layout/item_community_comment" /> @@ -198,8 +294,10 @@ android:id="@+id/tv_error" android:layout_width="match_parent" android:layout_height="wrap_content" + android:fontFamily="@font/pretendard_medium" android:gravity="center" - android:textColor="@color/mumugray_350" /> + android:textColor="@color/mumugray_350" + android:textSize="14sp" /> diff --git a/app/src/main/res/layout/fragment_community_post_list.xml b/app/src/main/res/layout/fragment_community_post_list.xml new file mode 100644 index 0000000..147b147 --- /dev/null +++ b/app/src/main/res/layout/fragment_community_post_list.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_community_write.xml b/app/src/main/res/layout/fragment_community_write.xml index 82e2365..add5ca6 100644 --- a/app/src/main/res/layout/fragment_community_write.xml +++ b/app/src/main/res/layout/fragment_community_write.xml @@ -4,6 +4,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/white" + android:clipToPadding="false" android:fillViewport="true"> - + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + android:fontFamily="@font/pretendard_regular" + android:text="반려생활의 질문과 순간을 자유롭게 남겨보세요." + android:textColor="@color/mumugray_200" + android:textSize="11sp" /> - + android:layout_height="wrap_content" + android:layout_marginTop="20dp" + android:background="@drawable/bg_community_card" + android:orientation="vertical" + android:padding="16dp"> - + - + - + + + + + + + + + + + + + + android:background="@drawable/bg_community_input" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingHorizontal="14dp" + android:paddingVertical="12dp"> + + + + + + + + + + + android:layout_marginTop="18dp"> - + android:paddingTop="12dp" + android:paddingBottom="10dp"> + + + android:textSize="12sp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@id/tv_avatar" + app:layout_constraintTop_toTopOf="@id/tv_avatar" + tools:text="사용자 2" /> + android:textSize="13sp" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@id/tv_avatar" + app:layout_constraintTop_toBottomOf="@id/tv_author" + tools:text="정말 귀여워요! 다음 산책 이야기도 기대할게요." /> - + android:textSize="10sp" + app:layout_constraintStart_toEndOf="@id/tv_avatar" + app:layout_constraintTop_toBottomOf="@id/tv_content" + tools:text="2026.08.27 · 좋아요 2" /> + + + + diff --git a/app/src/main/res/layout/item_community_guardian_rank.xml b/app/src/main/res/layout/item_community_guardian_rank.xml new file mode 100644 index 0000000..fd8addc --- /dev/null +++ b/app/src/main/res/layout/item_community_guardian_rank.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/app/src/main/res/layout/item_community_popular_post.xml b/app/src/main/res/layout/item_community_popular_post.xml new file mode 100644 index 0000000..3c9b671 --- /dev/null +++ b/app/src/main/res/layout/item_community_popular_post.xml @@ -0,0 +1,47 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/item_community_post.xml b/app/src/main/res/layout/item_community_post.xml new file mode 100644 index 0000000..82c5a1e --- /dev/null +++ b/app/src/main/res/layout/item_community_post.xml @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/item_community_quick_menu.xml b/app/src/main/res/layout/item_community_quick_menu.xml new file mode 100644 index 0000000..2070143 --- /dev/null +++ b/app/src/main/res/layout/item_community_quick_menu.xml @@ -0,0 +1,24 @@ + + + + + + + diff --git a/app/src/main/res/menu/main_menu.xml b/app/src/main/res/menu/main_menu.xml index 99cd1fc..7f52ea3 100644 --- a/app/src/main/res/menu/main_menu.xml +++ b/app/src/main/res/menu/main_menu.xml @@ -16,6 +16,7 @@ android:title="홈" /> @@ -23,4 +24,4 @@ android:id="@+id/myFragment" android:icon="@drawable/ic_user" android:title="마이" /> - \ No newline at end of file + diff --git a/app/src/main/res/navigation/nav_graph_main.xml b/app/src/main/res/navigation/nav_graph_main.xml index ead9609..6e72fca 100644 --- a/app/src/main/res/navigation/nav_graph_main.xml +++ b/app/src/main/res/navigation/nav_graph_main.xml @@ -33,6 +33,21 @@ android:name="kr.ac.anu.mumu.presentation.main.MyFragment" tools:layout="@layout/fragment_my" /> + + + + + + 50% + +