Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d2d109a
[refactor]: 탭 다시 선택했을 때 스크롤 최상단 (#102)
rbqks529 Aug 27, 2025
7da1a0a
Merge branch 'THIP-TextHip:develop' into refactor/#102_QA_4
rbqks529 Aug 29, 2025
09882e4
[refactor]: 닉네임으로 대문자 입력을 하지 못하게 수정 (#102)
rbqks529 Aug 29, 2025
0a3aa0f
[refactor]: 해당 textField를 Basic으로 구현(패딩 문제) (#102)
rbqks529 Aug 29, 2025
68e7b13
[refactor]: Screen에서 Content로 미구현된 부분을 Content로 수정 (#102)
rbqks529 Aug 30, 2025
21a3227
[refactor]: Screen에서 Content로 미구현된 부분을 Content로 수정 (#102)
rbqks529 Aug 30, 2025
a26da57
[refactor]: MyPageSave에서 저장된 피드와 책을 눌렀을 때 자세히 보기 화면으로 이동 로직 추가 (#102)
rbqks529 Aug 30, 2025
fce906c
[refactor]: 자동으로 소문자 변환 (#102)
rbqks529 Aug 30, 2025
8d82438
[refactor]: 사진 더보기 화면 아이콘 위치 수정 (#102)
rbqks529 Aug 31, 2025
b38efa3
[refactor]: 피드 게시글 페이지 사용자 칭호별 색 반영 (#102)
rbqks529 Aug 31, 2025
b0fe3da
[refactor]: 사용자 검색 시 사용자 칭호별 색 반영 (#102)
rbqks529 Aug 31, 2025
75008af
[refactor]: 피드 관련 QA 수정(닉네임 수정, 다른 유저 게시글 좋아요) (#102)
rbqks529 Aug 31, 2025
72d1ec5
[refactor]: 피드 관련 QA 수정(닉네임 수정, 다른 유저 게시글 좋아요) (#102)
rbqks529 Aug 31, 2025
f92b0e4
[refactor]: 책 조회 api cursor 기반으로 수정 (#102)
rbqks529 Sep 2, 2025
421e3a6
[refactor]: 모임 및 피드 생성시 책검색 무한 스크롤 로직 되도록 수정 (#102)
rbqks529 Sep 2, 2025
bd017ca
[refactor]: 책 조회 api cursor 기반으로 수정 (#102)
rbqks529 Sep 2, 2025
ef29f58
[refactor]: 참여중인 모임방 조회 api cursor 기반으로 수정 (#102)
rbqks529 Sep 2, 2025
a4645b1
[refactor]: 책 조회 api cursor 기반으로 수정 (#102)
rbqks529 Sep 2, 2025
e88aa37
[refactor]: 참여중인 모임방 dto 수정 (#102)
rbqks529 Sep 2, 2025
8388234
[refactor]: 이미지 업로드 방식 수정을 위한 dto수정 (#102)
rbqks529 Sep 2, 2025
7c67d8f
[refactor]: 이미지 업로드 헬퍼 구현 (#102)
rbqks529 Sep 2, 2025
2542073
[refactor]: 이미지 업로드 수정된 버전으로 구현 (#102)
rbqks529 Sep 2, 2025
926e777
[refactor]: 이미지 업로드 request 로직 수정 (#102)
rbqks529 Sep 3, 2025
8947228
[refactor]: 이미지 업로드 request 로직 수정 (#102)
rbqks529 Sep 3, 2025
c171e22
[refactor]: QA 반영 띄어쓰기 및 dp 수정 (#102)
rbqks529 Sep 3, 2025
eb16b28
[refactor]: QA 반영 기록장 text field 수정 (#102)
rbqks529 Sep 3, 2025
eab3e5c
[refactor]: QA 반영 모집중인 모임방 버튼 비활성화 및 토스트 메세지 표시 (#102)
rbqks529 Sep 3, 2025
ce65cad
[refactor]: QA 반영 프로필 이미지 테두리 적용 (#102)
rbqks529 Sep 3, 2025
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
24 changes: 22 additions & 2 deletions app/src/main/java/com/texthip/thip/MainScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.texthip.thip.ui.navigator.BottomNavigationBar
import com.texthip.thip.ui.navigator.MainNavHost
import com.texthip.thip.ui.navigator.extensions.isMainTabRoute
import com.texthip.thip.ui.navigator.routes.MainTabRoutes

@Composable
fun MainScreen(
Expand All @@ -20,19 +24,35 @@ fun MainScreen(
val navController = rememberNavController()
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
var feedReselectionTrigger by remember { mutableStateOf(0) }

val showBottomBar = currentDestination?.isMainTabRoute() ?: true

Scaffold(
bottomBar = {
if (showBottomBar) BottomNavigationBar(navController)
if (showBottomBar) {
BottomNavigationBar(
navController = navController,
onTabReselected = { route ->
when (route) {
MainTabRoutes.Feed -> {
feedReselectionTrigger += 1
}
else -> {
// 다른 탭들은 향후 확장 가능
}
}
}
)
}
},
containerColor = Color.Transparent
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
MainNavHost(
navController = navController,
onNavigateToLogin = onNavigateToLogin
onNavigateToLogin = onNavigateToLogin,
onFeedTabReselected = feedReselectionTrigger
)
}
}
Expand Down
10 changes: 9 additions & 1 deletion app/src/main/java/com/texthip/thip/data/di/NetworkModule.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.texthip.thip.data.di

import android.content.Context
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import com.texthip.thip.BuildConfig
import com.texthip.thip.data.service.AuthService
import com.texthip.thip.utils.auth.AuthInterceptor
import com.texthip.thip.utils.image.ImageUploadHelper
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
Expand Down Expand Up @@ -68,4 +70,10 @@ object NetworkModule {
)
.client(okHttpClient)
.build()

@Provides
@Singleton
fun provideImageUploadHelper(
@ApplicationContext context: Context
): ImageUploadHelper = ImageUploadHelper(context)
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import kotlinx.serialization.Serializable

@Serializable
data class BookListResponse(
@SerialName("bookList") val bookList: List<BookSavedResponse> = emptyList()
@SerialName("bookList") val bookList: List<BookSavedResponse> = emptyList(),
@SerialName("nextCursor") val nextCursor: String? = null,
@SerialName("isLast") val isLast: Boolean = false
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.texthip.thip.data.model.book.response

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class BookUserSaveResponse(
val bookList: List<BookUserSaveList>
val bookList: List<BookUserSaveList>,
@SerialName("nextCursor") val nextCursor: String? = null,
@SerialName("isLast") val isLast: Boolean = false
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ data class CreateFeedRequest(
@SerialName("isPublic")
val isPublic: Boolean,
@SerialName("tagList")
val tagList: List<String> = emptyList()
val tagList: List<String> = emptyList(),
@SerialName("imageUrls")
val imageUrls: List<String> = emptyList()
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.texthip.thip.data.model.feed.request

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class ImageMetadata(
@SerialName("extension")
val extension: String,
@SerialName("size")
val size: Long
)

typealias PresignedUrlRequest = List<ImageMetadata>
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.texthip.thip.data.model.feed.response

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

@Serializable
data class PresignedUrlInfo(
@SerialName("presignedUrl")
val presignedUrl: String,
@SerialName("fileUrl")
val fileUrl: String
)

@Serializable
data class PresignedUrlResponse(
@SerialName("presignedUrls")
val presignedUrls: List<PresignedUrlInfo>
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import kotlinx.serialization.Serializable
data class JoinedRoomListResponse(
@SerialName("roomList") val roomList: List<JoinedRoomResponse>,
@SerialName("nickname") val nickname: String,
@SerialName("page") val page: Int,
@SerialName("size") val size: Int,
@SerialName("last") val last: Boolean,
@SerialName("first") val first: Boolean
@SerialName("nextCursor") val nextCursor: String? = null,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

커서 기반 필드 도입 OK, 사용처 점검 필요

nextCursor 추가는 적절합니다. 다만 last/first와 동시 노출 시 어떤 값을 진리 소스로 삼을지 명확히 하세요(권장: 한쪽만 UI 판단 근거).

서비스/레포지토리 시그니처가 커서 파라미터를 수용하는지 점검:


🏁 Script executed:

#!/bin/bash
rg -nP '\bgetJoinedRooms\s*\(' -C3
rg -nP 'RoomsService|RoomsRepository' -C3 | rg -nP '\bcursor\b' -n

Length of output: 1528


커서 기반 페이징 UI 진리 소스 결정 필요

  • nextCursor 추가 및 서비스( getJoinedRooms )·레포지토리( getMyJoinedRooms )의 cursor 파라미터 수용은 이미 완료되어 있습니다.
  • lastCursor/firstCursor와 함께 노출될 경우 UI에서 사용할 진리 소스를 하나로 명확히 설정(권장: 하나만 사용)해주세요.
🤖 Prompt for AI Agents
In
app/src/main/java/com/texthip/thip/data/model/rooms/response/JoinedRoomListResponse.kt
around line 11, the model currently exposes nextCursor while the API/service
also supports lastCursor/firstCursor, causing ambiguity for the UI; pick a
single canonical cursor field (recommended: keep only nextCursor) and update the
response model to expose only that field (or mark others deprecated and remove
their serialization), then ensure getJoinedRooms/getMyJoinedRooms and any DTOs,
serializers, and UI consumers are aligned to use the chosen cursor name
consistently and update any docs/tests accordingly.

@SerialName("isLast") val isLast: Boolean
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ class BookRepository @Inject constructor(
) {

/** 저장된 책 또는 모임 책 목록 조회 */
suspend fun getBooks(type: String): Result<BookListResponse?> = runCatching {
bookService.getBooks(type)
suspend fun getBooks(type: String, cursor: String? = null): Result<BookListResponse?> = runCatching {
bookService.getBooks(type, cursor)
.handleBaseResponse()
.getOrThrow()
}
Expand Down Expand Up @@ -67,8 +67,8 @@ class BookRepository @Inject constructor(
.getOrThrow()
}

suspend fun getSavedBooks(): Result<BookUserSaveResponse?> = runCatching {
bookService.getSavedBooks()
suspend fun getSavedBooks(cursor: String? = null): Result<BookUserSaveResponse?> = runCatching {
bookService.getSavedBooks(cursor)
.handleBaseResponse()
.getOrThrow()
}
Expand Down
132 changes: 50 additions & 82 deletions app/src/main/java/com/texthip/thip/data/repository/FeedRepository.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package com.texthip.thip.data.repository

import android.content.Context
import android.net.Uri
import com.texthip.thip.data.model.base.handleBaseResponse
import com.texthip.thip.data.model.feed.request.CreateFeedRequest
import com.texthip.thip.data.model.feed.request.FeedLikeRequest
import com.texthip.thip.data.model.feed.request.FeedSaveRequest
import com.texthip.thip.data.model.feed.request.UpdateFeedRequest
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import com.texthip.thip.data.model.feed.response.AllFeedResponse
import com.texthip.thip.data.model.feed.response.CreateFeedResponse
import com.texthip.thip.data.model.feed.response.FeedDetailResponse
Expand All @@ -18,27 +21,17 @@ import com.texthip.thip.data.model.feed.response.MyFeedResponse
import com.texthip.thip.data.model.feed.response.RelatedBooksResponse
import com.texthip.thip.data.service.FeedService
import com.texthip.thip.ui.feed.mock.FeedStateUpdateResult
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import com.texthip.thip.utils.image.ImageUploadHelper
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File
import java.io.FileOutputStream
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class FeedRepository @Inject constructor(
private val feedService: FeedService,
@param:ApplicationContext private val context: Context,
private val json: Json
private val imageUploadHelper: ImageUploadHelper
) {
private val _feedStateUpdateResult = MutableSharedFlow<FeedStateUpdateResult>()
val feedStateUpdateResult: Flow<FeedStateUpdateResult> = _feedStateUpdateResult.asSharedFlow()
Expand Down Expand Up @@ -72,80 +65,67 @@ class FeedRepository @Inject constructor(
tagList: List<String>,
imageUris: List<Uri>
): Result<CreateFeedResponse?> = runCatching {
val imageUrls = if (imageUris.isNotEmpty()) {
uploadImagesToS3(imageUris)
} else {
emptyList()
}

val request = CreateFeedRequest(
isbn = isbn,
contentBody = contentBody,
isPublic = isPublic,
tagList = tagList
tagList = tagList,
imageUrls = imageUrls
)

// JSON 요청 부분을 RequestBody로 변환
val requestJson = json.encodeToString(CreateFeedRequest.serializer(), request)
val requestBody = requestJson.toRequestBody("application/json".toMediaType())

// 임시 파일 목록 추적
val tempFiles = mutableListOf<File>()
feedService.createFeed(request)
.handleBaseResponse()
.getOrThrow()
}

// 이미지 파일들을 MultipartBody.Part로 변환
val imageParts = if (imageUris.isNotEmpty()) {
withContext(Dispatchers.IO) {
imageUris.mapNotNull { uri ->
runCatching {
uriToMultipartBodyPart(uri, "images", tempFiles)
}.getOrNull()
/** 이미지들을 S3에 업로드하고 CloudFront URL 목록 반환 */
private suspend fun uploadImagesToS3(imageUris: List<Uri>): List<String> = withContext(Dispatchers.IO) {
val validImagePairs = imageUris.map { uri ->
async {
imageUploadHelper.getImageMetadata(uri)?.let { metadata ->
uri to metadata
}
}
} else {
null
}
}.awaitAll().filterNotNull()

try {
feedService.createFeed(requestBody, imageParts)
.handleBaseResponse()
.getOrThrow()
} finally {
// 임시 파일들 정리
cleanupTempFiles(tempFiles)
}
}
if (validImagePairs.isEmpty()) return@withContext emptyList()

private fun uriToMultipartBodyPart(
uri: Uri,
paramName: String,
tempFiles: MutableList<File>
): MultipartBody.Part? {
return runCatching {
// MIME 타입 확인
val mimeType = context.contentResolver.getType(uri) ?: "image/jpeg"
val extension = when (mimeType) {
"image/png" -> "png"
"image/gif" -> "gif"
"image/jpeg", "image/jpg" -> "jpg"
else -> "jpg" // 기본값
}
val presignedUrlRequest = validImagePairs.map { it.second }

val presignedResponse = feedService.getPresignedUrls(presignedUrlRequest)
.handleBaseResponse()
.getOrThrow() ?: throw Exception("Failed to get presigned URLs")

// 파일명 생성
val fileName = "feed_image_${System.currentTimeMillis()}.$extension"
val tempFile = File(context.cacheDir, fileName)
// 개수 검증
if (validImagePairs.size != presignedResponse.presignedUrls.size) {
throw Exception("Presigned URL count mismatch: expected ${validImagePairs.size}, got ${presignedResponse.presignedUrls.size}")
}

// 임시 파일 목록에 추가
tempFiles.add(tempFile)
val uploadedImageUrls = mutableListOf<String>()

// InputStream을 use 블록으로 안전하게 관리
context.contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
} ?: throw IllegalStateException("Failed to open input stream for URI: $uri")
validImagePairs.forEachIndexed { index, (uri, _) ->
val presignedInfo = presignedResponse.presignedUrls[index]

// MultipartBody.Part 생성
val requestFile = tempFile.asRequestBody(mimeType.toMediaType())
MultipartBody.Part.createFormData(paramName, fileName, requestFile)
}.onFailure { e ->
e.printStackTrace()
}.getOrNull()
imageUploadHelper.uploadImageToS3(
uri = uri,
presignedUrl = presignedInfo.presignedUrl
).onSuccess {
uploadedImageUrls.add(presignedInfo.fileUrl)
}.onFailure { exception ->
throw Exception("Failed to upload image ${index + 1}: ${exception.message}")
}
}

uploadedImageUrls
}


/** 전체 피드 목록 조회 */
suspend fun getAllFeeds(cursor: String? = null): Result<AllFeedResponse?> = runCatching {
feedService.getAllFeeds(cursor)
Expand Down Expand Up @@ -205,18 +185,6 @@ class FeedRepository @Inject constructor(
.getOrThrow()
}

/** 임시 파일들을 정리하는 함수 */
private fun cleanupTempFiles(tempFiles: List<File>) {
tempFiles.forEach { file ->
runCatching {
if (file.exists()) {
file.delete()
}
}.onFailure { e ->
e.printStackTrace()
}
}
}

suspend fun getFeedUsersInfo(userId: Long) = runCatching {
feedService.getFeedUsersInfo(userId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ class RoomsRepository @Inject constructor(
}

/** 내가 참여 중인 모임방 목록 조회 */
suspend fun getMyJoinedRooms(page: Int): Result<JoinedRoomListResponse?> = runCatching {
val response = roomsService.getJoinedRooms(page)
suspend fun getMyJoinedRooms(cursor: String? = null): Result<JoinedRoomListResponse?> = runCatching {
val response = roomsService.getJoinedRooms(cursor)
.handleBaseResponse()
.getOrThrow()

Expand Down
Loading