Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/#67 게시글 목록 기능 구현 #79

Merged
merged 30 commits into from
Apr 5, 2024
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
b4868ff
feat : 게시글 등록 모듈 생성
chws0508 Mar 8, 2024
27c6192
feat : 게시글 등록 기능 UI 구현 완료
chws0508 Mar 8, 2024
038eb3c
feat : UI기능 전까지 완료
chws0508 Mar 8, 2024
16fadaa
feat : 커스텀 갤러리 기능 구현
chws0508 Mar 11, 2024
10a6e5f
feat : 갤러리에서 선택한 이미지를 게시글에서 받도록 구현
chws0508 Mar 11, 2024
d7c8136
feat : 게시글 등록 API 연동 구현
chws0508 Mar 11, 2024
a09216d
feat : StringResource 리팩토링
chws0508 Mar 12, 2024
5f024c0
feat : Dependency Graph 모듈 볼수 있는 플러그인 추가
chws0508 Mar 12, 2024
f206048
feat : 화면 회전시 앱이 안보이는 버그 수정
chws0508 Mar 13, 2024
0454238
feat : GetAlbumImagesUseCase로 네이밍 수정
chws0508 Mar 13, 2024
1e7f6a7
feat : GetAlbumImagesUseCase에서 ImagePagingInfo를 주도록 변경
chws0508 Mar 14, 2024
92d3e28
feat : GalleryViewModel Test 작성
chws0508 Mar 14, 2024
700f690
feat : 앱 난독화 적용
chws0508 Mar 15, 2024
7929d99
test : RegisterPostViewModel 테스트 작성
chws0508 Mar 15, 2024
b969197
feat : core-ui 모듈 추가 및 PostTopicUiState core-ui로 이동
chws0508 Mar 18, 2024
4a19142
refactor : Rebase 충돌 수정
chws0508 Mar 18, 2024
6b02cfe
refactor : 모듈을 post에서 postList로 수정
chws0508 Mar 18, 2024
29611b1
refactor : material3 버젼 업
chws0508 Mar 18, 2024
419f456
feat : 게시글 목록 화면 탭 UI 구현
chws0508 Mar 18, 2024
94865c7
feat : Tab에 viewModel 상태 적용
chws0508 Mar 18, 2024
5940514
feat : 게시글 목록 UI 구현 완료
chws0508 Mar 18, 2024
2153afc
feat : Date 계산 로직 도메인으로 이동
chws0508 Mar 19, 2024
022192c
feat : 사진 변경사항 적용
chws0508 Mar 24, 2024
fc192c5
feat : 게시글 목록 가져오는 기능 Usecase 및 Repository 로직 구현
chws0508 Mar 24, 2024
d823b00
feat : 게시글 목록 조회 Paging 기능 ViewModel 및 View에 적용
chws0508 Mar 24, 2024
b39019d
feat : snapshotList를 쓰지 않도록 수정
chws0508 Mar 30, 2024
8da7830
feat : 충돌 해결
chws0508 Apr 4, 2024
0f1053b
feat : 에러 이벤트 처리 구현
chws0508 Apr 4, 2024
7ef7d4d
feat : UiModel 적용
chws0508 Apr 4, 2024
de2f859
feat : 리뷰 반영
chws0508 Apr 5, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions app/src/main/java/com/withpeace/withpeace/MainBottomNavigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import com.withpeace.withpeace.feature.home.navigation.HOME_ROUTE
import com.withpeace.withpeace.feature.home.navigation.navigateHome
import com.withpeace.withpeace.feature.mypage.navigation.MY_PAGE_ROUTE
import com.withpeace.withpeace.feature.mypage.navigation.navigateMyPage
import com.withpeace.withpeace.feature.postlist.navigation.POST_LIST_ROUTE
import com.withpeace.withpeace.navigation.POST_NESTED_ROUTE

@Composable
Expand All @@ -34,19 +33,25 @@ fun MainBottomBar(
) {
BottomTab.entries.forEach { tab ->
NavigationBarItem(
colors = NavigationBarItemDefaults.colors(
selectedTextColor = WithpeaceTheme.colors.SystemBlack,
unselectedTextColor = WithpeaceTheme.colors.SystemGray2,
indicatorColor = WithpeaceTheme.colors.SystemWhite,
),
colors =
NavigationBarItemDefaults.colors(
selectedTextColor = WithpeaceTheme.colors.SystemBlack,
unselectedTextColor = WithpeaceTheme.colors.SystemGray2,
indicatorColor = WithpeaceTheme.colors.SystemWhite,
),
selected = currentDestination.route == tab.route,
onClick = { navController.navigateToTabScreen(tab) },
icon = {
Image(
painter = painterResource(
id = if (currentDestination.route == tab.route) tab.iconSelectedResId
else tab.iconUnSelectedResId,
),
painter =
painterResource(
id =
if (currentDestination.route == tab.route) {
tab.iconSelectedResId
} else {
tab.iconUnSelectedResId
},
),
contentDescription = context.getString(tab.contentDescription),
)
},
Expand Down Expand Up @@ -96,7 +101,8 @@ enum class BottomTab(
iconSelectedResId = R.drawable.ic_bottom_my_page_select,
contentDescription = R.string.my_page,
MY_PAGE_ROUTE,
);
),
;

companion object {
operator fun contains(route: String): Boolean {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.withpeace.withpeace.core.data.mapper

import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

fun String.toLocalDateTime(): LocalDateTime = LocalDateTime.parse(
this,
DateTimeFormatter.ofPattern(SERVER_DATE_FORMAT),
)

private const val SERVER_DATE_FORMAT = "yyyy/MM/dd HH:mm:ss"
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.withpeace.withpeace.core.data.mapper

import com.withpeace.withpeace.core.domain.model.date.Date
import com.withpeace.withpeace.core.domain.model.post.Post
import com.withpeace.withpeace.core.network.di.response.post.PostResponse

fun PostResponse.toDomain() =
Post(
postId = postId,
title = title,
content = content,
postTopic = type.toDomain(),
createDate = Date(createDate.toLocalDateTime()),
postImageUrl = postImageUrl,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.withpeace.withpeace.core.data.mapper

import com.withpeace.withpeace.core.domain.model.post.PostTopic
import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse
import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.ECONOMY
import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.FREEDOM
import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.HOBBY
import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.INFORMATION
import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.LIVING
import com.withpeace.withpeace.core.network.di.response.post.PostTopicResponse.QUESTION

fun PostTopicResponse.toDomain(): PostTopic {
return when (this) {
FREEDOM -> PostTopic.FREEDOM
INFORMATION -> PostTopic.INFORMATION
QUESTION -> PostTopic.QUESTION
LIVING -> PostTopic.LIVING
HOBBY -> PostTopic.HOBBY
ECONOMY -> PostTopic.ECONOMY
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,78 +2,107 @@ package com.withpeace.withpeace.core.data.repository

import android.content.Context
import android.net.Uri
import com.skydoves.sandwich.message
import com.skydoves.sandwich.messageOrNull
import com.skydoves.sandwich.suspendMapSuccess
import com.skydoves.sandwich.suspendOnError
import com.skydoves.sandwich.suspendOnException
import com.withpeace.withpeace.core.data.mapper.toDomain
import com.withpeace.withpeace.core.data.util.convertToFile
import com.withpeace.withpeace.core.domain.model.WithPeaceError
import com.withpeace.withpeace.core.domain.model.WithPeaceError.GeneralError
import com.withpeace.withpeace.core.domain.model.WithPeaceError.UnAuthorized
import com.withpeace.withpeace.core.domain.model.post.Post
import com.withpeace.withpeace.core.domain.model.post.PostTopic
import com.withpeace.withpeace.core.domain.model.post.RegisterPost
import com.withpeace.withpeace.core.domain.repository.PostRepository
import com.withpeace.withpeace.core.network.di.service.PostService
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import javax.inject.Inject

class DefaultPostRepository @Inject constructor(
@ApplicationContext private val context: Context,
private val postService: PostService,
) : PostRepository {
override fun registerPost(
post: RegisterPost,
onError: suspend (WithPeaceError) -> Unit,
): Flow<Long> =
flow {
val imageRequestBodies = getImageRequestBodies(post.images.urls)
val postRequestBodies = getPostRequestBodies(post)
postService.registerPost(postRequestBodies, imageRequestBodies)
.suspendMapSuccess {
emit(data.postId)
class DefaultPostRepository
@Inject
constructor(
@ApplicationContext private val context: Context,
private val postService: PostService,
) : PostRepository {
override fun getPosts(
postTopic: PostTopic,
pageIndex: Int,
pageSize: Int,
onError: suspend (WithPeaceError) -> Unit,
): Flow<List<Post>> =
flow {
postService.getPosts(
postTopic = postTopic.name,
pageIndex = pageIndex,
pageSize = pageSize,
).suspendMapSuccess {
emit(data.map { it.toDomain() })
}.suspendOnError {
if (statusCode.code == 401) onError(UnAuthorized())
else onError(GeneralError(statusCode.code, messageOrNull))
onError(GeneralError(statusCode.code, messageOrNull))
rhkrwngud445 marked this conversation as resolved.
Show resolved Hide resolved
}.suspendOnException {
onError(GeneralError(message = messageOrNull))
}
}
}.flowOn(Dispatchers.IO)

override fun registerPost(
post: RegisterPost,
onError: suspend (WithPeaceError) -> Unit,
): Flow<Long> =
flow {
val imageRequestBodies = getImageRequestBodies(post.images.urls)
val postRequestBodies = getPostRequestBodies(post)
postService.registerPost(postRequestBodies, imageRequestBodies)
.suspendMapSuccess {
emit(data.postId)
}.suspendOnError {
if (statusCode.code == 401) {
onError(UnAuthorized())
} else {
onError(GeneralError(statusCode.code, messageOrNull))
}
}.suspendOnException {
onError(GeneralError(message = messageOrNull))
}
}.flowOn(Dispatchers.IO)

private fun getImageRequestBodies(
imageUris: List<String>,
): List<MultipartBody.Part> {
val imageFiles = imageUris.map { Uri.parse(it).convertToFile(context) }
return imageFiles.map { file ->
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
MultipartBody.Part.createFormData(
IMAGES_COLUMN,
file.name,
requestFile,
)
private fun getImageRequestBodies(imageUris: List<String>): List<MultipartBody.Part> {
val imageFiles = imageUris.map { Uri.parse(it).convertToFile(context) }
return imageFiles.map { file ->
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
MultipartBody.Part.createFormData(
IMAGES_COLUMN,
file.name,
requestFile,
)
}
}
}

private fun getPostRequestBodies(post: RegisterPost): HashMap<String, RequestBody> {
return HashMap<String, RequestBody>().apply {
set(
TYPE_COLUMN,
post.topic.toString().toRequestBody("application/json".toMediaTypeOrNull()),
)
set(TITLE_COLUMN, post.title.toRequestBody("application/json".toMediaTypeOrNull()))
set(CONTENT_COLUMN, post.content.toRequestBody("application/json".toMediaTypeOrNull()))
private fun getPostRequestBodies(post: RegisterPost): HashMap<String, RequestBody> {
return HashMap<String, RequestBody>().apply {
set(
TYPE_COLUMN,
post.topic.toString().toRequestBody("application/json".toMediaTypeOrNull()),
)
set(TITLE_COLUMN, post.title.toRequestBody("application/json".toMediaTypeOrNull()))
set(CONTENT_COLUMN, post.content.toRequestBody("application/json".toMediaTypeOrNull()))
}
}
}

companion object {
const val TITLE_COLUMN = "title"
const val CONTENT_COLUMN = "content"
const val TYPE_COLUMN = "type"
const val IMAGES_COLUMN = "images"
companion object {
const val TITLE_COLUMN = "title"
const val CONTENT_COLUMN = "content"
const val TYPE_COLUMN = "type"
const val IMAGES_COLUMN = "images"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.withpeace.withpeace.core.domain.model.post

import androidx.paging.PagingConfig
import androidx.paging.PagingSource

data class PostPageInfo(
val pageSize: Int,
val enablePlaceholders: Boolean = true,
val pagingSource: PagingSource<Int, Post>,
) {
val pagingConfig =
PagingConfig(
pageSize = pageSize,
enablePlaceholders = enablePlaceholders,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.withpeace.withpeace.core.domain.paging

import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.withpeace.withpeace.core.domain.model.WithPeaceError
import com.withpeace.withpeace.core.domain.model.post.Post
import com.withpeace.withpeace.core.domain.model.post.PostTopic
import com.withpeace.withpeace.core.domain.repository.PostRepository
import kotlinx.coroutines.flow.firstOrNull

data class PostPagingSource(
private val postRepository: PostRepository,
private val postTopic: PostTopic,
private val pageSize: Int,
private val onError: suspend (WithPeaceError) -> Unit,
) : PagingSource<Int, Post>() {

override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Post> {
return try {
val currentPage = params.key ?: STARTING_PAGE_INDEX
val data = postRepository.getPosts(
postTopic = postTopic,
pageIndex = currentPage,
pageSize = params.loadSize,
onError = onError,
).firstOrNull() ?: emptyList()
val endOfPaginationReached = data.isEmpty()
val prevKey = if (currentPage == STARTING_PAGE_INDEX) null else currentPage - 1
val nextKey =
if (endOfPaginationReached) null else currentPage + (params.loadSize / pageSize)
LoadResult.Page(data, prevKey, nextKey)
} catch (exception: Exception) {
LoadResult.Error(exception)
}
}

override fun getRefreshKey(state: PagingState<Int, Post>): Int? {
return state.anchorPosition
}

companion object {
const val STARTING_PAGE_INDEX = 0
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
package com.withpeace.withpeace.core.domain.repository

import com.withpeace.withpeace.core.domain.model.WithPeaceError
import com.withpeace.withpeace.core.domain.model.post.Post
import com.withpeace.withpeace.core.domain.model.post.PostTopic
import com.withpeace.withpeace.core.domain.model.post.RegisterPost
import kotlinx.coroutines.flow.Flow

interface PostRepository {
fun getPosts(
postTopic: PostTopic,
pageIndex: Int,
pageSize: Int,
onError: suspend (WithPeaceError) -> Unit,
): Flow<List<Post>>

fun registerPost(
post: RegisterPost,
onError: suspend (WithPeaceError) -> Unit,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.withpeace.withpeace.core.domain.usecase

import com.withpeace.withpeace.core.domain.model.WithPeaceError
import com.withpeace.withpeace.core.domain.model.post.PostPageInfo
import com.withpeace.withpeace.core.domain.model.post.PostTopic
import com.withpeace.withpeace.core.domain.paging.PostPagingSource
import com.withpeace.withpeace.core.domain.repository.PostRepository
import javax.inject.Inject

class GetPostsUseCase @Inject constructor(
private val postRepository: PostRepository,
) {
operator fun invoke(
postTopic: PostTopic,
pageSize: Int,
onError: suspend (WithPeaceError) -> Unit,
): PostPageInfo = PostPageInfo(
pageSize = pageSize,
pagingSource = PostPagingSource(
postRepository = postRepository,
postTopic = postTopic,
pageSize = pageSize,
onError = onError,
),
)
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.withpeace.withpeace.core.network.di.response
package com.withpeace.withpeace.core.network.di.response.post

import kotlinx.serialization.Serializable

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.withpeace.withpeace.core.network.di.response.post

import kotlinx.serialization.Serializable

@Serializable
data class PostResponse(
val postId: Long,
val title: String,
val content: String,
val type: PostTopicResponse,
val postImageUrl: String? = null,
val createDate: String,
)
Loading
Loading