Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.flint.core.designsystem.component.toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
Expand Down Expand Up @@ -36,7 +37,9 @@ fun ShowToast(
}

Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.imePadding(),
contentAlignment = Alignment.BottomCenter,
) {
FlintToast(
Expand Down
4 changes: 1 addition & 3 deletions app/src/main/java/com/flint/core/navigation/Route.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ interface Route {
data object Login : Route

@Serializable
data class OnboardingProfile(
val tempToken: String
) : Route
data object OnboardingProfile : Route

@Serializable
data class OnboardingGraph(
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/flint/data/api/AuthApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@ interface AuthApi {
@Body requestDto: SocialVerifyRequestDto,
): BaseResponse<SocialVerifyResponseDto>

@DELETE("/api/v1/auth/withdraw")
@POST("/api/v1/auth/withdraw")
suspend fun withdraw(): WithdrawResponseDto
}
5 changes: 3 additions & 2 deletions app/src/main/java/com/flint/data/api/SearchApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ interface SearchApi {
@GET("/api/v1/contents/search")
suspend fun getSearchContentList(
@Query("keyword") keyword: String? = null,
@Query("genre") genre: String? = null,
@Query("cursor") cursor: Int = 1,
@Query("genre") genre: List<String>? = null,
@Query("mediaType") mediaType: String? = null,
@Query("cursor") cursor: String? = null,
@Query("size") size: Int = 20,
): BaseResponse<SearchContentsResponseDto>
}
3 changes: 1 addition & 2 deletions app/src/main/java/com/flint/data/api/StorageApi.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.flint.data.api

import com.flint.data.dto.base.BaseResponse
import com.flint.data.dto.storage.response.PresignedUrlResponseDto
import retrofit2.http.GET
import retrofit2.http.Query
Expand All @@ -11,5 +10,5 @@ interface StorageApi {
suspend fun getPresignedUrl(
@Query("pathType") pathType: String,
@Query("extension") extension: String,
): BaseResponse<PresignedUrlResponseDto>
): PresignedUrlResponseDto
}
14 changes: 14 additions & 0 deletions app/src/main/java/com/flint/data/di/NetworkModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.flint.data.di
import com.flint.BuildConfig
import com.flint.data.di.interceptor.NetworkErrorInterceptor
import com.flint.data.di.interceptor.TokenInterceptor
import com.flint.data.di.qualifier.S3OkHttpClient
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
import dagger.Provides
Expand Down Expand Up @@ -59,6 +60,19 @@ object NetworkModule {
addInterceptor(loggingInterceptor)
}.build()

@Provides
@Singleton
@S3OkHttpClient
fun provideS3OkHttpClient(loggingInterceptor: HttpLoggingInterceptor): OkHttpClient =
OkHttpClient
.Builder()
.apply {
connectTimeout(20, TimeUnit.SECONDS)
writeTimeout(60, TimeUnit.SECONDS)
readTimeout(20, TimeUnit.SECONDS)
addInterceptor(loggingInterceptor)
}.build()

@Provides
@Singleton
fun provideRetrofit(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ class TokenInterceptor

val requestBuilder = originalRequest.newBuilder()

if (accessToken.isNotEmpty()) {
val isSearchRequest = originalRequest.url.encodedPath.endsWith("/contents/search")
if (accessToken.isNotEmpty() && !isSearchRequest) {
requestBuilder.header("Authorization", "Bearer $accessToken")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.flint.data.di.qualifier

import javax.inject.Qualifier

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class S3OkHttpClient
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ data class SignupRequestDto(
val favoriteContentIds: List<String>,
@SerialName("agreedTermsIds")
val agreedTermsIds: List<String>,
@SerialName("profileImageUrl")
val profileImageUrl: String? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,13 @@ data class SearchContentsResponseDto(
val returned: Int? = null,
@SerialName("nextCursor")
val nextCursor: String? = null,
@SerialName("page")
val page: Int? = null,
@SerialName("size")
val size: Int? = null,
@SerialName("totalElements")
val totalElements: String? = null,
Comment on lines +39 to +40

@coderabbitai coderabbitai Bot Jun 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does kotlinx.serialization Json decode a JSON number into a Kotlin String property or throw by default?

💡 Result:

By default, kotlinx.serialization’s Json decoder expects a JSON string token (quoted) for properties of Kotlin type String; if the input provides a JSON number instead, decoding fails (throws a SerializationException/JsonDecodingException), rather than silently converting the number to a String. Evidence: - The Json configuration option that would allow coercing certain wrong types is coerceInputValues, and it is false by default. The docs state it only coerces specific cases (e.g., JSON null into a non-nullable property, unknown enum members) and do not mention number-to-string coercion [1]. - Json decoding is token/type-driven: the core Decoder API exposes decodeString() as “Decodes a string value. Corresponding kind is PrimitiveKind.STRING”, i.e., the deserializer expects the underlying input element to be a string kind [2]. - The kotlinx.serialization issue discussions show that when a decoder calls decodeString() but the underlying JSON token is numeric, decodeString() throws an exception (example shows “Expected string literal with quotes” after decoding a numeric token) [3]. So the answer to your question is: it does not decode a JSON number into a Kotlin String property by default; it throws by default.

Citations:


totalElements 타입을 Int?로 통일하세요

  • Meta에서 page/size/totalPagesInt?인데 totalElementsString?입니다.
  • kotlinx.serialization Json은 기본적으로 String 프로퍼티에 대해 JSON number를 문자열로 자동 변환하지 않으며, 타입이 맞지 않으면 디코딩 예외가 발생합니다(백엔드가 숫자로 내리는지/문자열로 내리는지와 현재 Json 설정을 확인 필요).
🐛 제안 수정
         `@SerialName`("totalElements")
-        val totalElements: String? = null,
+        val totalElements: Int? = null,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@SerialName("totalElements")
val totalElements: String? = null,
`@SerialName`("totalElements")
val totalElements: Int? = null,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/flint/data/dto/search/SearchContentsResponseDto.kt`
around lines 39 - 40, The property totalElements in SearchContentsResponseDto
(inside the Meta/data class) is declared as String? while page/size/totalPages
are Int?; change totalElements to Int? to match types and avoid Json decoding
errors, and if the backend may emit numbers as strings ensure the serializer
configuration or a custom `@Serializable` converter is used for the totalElements
property to accept numeric strings (update the Meta class property named
totalElements accordingly).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

❌ 반영하지 않았습니다. Swagger 스펙에서 "totalElements": "123" 처럼 따옴표가 있는 문자열로 응답이 정의되어 있어 현재 String?이 올바른 타입입니다. 실제 서버 응답도 동일하게 문자열로 내려오는 것을 확인했습니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@kimjw2003, 확인해 주셔서 감사합니다! Swagger 스펙과 실제 서버 응답이 모두 문자열로 내려온다면 String?이 올바른 타입이 맞습니다. 제 제안이 잘못되었습니다. 향후 리뷰에 반영해 두겠습니다.

(ᓀ‸ᓂ)


✏️ Learnings added
Learnt from: kimjw2003
Repo: imflint/Flint-Android PR: 207
File: app/src/main/java/com/flint/data/dto/search/SearchContentsResponseDto.kt:39-40
Timestamp: 2026-06-02T12:11:59.612Z
Learning: In `app/src/main/java/com/flint/data/dto/search/SearchContentsResponseDto.kt`, the `totalElements` field inside the `Meta` data class is intentionally declared as `String?` (not `Int?`) because the backend Swagger spec defines it as a quoted string (e.g., `"totalElements": "123"`) and the actual server response returns it as a string value, not a JSON number.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: nahy-512
Repo: imflint/Flint-Android PR: 135
File: app/src/main/java/com/flint/domain/repository/AuthRepository.kt:1-22
Timestamp: 2026-01-19T00:48:44.889Z
Learning: Architect the Android app following Google's recommended Android App Architecture for this repo: place the data layer innermost, the domain layer can depend on the data layer, and avoid circular dependencies with other layers. For Kotlin files under app/src/main/java, ensure accompanying architecture aligns with this guidance (e.g., domain interfaces depending on data layer implementations or abstractions, not the other way around).

Learnt from: giovannijunseokim
Repo: imflint/Flint-Android PR: 153
File: app/src/main/java/com/flint/presentation/collectiondetail/CollectionDetailViewModel.kt:98-105
Timestamp: 2026-01-21T08:37:42.767Z
Learning: Guideline: For APIs like toggleContentBookmark that do not return a bookmarkCount, perform optimistic updates by updating the isBookmarked state on the client and compute bookmarkCount locally if needed. Do not rely on the server for the count; ensure the server response only conveys the boolean bookmarked state and synchronize this state accordingly. This applies to Kotlin Android ViewModels and related UI state management across files that handle similar bookmark toggle endpoints.

@SerialName("totalPages")
val totalPages: Int? = null,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ fun SignupRequestModel.toDto(): SignupRequestDto =
nickname = nickname,
favoriteContentIds = favoriteContentIds,
agreedTermsIds = agreedTermsIds,
profileImageUrl = profileImageUrl,
)

fun SignupResponseDto.toModel(): SignupResponseModel =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ data class SignupRequestModel(
val nickname: String,
val favoriteContentIds: List<String>,
val agreedTermsIds: List<String>,
val profileImageUrl: String? = null,
)

data class SignupResponseModel(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ class SearchRepository @Inject constructor(

suspend fun getSearchContentList(
keyword: String? = null,
genre: String? = null,
cursor: Int = 1,
genre: List<String>? = null,
mediaType: String? = null,
cursor: String? = null,
size: Int = 20,
): Result<SearchContentListModel> =
suspendRunCatching {
apiService.getSearchContentList(
keyword = keyword,
genre = genre,
mediaType = mediaType,
cursor = cursor,
size = size,
).data.toModel()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,23 @@ package com.flint.domain.repository

import com.flint.core.common.util.suspendRunCatching
import com.flint.data.api.StorageApi
import com.flint.data.di.qualifier.S3OkHttpClient
import com.flint.domain.mapper.storage.toModel
import com.flint.domain.model.storage.PresignedUrlModel
import com.flint.domain.type.FileExtension
import com.flint.domain.type.StoragePathType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import javax.inject.Inject

class StorageRepository @Inject constructor(
private val api: StorageApi,
@S3OkHttpClient private val s3Client: OkHttpClient,
) {
// Presigned URL 발급
suspend fun getPresignedUrl(
pathType: StoragePathType,
extension: FileExtension,
Expand All @@ -20,6 +27,23 @@ class StorageRepository @Inject constructor(
api.getPresignedUrl(
pathType = pathType.name,
extension = extension.name,
).data.toModel()
).toModel()
}

suspend fun uploadToS3(
uploadUrl: String,
imageBytes: ByteArray,
mimeType: String,
): Result<Unit> = suspendRunCatching {
withContext(Dispatchers.IO) {
val requestBody = imageBytes.toRequestBody(mimeType.toMediaTypeOrNull())
val request = Request.Builder()
.url(uploadUrl)
.put(requestBody)
.build()
s3Client.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("S3 upload failed: ${response.code}")
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
Expand Down Expand Up @@ -65,14 +68,18 @@ fun OnboardingProfileRoute(
isFormatValid = uiState.isFormatValid,
isNicknameAvailable = uiState.isNicknameAvailable,
canProceed = uiState.canProceed,
canCheckNickname = uiState.canCheckNickname,
hasError = uiState.hasError,
errorMessage = uiState.errorMessage,
profileImageUri = uiState.profileImageUri,
onNicknameChange = viewModel::updateNickname,
onCheckNickname = viewModel::checkNicknameDuplication,
onClearError = viewModel::clearNicknameError,
onProfileImageSelected = viewModel::updateProfileImage,
onBackClick = navigateUp,
onNextClick = navigateToOnboardingContent,
modifier = Modifier.padding(paddingValues),
modifier = Modifier
.padding(paddingValues)
.consumeWindowInsets(paddingValues),
)
}

Expand All @@ -84,11 +91,13 @@ fun OnboardingProfileScreen(
isFormatValid: Boolean,
isNicknameAvailable: Boolean?,
canProceed: Boolean,
canCheckNickname: Boolean,
hasError: Boolean,
errorMessage: String?,
profileImageUri: Uri?,
onNicknameChange: (String) -> Unit,
onCheckNickname: () -> Unit,
onClearError: () -> Unit,
onProfileImageSelected: (Uri?) -> Unit,
onBackClick: () -> Unit,
onNextClick: () -> Unit,
modifier: Modifier = Modifier,
Expand All @@ -98,12 +107,11 @@ fun OnboardingProfileScreen(
var toastMessage by remember { mutableStateOf("") }
var isToastSuccess by remember { mutableStateOf(false) }
var showProfileBottomSheet by remember { mutableStateOf(false) }
var selectedImageUri by remember { mutableStateOf<Uri?>(null) }

val galleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) { uri ->
if (uri != null) selectedImageUri = uri
if (uri != null) onProfileImageSelected(uri)
}

LaunchedEffect(hasError, errorMessage) {
Expand All @@ -127,6 +135,7 @@ fun OnboardingProfileScreen(
modifier = Modifier
.fillMaxSize()
.background(color = FlintTheme.colors.background)
.imePadding()
) {
FlintBackTopAppbar(
onClick = onBackClick,
Expand All @@ -140,7 +149,7 @@ fun OnboardingProfileScreen(
) {

EditProfileImage(
imageUrl = selectedImageUri?.toString() ?: "",
imageUrl = profileImageUri?.toString() ?: "",
onEditClick = { showProfileBottomSheet = true }
)

Expand Down Expand Up @@ -191,10 +200,10 @@ fun OnboardingProfileScreen(
.fillMaxHeight()
.clip(RoundedCornerShape(8.dp))
.background(
if (isValid && isFormatValid) FlintTheme.colors.primary400
if (canCheckNickname) FlintTheme.colors.primary400
else FlintTheme.colors.gray700
)
.clickable(enabled = isValid && isFormatValid) {
.clickable(enabled = canCheckNickname) {
keyboardController?.hide()
onCheckNickname()
}
Expand All @@ -203,8 +212,8 @@ fun OnboardingProfileScreen(
) {
Text(
text = "확인",
color = if (isValid && isFormatValid) FlintTheme.colors.white else FlintTheme.colors.gray400,
style = if (isValid && isFormatValid) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16,
color = if (canCheckNickname) FlintTheme.colors.white else FlintTheme.colors.gray400,
style = if (canCheckNickname) FlintTheme.typography.body1Sb16 else FlintTheme.typography.body1M16,
)
}
}
Expand Down Expand Up @@ -235,7 +244,7 @@ fun OnboardingProfileScreen(
MenuBottomSheetData(
label = "프로필 사진 삭제",
color = FlintTheme.colors.error500,
clickAction = { selectedImageUri = null }
clickAction = { onProfileImageSelected(null) }
),
),
onDismiss = { showProfileBottomSheet = false }
Expand All @@ -250,12 +259,7 @@ fun OnboardingProfileScreen(
),
paddingValues = PaddingValues.Zero,
yOffset = 100.dp,
hide = {
showToast = false
if (!isToastSuccess) {
onClearError()
}
},
hide = { showToast = false },
)
}
}
Expand All @@ -271,11 +275,13 @@ private fun OnboardingProfileScreenPreview() {
isFormatValid = true,
isNicknameAvailable = true,
canProceed = true,
canCheckNickname = true,
hasError = false,
errorMessage = null,
profileImageUri = null,
onNicknameChange = {},
onCheckNickname = {},
onClearError = {},
onProfileImageSelected = {},
onBackClick = {},
onNextClick = {},
)
Expand All @@ -292,11 +298,13 @@ private fun OnboardingProfileScreenDuplicateErrorPreview() {
isFormatValid = true,
isNicknameAvailable = false,
canProceed = false,
canCheckNickname = true,
hasError = true,
errorMessage = "이미 사용 중인 닉네임입니다",
profileImageUri = null,
onNicknameChange = {},
onCheckNickname = {},
onClearError = {},
onProfileImageSelected = {},
onBackClick = {},
onNextClick = {},
)
Expand All @@ -315,11 +323,13 @@ private fun OnboardingProfileScreenFormatErrorPreview() {
isFormatValid = false,
isNicknameAvailable = null,
canProceed = false,
canCheckNickname = false,
hasError = true,
errorMessage = "사용할 수 없는 닉네임입니다",
profileImageUri = null,
onNicknameChange = { text = it },
onCheckNickname = {},
onClearError = {},
onProfileImageSelected = {},
onBackClick = {},
onNextClick = {},
)
Expand Down
Loading
Loading