diff --git a/.gitignore b/.gitignore index 797b58f9..e1b27a2c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ google-services.json nextroom_key +# Claude Code files +.claude/ diff --git a/data/src/main/java/com/nextroom/nextroom/data/datasource/HintRemoteDataSource.kt b/data/src/main/java/com/nextroom/nextroom/data/datasource/HintRemoteDataSource.kt index 88ab3ec5..0eb30a3d 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/datasource/HintRemoteDataSource.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/datasource/HintRemoteDataSource.kt @@ -1,10 +1,15 @@ package com.nextroom.nextroom.data.datasource import com.nextroom.nextroom.data.network.ApiService +import com.nextroom.nextroom.data.network.request.AddHintRequestDto +import com.nextroom.nextroom.data.network.request.EditHintRequestDto +import com.nextroom.nextroom.data.network.request.RemoveHintRequestDto import com.nextroom.nextroom.data.network.response.toDomain import com.nextroom.nextroom.domain.model.Hint import com.nextroom.nextroom.domain.model.Result import com.nextroom.nextroom.domain.model.mapOnSuccess +import com.nextroom.nextroom.domain.request.AddHintRequest +import com.nextroom.nextroom.domain.request.EditHintRequest import javax.inject.Inject class HintRemoteDataSource @Inject constructor( @@ -14,4 +19,46 @@ class HintRemoteDataSource @Inject constructor( return apiService.getHint(themeId) .mapOnSuccess { it.data.toDomain() } } + + suspend fun addHint(request: AddHintRequest): Result { + return apiService.addHint( + AddHintRequestDto( + themeId = request.themeId, + hintCode = request.hintCode, + contents = request.contents, + answer = request.answer, + progress = request.progress, + hintImageList = request.hintImageUrlList.map { extractImageKey(it) }, + answerImageList = request.answerImageUrlList.map { extractImageKey(it) }, + ) + ) + } + + suspend fun editHint(request: EditHintRequest): Result { + return apiService.editHint( + EditHintRequestDto( + id = request.id, + hintCode = request.hintCode, + contents = request.contents, + answer = request.answer, + progress = request.progress, + hintImageList = request.hintImageUrlList.map { extractImageKey(it) }, + answerImageList = request.answerImageUrlList.map { extractImageKey(it) }, + ) + ) + } + + // Pre-signed URL에서 파일 key(UUID, 확장자 제외)만 추출 + // 서버는 항상 UUID만 받아야 하는데, 기존 이미지의 경우 pre-signed URL이 그대로 전달될 수 있음 + // 이미지 리스트는 확장자를 제외한 순수 파일 이름 필요 + private fun extractImageKey(urlOrKey: String): String { + if (!urlOrKey.startsWith("http")) return urlOrKey + val pathWithoutQuery = urlOrKey.split("?").first() + val fileName = pathWithoutQuery.substringAfterLast("/") + return fileName.substringBeforeLast(".") + } + + suspend fun deleteHint(hintId: Int): Result { + return apiService.deleteHint(RemoveHintRequestDto(id = hintId)) + } } diff --git a/data/src/main/java/com/nextroom/nextroom/data/datasource/ImageUploadDataSource.kt b/data/src/main/java/com/nextroom/nextroom/data/datasource/ImageUploadDataSource.kt new file mode 100644 index 00000000..1b4f5b3c --- /dev/null +++ b/data/src/main/java/com/nextroom/nextroom/data/datasource/ImageUploadDataSource.kt @@ -0,0 +1,112 @@ +package com.nextroom.nextroom.data.datasource + +import com.nextroom.nextroom.data.network.ApiService +import com.nextroom.nextroom.data.network.ImageUploadService +import com.nextroom.nextroom.domain.model.Result +import com.nextroom.nextroom.domain.model.mapOnSuccess +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import java.io.File +import javax.inject.Inject + +data class UploadImagesResult( + val hintImageFileNames: List, + val answerImageFileNames: List, + val failedHintImageIndices: Set, + val failedAnswerImageIndices: Set, +) + +class ImageUploadDataSource @Inject constructor( + private val apiService: ApiService, + private val imageUploadService: ImageUploadService, +) { + suspend fun uploadImages( + themeId: Int, + hintImageFiles: List, + answerImageFiles: List, + ): Result { + val presignedResult = apiService.getHintImagePresignedUrls( + themeId = themeId, + hintImageCount = hintImageFiles.size, + answerImageCount = answerImageFiles.size + ).mapOnSuccess { it.data } + + return when (presignedResult) { + is Result.Success -> { + val presignedData = presignedResult.data + try { + coroutineScope { + val hintDeferred = hintImageFiles.mapIndexed { index, file -> + async { + val url = presignedData.hintImageUrlList?.getOrNull(index) + ?: return@async null + val success = imageUploadService.uploadImage( + presignedUrl = url, + imageFile = file, + contentType = extractContentType(url), + ) + if (success) extractFileNameWithoutExtension(url) else null + } + } + val answerDeferred = answerImageFiles.mapIndexed { index, file -> + async { + val url = presignedData.answerImageUrlList?.getOrNull(index) + ?: return@async null + val success = imageUploadService.uploadImage( + presignedUrl = url, + imageFile = file, + contentType = extractContentType(url), + ) + if (success) extractFileNameWithoutExtension(url) else null + } + } + + val hintResults = hintDeferred.awaitAll() + val answerResults = answerDeferred.awaitAll() + + Result.Success( + UploadImagesResult( + hintImageFileNames = hintResults.filterNotNull(), + answerImageFileNames = answerResults.filterNotNull(), + failedHintImageIndices = hintResults.indices.filter { hintResults[it] == null } + .toSet(), + failedAnswerImageIndices = answerResults.indices.filter { answerResults[it] == null } + .toSet(), + ) + ) + } + } finally { + hintImageFiles.forEach { it.delete() } + answerImageFiles.forEach { it.delete() } + } + } + + is Result.Failure -> { + hintImageFiles.forEach { it.delete() } + answerImageFiles.forEach { it.delete() } + presignedResult + } + } + } + + private fun extractFileNameWithoutExtension(presignedUrl: String): String { + // Presigned URL format: /.../2e90295-2093i902-4909-a945.png?... + // Extract: 2e90295-2093i902-4909-a945 + val urlWithoutQuery = presignedUrl.split("?").first() + val fileName = urlWithoutQuery.substringAfterLast("/") + return fileName.substringBeforeLast(".") + } + + private fun extractContentType(presignedUrl: String): String { + // Extract file extension from presigned URL + val urlWithoutQuery = presignedUrl.split("?").first() + val extension = urlWithoutQuery.substringAfterLast(".", "") + return when (extension.lowercase()) { + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "webp" -> "image/webp" + else -> "image/jpeg" // default + } + } +} diff --git a/data/src/main/java/com/nextroom/nextroom/data/datasource/SubscriptionDataSource.kt b/data/src/main/java/com/nextroom/nextroom/data/datasource/SubscriptionDataSource.kt index cddeaa1f..f40b3070 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/datasource/SubscriptionDataSource.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/datasource/SubscriptionDataSource.kt @@ -12,6 +12,9 @@ import javax.inject.Inject class SubscriptionDataSource @Inject constructor( private val apiService: ApiService, ) { + /** + * [getUserSubscription] 함수가 구독 상태를 가져오는 용도로 쓰이고 있다. 역할이 중복됨. 불필요하면 추후 제거할 것 + */ suspend fun getUserSubscriptionStatus(): Result { return apiService.getUserSubscriptionStatus().mapOnSuccess { it.data.toDomain() diff --git a/data/src/main/java/com/nextroom/nextroom/data/datasource/ThemeRemoteDataSource.kt b/data/src/main/java/com/nextroom/nextroom/data/datasource/ThemeRemoteDataSource.kt index 847b03d6..cec871a7 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/datasource/ThemeRemoteDataSource.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/datasource/ThemeRemoteDataSource.kt @@ -2,10 +2,15 @@ package com.nextroom.nextroom.data.datasource import com.nextroom.nextroom.data.model.ThemeBackgroundActivationId import com.nextroom.nextroom.data.network.ApiService +import com.nextroom.nextroom.data.network.request.AddThemeRequestDto +import com.nextroom.nextroom.data.network.request.EditThemeRequestDto +import com.nextroom.nextroom.data.network.request.RemoveThemeRequestDto import com.nextroom.nextroom.data.network.response.toDomain import com.nextroom.nextroom.domain.model.Result import com.nextroom.nextroom.domain.model.ThemeInfo import com.nextroom.nextroom.domain.model.mapOnSuccess +import com.nextroom.nextroom.domain.request.AddThemeRequest +import com.nextroom.nextroom.domain.request.EditThemeRequest import javax.inject.Inject class ThemeRemoteDataSource @Inject constructor( @@ -20,4 +25,29 @@ class ThemeRemoteDataSource @Inject constructor( suspend fun putActiveThemeBackgroundImage(themeBackgroundActivationId: ThemeBackgroundActivationId): Result { return apiService.putActiveThemeBackgroundImage(themeBackgroundActivationId) } + + suspend fun addTheme(request: AddThemeRequest): Result { + return apiService.addTheme( + AddThemeRequestDto( + title = request.title, + timeLimit = request.timeLimit, + hintLimit = request.hintLimit, + ) + ) + } + + suspend fun editTheme(request: EditThemeRequest): Result { + return apiService.editTheme( + EditThemeRequestDto( + id = request.id, + title = request.title, + timeLimit = request.timeLimit, + hintLimit = request.hintLimit, + ) + ) + } + + suspend fun deleteTheme(themeId: Int): Result { + return apiService.deleteTheme(RemoveThemeRequestDto(id = themeId)) + } } diff --git a/data/src/main/java/com/nextroom/nextroom/data/di/NetworkModule.kt b/data/src/main/java/com/nextroom/nextroom/data/di/NetworkModule.kt index 192c2b63..d9fdfe04 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/di/NetworkModule.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/di/NetworkModule.kt @@ -8,6 +8,7 @@ import com.nextroom.nextroom.data.datasource.TokenDataSource import com.nextroom.nextroom.data.network.ApiService import com.nextroom.nextroom.data.network.AuthAuthenticator import com.nextroom.nextroom.data.network.AuthInterceptor +import com.nextroom.nextroom.data.network.ImageUploadService import com.nextroom.nextroom.data.network.ResultCallAdapterFactory import dagger.Module import dagger.Provides @@ -157,4 +158,12 @@ object NetworkModule { ): FlavorExtraFunction { return FlavorExtraFunction(context) } + + @Singleton + @Provides + fun provideImageUploadService( + @Named("defaultOkHttpClient") okHttpClient: OkHttpClient, + ): ImageUploadService { + return ImageUploadService(okHttpClient) + } } diff --git a/data/src/main/java/com/nextroom/nextroom/data/di/RepositoryModule.kt b/data/src/main/java/com/nextroom/nextroom/data/di/RepositoryModule.kt index ef9a536e..42c8fa62 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/di/RepositoryModule.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/di/RepositoryModule.kt @@ -9,6 +9,7 @@ import com.nextroom.nextroom.data.datasource.BillingDataSource import com.nextroom.nextroom.data.datasource.FirebaseRemoteConfigDataSource import com.nextroom.nextroom.data.datasource.HintLocalDataSource import com.nextroom.nextroom.data.datasource.HintRemoteDataSource +import com.nextroom.nextroom.data.datasource.ImageUploadDataSource import com.nextroom.nextroom.data.datasource.SettingDataSource import com.nextroom.nextroom.data.datasource.StatisticsDataSource import com.nextroom.nextroom.data.datasource.SubscriptionDataSource @@ -68,12 +69,14 @@ object RepositoryModule { hintRemoteDataSource: HintRemoteDataSource, themeLocalDataSource: ThemeLocalDataSource, settingDataSource: SettingDataSource, + imageUploadDataSource: ImageUploadDataSource, ): HintRepository { return HintRepositoryImpl( hintLocalDataSource, hintRemoteDataSource, themeLocalDataSource, settingDataSource, + imageUploadDataSource, ) } diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/ApiService.kt b/data/src/main/java/com/nextroom/nextroom/data/network/ApiService.kt index 84546ba7..5b6ae369 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/network/ApiService.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/network/ApiService.kt @@ -2,8 +2,14 @@ package com.nextroom.nextroom.data.network import com.nextroom.nextroom.data.model.ThemeBackgroundActivationId import com.nextroom.nextroom.data.model.TokenDto +import com.nextroom.nextroom.data.network.request.AddHintRequestDto +import com.nextroom.nextroom.data.network.request.AddThemeRequestDto +import com.nextroom.nextroom.data.network.request.EditHintRequestDto +import com.nextroom.nextroom.data.network.request.EditThemeRequestDto import com.nextroom.nextroom.data.network.request.LoginRequest import com.nextroom.nextroom.data.network.request.PurchaseToken +import com.nextroom.nextroom.data.network.request.RemoveHintRequestDto +import com.nextroom.nextroom.data.network.request.RemoveThemeRequestDto import com.nextroom.nextroom.data.network.request.StatisticsRequest import com.nextroom.nextroom.data.network.response.AdditionalUserInfoRequestDto import com.nextroom.nextroom.data.network.response.AdditionalUserInfoResponseDto @@ -15,6 +21,7 @@ import com.nextroom.nextroom.data.network.response.GoogleLoginResponseDto import com.nextroom.nextroom.data.network.response.HintDto import com.nextroom.nextroom.data.network.response.LoginDto import com.nextroom.nextroom.data.network.response.MypageDto +import com.nextroom.nextroom.data.network.response.PresignedUrlResponseDto import com.nextroom.nextroom.data.network.response.SubscriptionPlanDto import com.nextroom.nextroom.data.network.response.ThemeDto import com.nextroom.nextroom.data.network.response.TicketDto @@ -24,6 +31,7 @@ import com.nextroom.nextroom.domain.request.TokenRefreshRequest import retrofit2.http.Body import retrofit2.http.DELETE import retrofit2.http.GET +import retrofit2.http.HTTP import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Query @@ -42,9 +50,30 @@ interface ApiService { @GET("api/v1/theme") suspend fun getThemes(): Result> + @POST("api/v1/theme") + suspend fun addTheme(@Body request: AddThemeRequestDto): Result + + @PUT("api/v1/theme") + suspend fun editTheme(@Body request: EditThemeRequestDto): Result + + @HTTP(method = "DELETE", path = "api/v1/theme", hasBody = true) + suspend fun deleteTheme(@Body request: RemoveThemeRequestDto): Result + @GET("api/v1/hint") suspend fun getHint(@Query("themeId") themeId: Int): Result> + @POST("api/v1/hint") + suspend fun addHint(@Body request: AddHintRequestDto): Result + + @PUT("api/v1/hint") + suspend fun editHint(@Body request: EditHintRequestDto): Result + + @HTTP(method = "DELETE", path = "api/v1/hint", hasBody = true) + suspend fun deleteHint(@Body request: RemoveHintRequestDto): Result + + /** + * [getMypageInfo] 함수가 구독 상태를 가져오는 용도로 쓰이고 있다. 역할이 중복됨. 불필요하면 추후 제거할 것 + */ @GET("api/v1/subscription/status") suspend fun getUserSubscriptionStatus(): Result> @@ -76,4 +105,11 @@ interface ApiService { suspend fun putAdditionalUserInfo( @Body request: AdditionalUserInfoRequestDto, ): Result> + + @GET("api/v1/hint/url") + suspend fun getHintImagePresignedUrls( + @Query("themeId") themeId: Int, + @Query("hintImageCount") hintImageCount: Int, + @Query("answerImageCount") answerImageCount: Int + ): Result> } diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/ImageUploadService.kt b/data/src/main/java/com/nextroom/nextroom/data/network/ImageUploadService.kt new file mode 100644 index 00000000..ed9625c9 --- /dev/null +++ b/data/src/main/java/com/nextroom/nextroom/data/network/ImageUploadService.kt @@ -0,0 +1,33 @@ +package com.nextroom.nextroom.data.network + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import java.io.File +import javax.inject.Inject + +class ImageUploadService @Inject constructor( + private val okHttpClient: OkHttpClient, +) { + suspend fun uploadImage( + presignedUrl: String, + imageFile: File, + contentType: String, + ): Boolean = withContext(Dispatchers.IO) { + try { + val requestBody = imageFile.asRequestBody(contentType.toMediaType()) + val request = Request.Builder() + .url(presignedUrl) + .put(requestBody) + .build() + + val response = okHttpClient.newCall(request).execute() + response.isSuccessful + } catch (e: Exception) { + false + } + } +} diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/request/AddHintRequestDto.kt b/data/src/main/java/com/nextroom/nextroom/data/network/request/AddHintRequestDto.kt new file mode 100644 index 00000000..7b134d2f --- /dev/null +++ b/data/src/main/java/com/nextroom/nextroom/data/network/request/AddHintRequestDto.kt @@ -0,0 +1,13 @@ +package com.nextroom.nextroom.data.network.request + +import com.google.gson.annotations.SerializedName + +data class AddHintRequestDto( + @SerializedName("themeId") val themeId: Int, + @SerializedName("hintCode") val hintCode: String, + @SerializedName("contents") val contents: String, + @SerializedName("answer") val answer: String, + @SerializedName("progress") val progress: Int, + @SerializedName("hintImageList") val hintImageList: List, + @SerializedName("answerImageList") val answerImageList: List, +) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/request/AddThemeRequestDto.kt b/data/src/main/java/com/nextroom/nextroom/data/network/request/AddThemeRequestDto.kt new file mode 100644 index 00000000..fba66c15 --- /dev/null +++ b/data/src/main/java/com/nextroom/nextroom/data/network/request/AddThemeRequestDto.kt @@ -0,0 +1,9 @@ +package com.nextroom.nextroom.data.network.request + +import com.google.gson.annotations.SerializedName + +data class AddThemeRequestDto( + @SerializedName("title") val title: String, + @SerializedName("timeLimit") val timeLimit: Int, + @SerializedName("hintLimit") val hintLimit: Int, +) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/request/EditHintRequestDto.kt b/data/src/main/java/com/nextroom/nextroom/data/network/request/EditHintRequestDto.kt new file mode 100644 index 00000000..a81eca71 --- /dev/null +++ b/data/src/main/java/com/nextroom/nextroom/data/network/request/EditHintRequestDto.kt @@ -0,0 +1,13 @@ +package com.nextroom.nextroom.data.network.request + +import com.google.gson.annotations.SerializedName + +data class EditHintRequestDto( + @SerializedName("id") val id: Int, + @SerializedName("hintCode") val hintCode: String, + @SerializedName("contents") val contents: String, + @SerializedName("answer") val answer: String, + @SerializedName("progress") val progress: Int, + @SerializedName("hintImageList") val hintImageList: List, + @SerializedName("answerImageList") val answerImageList: List, +) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/request/EditThemeRequestDto.kt b/data/src/main/java/com/nextroom/nextroom/data/network/request/EditThemeRequestDto.kt new file mode 100644 index 00000000..9ba0f753 --- /dev/null +++ b/data/src/main/java/com/nextroom/nextroom/data/network/request/EditThemeRequestDto.kt @@ -0,0 +1,10 @@ +package com.nextroom.nextroom.data.network.request + +import com.google.gson.annotations.SerializedName + +data class EditThemeRequestDto( + @SerializedName("id") val id: Int, + @SerializedName("title") val title: String, + @SerializedName("timeLimit") val timeLimit: Int, + @SerializedName("hintLimit") val hintLimit: Int, +) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/request/RemoveHintRequestDto.kt b/data/src/main/java/com/nextroom/nextroom/data/network/request/RemoveHintRequestDto.kt new file mode 100644 index 00000000..012a4bdb --- /dev/null +++ b/data/src/main/java/com/nextroom/nextroom/data/network/request/RemoveHintRequestDto.kt @@ -0,0 +1,7 @@ +package com.nextroom.nextroom.data.network.request + +import com.google.gson.annotations.SerializedName + +data class RemoveHintRequestDto( + @SerializedName("id") val id: Int, +) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/request/RemoveThemeRequestDto.kt b/data/src/main/java/com/nextroom/nextroom/data/network/request/RemoveThemeRequestDto.kt new file mode 100644 index 00000000..945106eb --- /dev/null +++ b/data/src/main/java/com/nextroom/nextroom/data/network/request/RemoveThemeRequestDto.kt @@ -0,0 +1,7 @@ +package com.nextroom.nextroom.data.network.request + +import com.google.gson.annotations.SerializedName + +data class RemoveThemeRequestDto( + @SerializedName("id") val id: Int, +) diff --git a/data/src/main/java/com/nextroom/nextroom/data/network/response/PresignedUrlResponseDto.kt b/data/src/main/java/com/nextroom/nextroom/data/network/response/PresignedUrlResponseDto.kt new file mode 100644 index 00000000..0a602c65 --- /dev/null +++ b/data/src/main/java/com/nextroom/nextroom/data/network/response/PresignedUrlResponseDto.kt @@ -0,0 +1,8 @@ +package com.nextroom.nextroom.data.network.response + +import com.google.gson.annotations.SerializedName + +data class PresignedUrlResponseDto( + @SerializedName("hintImageUrlList") val hintImageUrlList: List?, + @SerializedName("answerImageUrlList") val answerImageUrlList: List?, +) diff --git a/data/src/main/java/com/nextroom/nextroom/data/repository/AdminRepositoryImpl.kt b/data/src/main/java/com/nextroom/nextroom/data/repository/AdminRepositoryImpl.kt index e8b91b63..86d26e5a 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/repository/AdminRepositoryImpl.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/repository/AdminRepositoryImpl.kt @@ -21,6 +21,7 @@ import com.nextroom.nextroom.domain.model.GoogleLoginResponse import com.nextroom.nextroom.domain.model.LoginInfo import com.nextroom.nextroom.domain.model.Mypage import com.nextroom.nextroom.domain.model.Result +import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.domain.model.SubscriptionPlan import com.nextroom.nextroom.domain.model.UserSubscribeStatus import com.nextroom.nextroom.domain.model.mapOnSuccess @@ -47,6 +48,10 @@ class AdminRepositoryImpl @Inject constructor( override val authEvent: Flow = authDataSource.authEvent + private var _cachedSubscribeStatus: SubscribeStatus = SubscribeStatus.Default + override val cachedSubscribeStatus: SubscribeStatus + get() = _cachedSubscribeStatus + override suspend fun login(adminCode: String, password: String, emailSaveChecked: Boolean): Result { return authDataSource.login(adminCode, password).onSuccess { if (emailSaveChecked) { @@ -106,12 +111,19 @@ class AdminRepositoryImpl @Inject constructor( return settingDataSource.getAdminCode() == code } + /** + * [getUserSubscribe] 함수가 구독 상태를 가져오는 용도로 쓰이고 있다. 역할이 중복됨. 불필요하면 추후 제거할 것 + */ override suspend fun getUserSubscribeStatus(): Result { - return subscriptionDataSource.getUserSubscriptionStatus() + return subscriptionDataSource.getUserSubscriptionStatus().onSuccess { + _cachedSubscribeStatus = it.subscribeStatus + } } override suspend fun getUserSubscribe(): Result { - return subscriptionDataSource.getUserSubscription() + return subscriptionDataSource.getUserSubscription().onSuccess { + _cachedSubscribeStatus = it.status + } } override suspend fun getEmailSaveChecked(): Boolean { diff --git a/data/src/main/java/com/nextroom/nextroom/data/repository/HintRepositoryImpl.kt b/data/src/main/java/com/nextroom/nextroom/data/repository/HintRepositoryImpl.kt index bff14994..2af96846 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/repository/HintRepositoryImpl.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/repository/HintRepositoryImpl.kt @@ -2,6 +2,7 @@ package com.nextroom.nextroom.data.repository import com.nextroom.nextroom.data.datasource.HintLocalDataSource import com.nextroom.nextroom.data.datasource.HintRemoteDataSource +import com.nextroom.nextroom.data.datasource.ImageUploadDataSource import com.nextroom.nextroom.data.datasource.SettingDataSource import com.nextroom.nextroom.data.datasource.ThemeLocalDataSource import com.nextroom.nextroom.data.model.toDomain @@ -10,6 +11,10 @@ import com.nextroom.nextroom.domain.model.Result import com.nextroom.nextroom.domain.model.mapOnSuccess import com.nextroom.nextroom.domain.model.suspendOnSuccess import com.nextroom.nextroom.domain.repository.HintRepository +import com.nextroom.nextroom.domain.repository.UploadImagesResult +import com.nextroom.nextroom.domain.request.AddHintRequest +import com.nextroom.nextroom.domain.request.EditHintRequest +import java.io.File import javax.inject.Inject class HintRepositoryImpl @Inject constructor( @@ -17,6 +22,7 @@ class HintRepositoryImpl @Inject constructor( private val hintRemoteDataSource: HintRemoteDataSource, private val themeLocalDataSource: ThemeLocalDataSource, private val settingDataSource: SettingDataSource, + private val imageUploadDataSource: ImageUploadDataSource, ) : HintRepository { override suspend fun getHint(hintCode: String): Hint? { @@ -32,4 +38,39 @@ class HintRepositoryImpl @Inject constructor( hintLocalDataSource.saveHints(themeId, it) }.mapOnSuccess { updatedTime } } + + override suspend fun getHintsForTheme(themeId: Int): Result> { + return hintRemoteDataSource.getHints(themeId) + } + + override suspend fun addHint(request: AddHintRequest): Result { + return hintRemoteDataSource.addHint(request) + } + + override suspend fun editHint(request: EditHintRequest): Result { + return hintRemoteDataSource.editHint(request) + } + + override suspend fun deleteHint(hintId: Int): Result { + return hintRemoteDataSource.deleteHint(hintId) + } + + override suspend fun uploadImages( + themeId: Int, + hintImageFiles: List, + answerImageFiles: List + ): Result { + return imageUploadDataSource.uploadImages( + themeId = themeId, + hintImageFiles = hintImageFiles, + answerImageFiles = answerImageFiles + ).mapOnSuccess { + UploadImagesResult( + hintImageFileNames = it.hintImageFileNames, + answerImageFileNames = it.answerImageFileNames, + failedHintImageIndices = it.failedHintImageIndices, + failedAnswerImageIndices = it.failedAnswerImageIndices, + ) + } + } } diff --git a/data/src/main/java/com/nextroom/nextroom/data/repository/ThemeRepositoryImpl.kt b/data/src/main/java/com/nextroom/nextroom/data/repository/ThemeRepositoryImpl.kt index eeb8ab1d..10a92217 100644 --- a/data/src/main/java/com/nextroom/nextroom/data/repository/ThemeRepositoryImpl.kt +++ b/data/src/main/java/com/nextroom/nextroom/data/repository/ThemeRepositoryImpl.kt @@ -8,6 +8,8 @@ import com.nextroom.nextroom.domain.model.Result import com.nextroom.nextroom.domain.model.ThemeInfo import com.nextroom.nextroom.domain.model.suspendOnSuccess import com.nextroom.nextroom.domain.repository.ThemeRepository +import com.nextroom.nextroom.domain.request.AddThemeRequest +import com.nextroom.nextroom.domain.request.EditThemeRequest import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import javax.inject.Inject @@ -63,4 +65,16 @@ class ThemeRepositoryImpl @Inject constructor( override suspend fun getThemeById(id: Int): ThemeInfo { return themeLocalDataSource.getTheme(id).first() } + + override suspend fun addTheme(request: AddThemeRequest): Result { + return themeRemoteDateSource.addTheme(request) + } + + override suspend fun editTheme(request: EditThemeRequest): Result { + return themeRemoteDateSource.editTheme(request) + } + + override suspend fun deleteTheme(themeId: Int): Result { + return themeRemoteDateSource.deleteTheme(themeId) + } } diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/repository/AdminRepository.kt b/domain/src/main/java/com/nextroom/nextroom/domain/repository/AdminRepository.kt index 8bb03086..29577ef8 100644 --- a/domain/src/main/java/com/nextroom/nextroom/domain/repository/AdminRepository.kt +++ b/domain/src/main/java/com/nextroom/nextroom/domain/repository/AdminRepository.kt @@ -6,6 +6,7 @@ import com.nextroom.nextroom.domain.model.GoogleLoginResponse import com.nextroom.nextroom.domain.model.LoginInfo import com.nextroom.nextroom.domain.model.Mypage import com.nextroom.nextroom.domain.model.Result +import com.nextroom.nextroom.domain.model.SubscribeStatus import com.nextroom.nextroom.domain.model.SubscriptionPlan import com.nextroom.nextroom.domain.model.UserSubscribeStatus import kotlinx.coroutines.flow.Flow @@ -15,6 +16,7 @@ interface AdminRepository { val shopName: Flow val loggedIn: Flow val authEvent: Flow + val cachedSubscribeStatus: SubscribeStatus /** * @return shopName diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/repository/HintRepository.kt b/domain/src/main/java/com/nextroom/nextroom/domain/repository/HintRepository.kt index 7c7ac876..4ea295d9 100644 --- a/domain/src/main/java/com/nextroom/nextroom/domain/repository/HintRepository.kt +++ b/domain/src/main/java/com/nextroom/nextroom/domain/repository/HintRepository.kt @@ -2,8 +2,30 @@ package com.nextroom.nextroom.domain.repository import com.nextroom.nextroom.domain.model.Hint import com.nextroom.nextroom.domain.model.Result +import com.nextroom.nextroom.domain.request.AddHintRequest +import com.nextroom.nextroom.domain.request.EditHintRequest +import java.io.File + +data class UploadImagesResult( + val hintImageFileNames: List, + val answerImageFileNames: List, + val failedHintImageIndices: Set = emptySet(), + val failedAnswerImageIndices: Set = emptySet(), +) { + val hasFailures: Boolean + get() = failedHintImageIndices.isNotEmpty() || failedAnswerImageIndices.isNotEmpty() +} interface HintRepository { suspend fun getHint(hintCode: String): Hint? suspend fun saveHints(themeId: Int): Result + suspend fun getHintsForTheme(themeId: Int): Result> + suspend fun addHint(request: AddHintRequest): Result + suspend fun editHint(request: EditHintRequest): Result + suspend fun deleteHint(hintId: Int): Result + suspend fun uploadImages( + themeId: Int, + hintImageFiles: List, + answerImageFiles: List + ): Result } diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/repository/ThemeRepository.kt b/domain/src/main/java/com/nextroom/nextroom/domain/repository/ThemeRepository.kt index 75b4ced4..ed426a70 100644 --- a/domain/src/main/java/com/nextroom/nextroom/domain/repository/ThemeRepository.kt +++ b/domain/src/main/java/com/nextroom/nextroom/domain/repository/ThemeRepository.kt @@ -2,6 +2,8 @@ package com.nextroom.nextroom.domain.repository import com.nextroom.nextroom.domain.model.Result import com.nextroom.nextroom.domain.model.ThemeInfo +import com.nextroom.nextroom.domain.request.AddThemeRequest +import com.nextroom.nextroom.domain.request.EditThemeRequest import kotlinx.coroutines.flow.Flow interface ThemeRepository { @@ -12,4 +14,7 @@ interface ThemeRepository { suspend fun getLatestTheme(): Flow // 최근 플레이 한 테마 suspend fun activateThemeBackgroundImage(activeThemeIdList: List, deActiveThemeIdList: List): Result suspend fun getThemeById(id: Int): ThemeInfo + suspend fun addTheme(request: AddThemeRequest): Result + suspend fun editTheme(request: EditThemeRequest): Result + suspend fun deleteTheme(themeId: Int): Result } diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/request/AddHintRequest.kt b/domain/src/main/java/com/nextroom/nextroom/domain/request/AddHintRequest.kt new file mode 100644 index 00000000..1ac83de6 --- /dev/null +++ b/domain/src/main/java/com/nextroom/nextroom/domain/request/AddHintRequest.kt @@ -0,0 +1,11 @@ +package com.nextroom.nextroom.domain.request + +data class AddHintRequest( + val themeId: Int, + val hintCode: String, + val contents: String, + val answer: String, + val progress: Int, + val hintImageUrlList: List = emptyList(), + val answerImageUrlList: List = emptyList(), +) diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/request/AddThemeRequest.kt b/domain/src/main/java/com/nextroom/nextroom/domain/request/AddThemeRequest.kt new file mode 100644 index 00000000..1845560d --- /dev/null +++ b/domain/src/main/java/com/nextroom/nextroom/domain/request/AddThemeRequest.kt @@ -0,0 +1,7 @@ +package com.nextroom.nextroom.domain.request + +data class AddThemeRequest( + val title: String, + val timeLimit: Int, + val hintLimit: Int, +) diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/request/EditHintRequest.kt b/domain/src/main/java/com/nextroom/nextroom/domain/request/EditHintRequest.kt new file mode 100644 index 00000000..027cd168 --- /dev/null +++ b/domain/src/main/java/com/nextroom/nextroom/domain/request/EditHintRequest.kt @@ -0,0 +1,11 @@ +package com.nextroom.nextroom.domain.request + +data class EditHintRequest( + val id: Int, + val hintCode: String, + val contents: String, + val answer: String, + val progress: Int, + val hintImageUrlList: List = emptyList(), + val answerImageUrlList: List = emptyList(), +) diff --git a/domain/src/main/java/com/nextroom/nextroom/domain/request/EditThemeRequest.kt b/domain/src/main/java/com/nextroom/nextroom/domain/request/EditThemeRequest.kt new file mode 100644 index 00000000..7be56e92 --- /dev/null +++ b/domain/src/main/java/com/nextroom/nextroom/domain/request/EditThemeRequest.kt @@ -0,0 +1,8 @@ +package com.nextroom.nextroom.domain.request + +data class EditThemeRequest( + val id: Int, + val title: String, + val timeLimit: Int, + val hintLimit: Int, +) diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/common/NRTwoButtonDialog.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/NRTwoButtonDialog.kt index d463a57d..e026325a 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/common/NRTwoButtonDialog.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/NRTwoButtonDialog.kt @@ -52,7 +52,10 @@ class NRTwoButtonDialog : private fun initListeners() { binding.btnPositive.setOnClickListener { findNavController().popBackStack() - setFragmentResult(args.nrTwoButtonArgument.dialogKey, bundleOf()) + setFragmentResult( + args.nrTwoButtonArgument.dialogKey, + args.nrTwoButtonArgument.bundle ?: bundleOf() + ) } binding.btnNegative.setOnClickListener { findNavController().popBackStack() @@ -67,5 +70,6 @@ class NRTwoButtonDialog : val posBtnText: String? = null, val negBtnText: String? = null, val dialogKey: String, + val bundle: Bundle? = null, ) : Parcelable } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NROutlinedTextField.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NROutlinedTextField.kt new file mode 100644 index 00000000..f9082efa --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/common/compose/NROutlinedTextField.kt @@ -0,0 +1,65 @@ +package com.nextroom.nextroom.presentation.common.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp + +@Composable +fun NROutlinedTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Text, + singleLine: Boolean = true, + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + placeholder: String = "", +) { + Column(modifier = modifier) { + Text( + modifier = Modifier.padding(bottom = 8.dp), + text = label, + style = NRTypo.Body.size12Regular, + color = NRColor.Gray01, + ) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + textStyle = NRTypo.Body.size14Regular.copy(color = NRColor.White), + singleLine = singleLine, + minLines = minLines, + maxLines = maxLines, + shape = RoundedCornerShape(8.dp), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + placeholder = if (placeholder.isEmpty()) { + null + } else { + { + Text( + text = placeholder, + color = NRColor.Gray02, + style = NRTypo.Body.size14Regular + ) + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = NRColor.Blue, + unfocusedBorderColor = NRColor.Gray02, + focusedContainerColor = NRColor.Sub1, + unfocusedContainerColor = NRColor.Sub1, + cursorColor = NRColor.Blue, + ), + ) + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/extension/Flow.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/extension/Flow.kt new file mode 100644 index 00000000..a7bbbee5 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/extension/Flow.kt @@ -0,0 +1,22 @@ +package com.nextroom.nextroom.presentation.extension + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine + +@Suppress("UNCHECKED_CAST") +fun combine( + flow1: Flow, + flow2: Flow, + flow3: Flow, + flow4: Flow, + flow5: Flow, + flow6: Flow, + transform: suspend (T1, T2, T3, T4, T5, T6) -> R, +): Flow = combine( + combine(flow1, flow2, flow3, flow4, flow5) { t1, t2, t3, t4, t5 -> + arrayOf(t1, t2, t3, t4, t5) + }, + flow6, +) { arr, t6 -> + transform(arr[0] as T1, arr[1] as T2, arr[2] as T3, arr[3] as T4, arr[4] as T5, t6) +} \ No newline at end of file diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/HintManageFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/HintManageFragment.kt new file mode 100644 index 00000000..8cac3056 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/HintManageFragment.kt @@ -0,0 +1,161 @@ +package com.nextroom.nextroom.presentation.ui.manage.hint + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.setFragmentResultListener +import androidx.fragment.app.viewModels +import androidx.navigation.NavOptions +import androidx.navigation.fragment.findNavController +import com.nextroom.nextroom.domain.model.Hint +import com.nextroom.nextroom.presentation.NavGraphDirections +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment +import com.nextroom.nextroom.presentation.common.NRTwoButtonDialog +import com.nextroom.nextroom.presentation.common.compose.NRLoading +import com.nextroom.nextroom.presentation.extension.safeNavigate +import com.nextroom.nextroom.presentation.extension.snackbar +import com.nextroom.nextroom.presentation.extension.toast +import com.nextroom.nextroom.presentation.ui.manage.hint.compose.HintManageScreen +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class HintManageFragment : ComposeBaseViewModelFragment() { + + override val screenName: String = "hint_manage" + override val viewModel: HintManageViewModel by viewModels() + + private var onHintImagesSelected: ((List) -> Unit)? = null + private var onAnswerImagesSelected: ((List) -> Unit)? = null + + private val hintImagePickerLauncher = registerForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(maxItems = MAX_SELECT_IMAGE_COUNT) + ) { uris -> + if (uris.isNotEmpty()) { + onHintImagesSelected?.invoke(uris) + } + } + + private val answerImagePickerLauncher = registerForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(maxItems = MAX_SELECT_IMAGE_COUNT) + ) { uris -> + if (uris.isNotEmpty()) { + onAnswerImagesSelected?.invoke(uris) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + onHintImagesSelected = { uris -> viewModel.addHintImages(uris) } + onAnswerImagesSelected = { uris -> viewModel.addAnswerImages(uris) } + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.uiEvent.collect { event -> + when (event) { + is HintManageEvent.RequestDeleteHint -> showDeleteConfirmDialog(event.hint) + is HintManageEvent.HintSaved -> snackbar(R.string.hint_manage_save_success) + is HintManageEvent.HintDeleted -> snackbar(R.string.hint_manage_delete_success) + is HintManageEvent.ImageUploadFailed -> { + when (event.reason) { + HintManageEvent.ImageUploadFailed.Reason.INVALID_FORMAT -> R.string.image_invalid_format + HintManageEvent.ImageUploadFailed.Reason.INVALID_SIZE -> R.string.image_invalid_size + HintManageEvent.ImageUploadFailed.Reason.EXCEED_IMAGE_COUNT -> R.string.image_count_exceed + HintManageEvent.ImageUploadFailed.Reason.CONVERT_FAIL -> R.string.image_convert_failed + HintManageEvent.ImageUploadFailed.Reason.UPLOAD_FAIL -> R.string.image_upload_failed + HintManageEvent.ImageUploadFailed.Reason.NOT_SUBSCRIBE -> R.string.feature_for_subscriber + }.also { + toast(it) + } + } + } + } + } + + when (val state = uiState) { + is HintManageUiState.PreLoading -> NRLoading(isVisible = true) + is HintManageUiState.Loaded -> HintManageScreen( + themeTitle = viewModel.themeTitle, + state = state, + onBackClick = { findNavController().navigateUp() }, + onAddClick = viewModel::showAddSheet, + onHintClick = viewModel::showEditSheet, + onDeleteClick = viewModel::requestDelete, + onHideSheet = viewModel::hideSheet, + onCodeChange = viewModel::updateCode, + onContentsChange = viewModel::updateContents, + onAnswerChange = viewModel::updateAnswer, + onProgressChange = viewModel::updateProgress, + onSaveHint = viewModel::saveHint, + onAddHintImages = { launchHintImagePicker() }, + onAddAnswerImages = { launchAnswerImagePicker() }, + onRemoveHintImage = viewModel::removeHintImage, + onRemoveAnswerImage = viewModel::removeAnswerImage, + onSortTypeChange = viewModel::changeSortType, + ) + } + } + } + } + + override fun initSubscribe() { + // Event handling is now done in Compose LaunchedEffect + } + + override fun setFragmentResultListeners() { + setFragmentResultListener(DIALOG_KEY_DELETE_HINT) { _, _ -> + viewModel.confirmDelete() + } + } + + private fun launchHintImagePicker() { + hintImagePickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + + private fun launchAnswerImagePicker() { + answerImagePickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + + private fun showDeleteConfirmDialog(hint: Hint) { + NavGraphDirections.moveToNrTwoButtonDialog( + NRTwoButtonDialog.NRTwoButtonArgument( + title = getString(R.string.hint_manage_delete_confirm), + message = "[${hint.code}] ${hint.description.take(ELLIPSIS_THRESHOLD)}${if (hint.description.length > ELLIPSIS_THRESHOLD) "…" else ""}", + posBtnText = getString(R.string.text_delete), + negBtnText = getString(R.string.text_cancel), + dialogKey = DIALOG_KEY_DELETE_HINT, + ) + ).also { + findNavController().safeNavigate( + direction = it, + navOptions = NavOptions.Builder().setLaunchSingleTop(true).build(), + ) + } + } + + companion object { + private const val DIALOG_KEY_DELETE_HINT = "DIALOG_KEY_DELETE_HINT" + private const val ELLIPSIS_THRESHOLD = 30 + private const val MAX_SELECT_IMAGE_COUNT = 5 + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/HintManageState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/HintManageState.kt new file mode 100644 index 00000000..db7efd75 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/HintManageState.kt @@ -0,0 +1,70 @@ +package com.nextroom.nextroom.presentation.ui.manage.hint + +import android.net.Uri +import com.nextroom.nextroom.domain.model.Hint + +data class HintEditData( + val hintId: Int? = null, + val code: String = "", + val contents: String = "", + val answer: String = "", + val progress: Int? = null, + val hintImageUris: List = emptyList(), + val hintImageUrls: List = emptyList(), + val answerImageUris: List = emptyList(), + val answerImageUrls: List = emptyList(), + val failedHintImageUriIndices: Set = emptySet(), + val failedAnswerImageUriIndices: Set = emptySet(), +) + +sealed interface HintManageUiState { + data object PreLoading : HintManageUiState + data class Loaded( + val hints: List, + val isLoading: Boolean, + val sortType: HintSortType, + val uploadingImages: Boolean, + val uploadProgress: UploadProgress?, + val editData: HintEditData, + val sheetType: HintSheetType, + ) : HintManageUiState { + val sortedHints: List + get() = when (sortType) { + HintSortType.PROGRESS -> hints.sortedBy { it.progress } + HintSortType.CODE -> hints.sortedBy { it.code } + } + } +} + +data class UploadProgress( + val current: Int, + val total: Int, +) + +data class UploadState( + val uploadingImages: Boolean, + val uploadProgress: UploadProgress?, +) + +enum class HintSheetType { None, Add, Edit } + +enum class HintSortType { + PROGRESS, // 진행률순 (기본값) + CODE // 힌트코드순 +} + +sealed interface HintManageEvent { + data class RequestDeleteHint(val hint: Hint) : HintManageEvent + data object HintSaved : HintManageEvent + data object HintDeleted : HintManageEvent + data class ImageUploadFailed(val reason: Reason) : HintManageEvent { + enum class Reason { + INVALID_FORMAT, + INVALID_SIZE, + EXCEED_IMAGE_COUNT, + CONVERT_FAIL, + UPLOAD_FAIL, + NOT_SUBSCRIBE, + } + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/HintManageViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/HintManageViewModel.kt new file mode 100644 index 00000000..2d50fbea --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/HintManageViewModel.kt @@ -0,0 +1,375 @@ +package com.nextroom.nextroom.presentation.ui.manage.hint + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.SavedStateHandle +import com.nextroom.nextroom.domain.model.Hint +import com.nextroom.nextroom.domain.model.Result +import com.nextroom.nextroom.domain.model.SubscribeStatus +import com.nextroom.nextroom.domain.model.onSuccess +import com.nextroom.nextroom.domain.repository.AdminRepository +import com.nextroom.nextroom.domain.repository.HintRepository +import com.nextroom.nextroom.domain.repository.UploadImagesResult +import com.nextroom.nextroom.domain.request.AddHintRequest +import com.nextroom.nextroom.domain.request.EditHintRequest +import com.nextroom.nextroom.presentation.base.NewBaseViewModel +import com.nextroom.nextroom.presentation.extension.combine +import com.nextroom.nextroom.presentation.ui.manage.hint.HintManageEvent.ImageUploadFailed.Reason +import com.nextroom.nextroom.presentation.util.ImageUtil +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HintManageViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val hintRepository: HintRepository, + @param:ApplicationContext private val context: Context, + private val adminRepository: AdminRepository, +) : NewBaseViewModel() { + + val themeId: Int = checkNotNull(savedStateHandle["themeId"]) + val themeTitle: String = checkNotNull(savedStateHandle["themeTitle"]) + + private val _hints = MutableStateFlow?>(null) + private val _isLoading = MutableStateFlow(false) + private val _sortType = MutableStateFlow(HintSortType.PROGRESS) + private val _uploadState = MutableStateFlow( + UploadState( + uploadingImages = false, + uploadProgress = null, + ) + ) + private val _editData = MutableStateFlow(HintEditData()) + private val _sheetType = MutableStateFlow(HintSheetType.None) + + val uiState = combine( + _hints, + _isLoading, + _sortType, + _uploadState, + _editData, + _sheetType, + ) { hints, isLoading, sortType, uploadState, editData, sheetType -> + if (hints == null) { + HintManageUiState.PreLoading + } else { + HintManageUiState.Loaded( + hints = hints, + isLoading = isLoading, + sortType = sortType, + uploadingImages = uploadState.uploadingImages, + uploadProgress = uploadState.uploadProgress, + editData = editData, + sheetType = sheetType, + ) + } + }.stateIn(baseViewModelScope, SharingStarted.Lazily, HintManageUiState.PreLoading) + + private val _uiEvent = MutableSharedFlow(extraBufferCapacity = 1) + val uiEvent = _uiEvent.asSharedFlow() + + init { + loadHints() + } + + fun loadHints() { + baseViewModelScope.launch { + try { + _isLoading.emit(true) + hintRepository.getHintsForTheme(themeId).getOrThrow.also { hints -> + _hints.emit(hints) + } + } catch (e: Exception) { + handleError(e) + } finally { + _isLoading.emit(false) + } + } + } + + fun showAddSheet() { + _editData.value = HintEditData() + _sheetType.value = HintSheetType.Add + } + + fun showEditSheet(hint: Hint) { + _editData.value = HintEditData( + hintId = hint.id, + code = hint.code, + contents = hint.description, + answer = hint.answer, + progress = hint.progress, + hintImageUrls = hint.hintImageUrlList, + answerImageUrls = hint.answerImageUrlList, + ) + _sheetType.value = HintSheetType.Edit + } + + fun hideSheet() { + _sheetType.value = HintSheetType.None + } + + fun updateCode(code: String) { + if (code.length <= 4 && code.all { it.isDigit() }) { + _editData.value = _editData.value.copy(code = code) + } + } + + fun updateContents(contents: String) { + _editData.value = _editData.value.copy(contents = contents) + } + + fun updateAnswer(answer: String) { + _editData.value = _editData.value.copy(answer = answer) + } + + fun updateProgress(progress: Int?) { + _editData.value = _editData.value.copy(progress = progress?.coerceIn(0, 100)) + } + + fun addHintImages(uris: List) { + baseViewModelScope.launch { + val current = _editData.value + val newList = validateAndAddImages( + uris = uris, + currentUris = current.hintImageUris, + currentUrls = current.hintImageUrls, + ) ?: return@launch + _editData.value = current.copy(hintImageUris = newList) + } + } + + fun addAnswerImages(uris: List) { + baseViewModelScope.launch { + val current = _editData.value + val newList = validateAndAddImages( + uris = uris, + currentUris = current.answerImageUris, + currentUrls = current.answerImageUrls, + ) ?: return@launch + _editData.value = current.copy(answerImageUris = newList) + } + } + + fun removeHintImage(index: Int) { + val current = _editData.value + val urisSize = current.hintImageUris.size + val newUris: List + val newUrls: List + if (index < urisSize) { + newUris = current.hintImageUris.toMutableList().also { it.removeAt(index) } + newUrls = current.hintImageUrls + } else { + newUris = current.hintImageUris + newUrls = current.hintImageUrls.toMutableList().also { it.removeAt(index - urisSize) } + } + _editData.value = current.copy(hintImageUris = newUris, hintImageUrls = newUrls) + } + + fun removeAnswerImage(index: Int) { + val current = _editData.value + val urisSize = current.answerImageUris.size + val newUris: List + val newUrls: List + if (index < urisSize) { + newUris = current.answerImageUris.toMutableList().also { it.removeAt(index) } + newUrls = current.answerImageUrls + } else { + newUris = current.answerImageUris + newUrls = current.answerImageUrls.toMutableList().also { it.removeAt(index - urisSize) } + } + _editData.value = current.copy(answerImageUris = newUris, answerImageUrls = newUrls) + } + + fun changeSortType(sortType: HintSortType) { + baseViewModelScope.launch { + _sortType.emit(sortType) + } + } + + fun saveHint() { + val editData = _editData.value + _sheetType.value = HintSheetType.None + baseViewModelScope.launch { + try { + _isLoading.emit(true) + val uploadedHintUrls: List + val uploadedAnswerUrls: List + + if (editData.hintImageUris.isNotEmpty() || editData.answerImageUris.isNotEmpty()) { + val uploadResult = + uploadImages(editData.hintImageUris, editData.answerImageUris) + ?: return@launch + uploadedHintUrls = editData.hintImageUrls + uploadResult.hintImageFileNames + uploadedAnswerUrls = + editData.answerImageUrls + uploadResult.answerImageFileNames + if (uploadResult.hasFailures) { + _editData.value = editData.copy( + failedHintImageUriIndices = uploadResult.failedHintImageIndices, + failedAnswerImageUriIndices = uploadResult.failedAnswerImageIndices, + ) + } + } else { + uploadedHintUrls = editData.hintImageUrls + uploadedAnswerUrls = editData.answerImageUrls + } + + val result = if (editData.hintId == null) { + hintRepository.addHint( + AddHintRequest( + themeId = themeId, + hintCode = editData.code, + contents = editData.contents, + answer = editData.answer, + progress = editData.progress ?: 0, + hintImageUrlList = uploadedHintUrls, + answerImageUrlList = uploadedAnswerUrls, + ) + ) + } else { + hintRepository.editHint( + EditHintRequest( + id = editData.hintId, + hintCode = editData.code, + contents = editData.contents, + answer = editData.answer, + progress = editData.progress ?: 0, + hintImageUrlList = uploadedHintUrls, + answerImageUrlList = uploadedAnswerUrls, + ) + ) + } + result.onSuccess { + loadHints() + _uiEvent.emit(HintManageEvent.HintSaved) + } + } catch (e: Exception) { + handleError(e) + } finally { + _isLoading.emit(false) + } + } + } + + private var deleteTargetHint: Hint? = null + + fun requestDelete(hint: Hint) { + deleteTargetHint = hint + baseViewModelScope.launch { + _uiEvent.emit(HintManageEvent.RequestDeleteHint(hint)) + } + } + + fun confirmDelete() { + val hintId = deleteTargetHint?.id ?: return + deleteTargetHint = null + baseViewModelScope.launch { + try { + _isLoading.emit(true) + hintRepository.deleteHint(hintId).getOrThrow.also { + loadHints() + _uiEvent.emit(HintManageEvent.HintDeleted) + } + } catch (e: Exception) { + handleError(e) + } finally { + _isLoading.emit(false) + } + } + } + + private suspend fun validateAndAddImages( + uris: List, + currentUris: List, + currentUrls: List, + ): List? { + when (adminRepository.cachedSubscribeStatus) { + SubscribeStatus.Default, + SubscribeStatus.SUBSCRIPTION_EXPIRATION -> { + _uiEvent.emit(HintManageEvent.ImageUploadFailed(Reason.NOT_SUBSCRIBE)) + return null + } + + SubscribeStatus.Subscribed -> Unit + } + + val validUris = uris.filter { uri -> + val mimeType = ImageUtil.getMimeType(context, uri) + val validFormat = ImageUtil.isValidImageFormat(mimeType) + val validSize = ImageUtil.validateImageSize(context, uri) + + if (!validFormat) _uiEvent.emit(HintManageEvent.ImageUploadFailed(Reason.INVALID_FORMAT)) + if (!validSize) _uiEvent.emit(HintManageEvent.ImageUploadFailed(Reason.INVALID_SIZE)) + + validFormat && validSize + } + + val totalImages = currentUrls.size + currentUris.size + validUris.size + return if (totalImages > 5) { + _uiEvent.emit(HintManageEvent.ImageUploadFailed(Reason.EXCEED_IMAGE_COUNT)) + null + } else { + currentUris + validUris + } + } + + private suspend fun uploadImages( + hintImageUris: List, + answerImageUris: List, + ): UploadImagesResult? { + _uploadState.emit(_uploadState.value.copy(uploadingImages = true)) + + val hintFiles = hintImageUris.mapNotNull { uri -> + ImageUtil.uriToFile(context, uri) + } + + val answerFiles = answerImageUris.mapNotNull { uri -> + ImageUtil.uriToFile(context, uri) + } + + if (hintFiles.size != hintImageUris.size || answerFiles.size != answerImageUris.size) { + _uploadState.emit( + _uploadState.value.copy( + uploadingImages = false, + uploadProgress = null + ) + ) + _uiEvent.emit(HintManageEvent.ImageUploadFailed(Reason.CONVERT_FAIL)) + return null + } + + return when (val result = hintRepository.uploadImages( + themeId = themeId, + hintImageFiles = hintFiles, + answerImageFiles = answerFiles + )) { + is Result.Success -> { + _uploadState.emit( + _uploadState.value.copy( + uploadingImages = false, + uploadProgress = null + ) + ) + result.data + } + + is Result.Failure -> { + _uploadState.emit( + _uploadState.value.copy( + uploadingImages = false, + uploadProgress = null + ) + ) + _uiEvent.emit(HintManageEvent.ImageUploadFailed(Reason.UPLOAD_FAIL)) + null + } + } + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/compose/HintManageScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/compose/HintManageScreen.kt new file mode 100644 index 00000000..e2b4d334 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/hint/compose/HintManageScreen.kt @@ -0,0 +1,931 @@ +package com.nextroom.nextroom.presentation.ui.manage.hint.compose + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.nextroom.nextroom.domain.model.Hint +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRTypo +import com.nextroom.nextroom.presentation.ui.manage.hint.HintEditData +import com.nextroom.nextroom.presentation.ui.manage.hint.HintManageUiState +import com.nextroom.nextroom.presentation.ui.manage.hint.HintSheetType +import com.nextroom.nextroom.presentation.ui.manage.hint.HintSortType +import com.nextroom.nextroom.presentation.ui.manage.hint.UploadProgress + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun HintManageScreen( + themeTitle: String, + state: HintManageUiState.Loaded, + onBackClick: () -> Unit, + onAddClick: () -> Unit, + onHintClick: (Hint) -> Unit, + onDeleteClick: (Hint) -> Unit, + onHideSheet: () -> Unit, + onCodeChange: (String) -> Unit, + onContentsChange: (String) -> Unit, + onAnswerChange: (String) -> Unit, + onProgressChange: (Int?) -> Unit, + onSaveHint: () -> Unit, + onAddHintImages: () -> Unit, + onAddAnswerImages: () -> Unit, + onRemoveHintImage: (Int) -> Unit, + onRemoveAnswerImage: (Int) -> Unit, + onSortTypeChange: (HintSortType) -> Unit, +) { + var showSortMenu by remember { mutableStateOf(false) } + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + confirmValueChange = { newValue -> + // 스와이프로 닫히지 않도록 Hidden 상태로 변경 방지 + newValue != SheetValue.Hidden + } + ) + + Scaffold( + topBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .background(NRColor.Dark01), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(R.string.hint_manage_title), + style = NRTypo.Pretendard.size18SemiBold, + color = NRColor.White, + textAlign = TextAlign.Center, + ) + Text( + text = themeTitle, + style = NRTypo.Body.size12Regular, + color = NRColor.Gray01, + textAlign = TextAlign.Center, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onBackClick, + modifier = Modifier + .size(64.dp) + .padding(20.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = NRColor.White, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + IconButton( + onClick = { showSortMenu = true }, + modifier = Modifier + .size(64.dp) + .padding(20.dp) + ) { + Icon( + imageVector = Icons.Default.Menu, + contentDescription = null, + tint = NRColor.White, + ) + } + + DropdownMenu( + expanded = showSortMenu, + onDismissRequest = { showSortMenu = false }, + modifier = Modifier.background(NRColor.Sub1), + ) { + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.hint_manage_sort_by_progress), + color = if (state.sortType == HintSortType.PROGRESS) NRColor.Blue else NRColor.White, + style = NRTypo.Body.size14Medium, + ) + }, + onClick = { + onSortTypeChange(HintSortType.PROGRESS) + showSortMenu = false + } + ) + DropdownMenuItem( + text = { + Text( + text = stringResource(R.string.hint_manage_sort_by_code), + color = if (state.sortType == HintSortType.CODE) NRColor.Blue else NRColor.White, + style = NRTypo.Body.size14Medium, + ) + }, + onClick = { + onSortTypeChange(HintSortType.CODE) + showSortMenu = false + } + ) + } + } + + IconButton( + onClick = onAddClick, + modifier = Modifier + .size(64.dp) + .padding(20.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = NRColor.White, + ) + } + } + } + } + }, + containerColor = NRColor.Dark01, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (state.hints.isEmpty() && !state.isLoading) { + Text( + text = stringResource(R.string.hint_manage_empty_guide), + style = NRTypo.Body.size14Regular, + color = NRColor.Gray01, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 24.dp), + ) + } + + LazyColumn(modifier = Modifier.fillMaxSize()) { + items(state.sortedHints, key = { it.id }) { hint -> + HintItem( + hint = hint, + onHintClick = { onHintClick(hint) }, + onDeleteClick = { onDeleteClick(hint) }, + ) + HorizontalDivider(color = NRColor.Gray03) + } + } + + if (state.isLoading) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = NRColor.Blue, + ) + } + } + } + + if (state.sheetType != HintSheetType.None) { + ModalBottomSheet( + onDismissRequest = onHideSheet, + sheetState = sheetState, + containerColor = NRColor.Sub1, + dragHandle = null, + ) { + HintEditSheetContent( + isAdd = state.sheetType == HintSheetType.Add, + editData = state.editData, + uploadingImages = state.uploadingImages, + uploadProgress = state.uploadProgress, + onCodeChange = onCodeChange, + onContentsChange = onContentsChange, + onAnswerChange = onAnswerChange, + onProgressChange = onProgressChange, + onCancel = onHideSheet, + onSave = onSaveHint, + onAddHintImages = onAddHintImages, + onAddAnswerImages = onAddAnswerImages, + onRemoveHintImage = onRemoveHintImage, + onRemoveAnswerImage = onRemoveAnswerImage, + ) + } + } +} + +@Composable +private fun HintItem( + hint: Hint, + onHintClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onHintClick) + .padding(horizontal = 20.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(6.dp)) + .background(NRColor.Blue15) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text( + text = hint.code, + style = NRTypo.Body.size14Medium, + color = NRColor.Blue, + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = hint.description.ifBlank { stringResource(R.string.hint_manage_content_empty) }, + style = NRTypo.Body.size14Regular, + color = NRColor.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (hint.answer.isNotBlank()) { + Text( + text = stringResource(R.string.hint_manage_answer_format, hint.answer), + style = NRTypo.Body.size12Regular, + color = NRColor.Gray01, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = NRColor.Gray01, + modifier = Modifier.size(20.dp), + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + LinearProgressIndicator( + progress = { hint.progress / 100f }, + modifier = Modifier + .weight(1f) + .height(4.dp) + .clip(RoundedCornerShape(2.dp)), + color = NRColor.Blue, + trackColor = NRColor.Gray03, + ) + Text( + text = "${hint.progress}%", + style = NRTypo.Body.size12Regular, + color = NRColor.Gray01, + modifier = Modifier.padding(start = 8.dp) + ) + } + } +} + +@Composable +private fun HintEditSheetContent( + isAdd: Boolean, + editData: HintEditData, + uploadingImages: Boolean, + uploadProgress: UploadProgress?, + onCodeChange: (String) -> Unit, + onContentsChange: (String) -> Unit, + onAnswerChange: (String) -> Unit, + onProgressChange: (Int?) -> Unit, + onCancel: () -> Unit, + onSave: () -> Unit, + onAddHintImages: () -> Unit, + onAddAnswerImages: () -> Unit, + onRemoveHintImage: (Int) -> Unit, + onRemoveAnswerImage: (Int) -> Unit, +) { + val title = if (isAdd) stringResource(R.string.hint_manage_add) + else stringResource(R.string.hint_manage_edit) + + val isSaveEnabled = editData.code.length == 4 + && editData.contents.isNotBlank() + && editData.answer.isNotBlank() + && !uploadingImages + + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .imePadding() + ) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 24.dp, bottom = 36.dp), + ) { + Text( + text = title, + style = NRTypo.Pretendard.size18SemiBold, + color = NRColor.White, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + SheetTextField( + label = stringResource(R.string.hint_manage_field_code), + value = editData.code, + onValueChange = onCodeChange, + keyboardType = KeyboardType.Number, + placeholder = stringResource(R.string.hint_manage_field_code_placeholder), + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SheetTextField( + label = stringResource(R.string.hint_manage_field_contents), + value = editData.contents, + onValueChange = onContentsChange, + singleLine = false, + minLines = 3, + maxLines = 4, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + ImageSection( + label = stringResource(R.string.hint_manage_field_hint_image), + imageUris = editData.hintImageUris, + imageUrls = editData.hintImageUrls, + failedUriIndices = editData.failedHintImageUriIndices, + onAddImages = onAddHintImages, + onRemoveImage = onRemoveHintImage, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SheetTextField( + label = stringResource(R.string.hint_manage_field_answer), + value = editData.answer, + onValueChange = onAnswerChange, + singleLine = false, + minLines = 2, + maxLines = 3, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + ImageSection( + label = stringResource(R.string.hint_manage_field_answer_image), + imageUris = editData.answerImageUris, + imageUrls = editData.answerImageUrls, + failedUriIndices = editData.failedAnswerImageUriIndices, + onAddImages = onAddAnswerImages, + onRemoveImage = onRemoveAnswerImage, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SheetTextField( + label = stringResource(R.string.hint_manage_field_progress), + value = editData.progress?.toString() ?: "", + onValueChange = { input -> + val filtered = input.filter { it.isDigit() } + when { + filtered.isEmpty() -> onProgressChange(null) + else -> { + val value = filtered.toIntOrNull() + if (value != null && value in 0..100) { + onProgressChange(value) + } + // 100 초과 시 입력 무시 + } + } + }, + keyboardType = KeyboardType.Number, + placeholder = "", + ) + + Spacer(modifier = Modifier.height(24.dp)) + + if (uploadingImages && uploadProgress != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource( + R.string.hint_manage_image_uploading, + uploadProgress.current, + uploadProgress.total + ), + style = NRTypo.Body.size12Regular, + color = NRColor.Gray01, + ) + LinearProgressIndicator( + progress = { uploadProgress.current.toFloat() / uploadProgress.total }, + modifier = Modifier.fillMaxWidth(), + color = NRColor.Blue, + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextButton( + onClick = onCancel, + modifier = Modifier.weight(1f), + enabled = !uploadingImages, + ) { + Text(text = stringResource(R.string.text_cancel), color = NRColor.Gray01) + } + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background(if (isSaveEnabled) NRColor.Blue else NRColor.Gray02) + .clickable(enabled = isSaveEnabled, onClick = onSave) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.save), + style = NRTypo.Pretendard.size16SemiBold, + color = NRColor.White, + ) + } + } + } + } + } +} + +@Composable +private fun ImageSection( + label: String, + imageUris: List, + imageUrls: List, + failedUriIndices: Set, + onAddImages: () -> Unit, + onRemoveImage: (Int) -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = label, + style = NRTypo.Body.size12Regular, + color = NRColor.Gray01, + ) + + if (imageUris.isNotEmpty() || imageUrls.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth() + ) { + itemsIndexed(imageUris) { index, uri -> + ImageThumbnail( + imageUri = uri, + isFailed = index in failedUriIndices, + onRemove = { onRemoveImage(index) } + ) + } + itemsIndexed(imageUrls) { index, url -> + ImageThumbnail( + imageUrl = url, + onRemove = { onRemoveImage(imageUris.size + index) } + ) + } + if (imageUris.size + imageUrls.size < 5) { + item { + AddImageButton(onClick = onAddImages) + } + } + } + } else { + AddImageButton(onClick = onAddImages) + } + } +} + +@Composable +private fun ImageThumbnail( + imageUri: Uri? = null, + imageUrl: String? = null, + isFailed: Boolean = false, + onRemove: () -> Unit, +) { + Box(modifier = Modifier.size(80.dp)) { + AsyncImage( + model = imageUri ?: imageUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + .background(NRColor.Gray03), + contentScale = ContentScale.Crop, + ) + + if (isFailed) { + Box( + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(8.dp)) + .background(NRColor.Red.copy(alpha = 0.6f)), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = NRColor.White, + modifier = Modifier.size(28.dp), + ) + } + } + + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = 6.dp, y = (-6).dp) + .size(20.dp) + .clip(CircleShape) + .background(NRColor.Dark01.copy(alpha = 0.95f)) + .clickable(onClick = onRemove), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = null, + tint = NRColor.White, + modifier = Modifier.size(12.dp), + ) + } + } +} + +@Composable +private fun AddImageButton(onClick: () -> Unit) { + Box( + modifier = Modifier + .size(80.dp) + .clip(RoundedCornerShape(8.dp)) + .background(NRColor.Gray03) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = NRColor.Gray01, + modifier = Modifier.size(24.dp) + ) + Text( + text = stringResource(R.string.hint_manage_image_add_button), + style = NRTypo.Body.size12Regular, + color = NRColor.Gray01, + ) + } + } +} + +@Composable +private fun SheetTextField( + label: String, + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + keyboardType: KeyboardType = KeyboardType.Text, + singleLine: Boolean = true, + minLines: Int = 1, + maxLines: Int = Int.MAX_VALUE, + placeholder: String = "", +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = label, + style = NRTypo.Body.size12Regular, + color = NRColor.Gray01, + ) + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth(), + textStyle = NRTypo.Body.size14Regular.copy(color = NRColor.White), + singleLine = singleLine, + minLines = minLines, + maxLines = maxLines, + shape = RoundedCornerShape(8.dp), + keyboardOptions = KeyboardOptions(keyboardType = keyboardType), + placeholder = if (placeholder.isNotEmpty()) { + { + Text( + text = placeholder, + color = NRColor.Gray02, + style = NRTypo.Body.size14Regular + ) + } + } else null, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = NRColor.Blue, + unfocusedBorderColor = NRColor.Gray02, + focusedContainerColor = NRColor.Sub1, + unfocusedContainerColor = NRColor.Sub1, + cursorColor = NRColor.Blue, + ), + ) + } +} + +// ==================== Previews ==================== + +@Preview(name = "힌트 관리 - 비어있음", showBackground = true) +@Composable +private fun HintManageScreenEmptyPreview() { + HintManageScreen( + themeTitle = "범인 찾기", + state = HintManageUiState.Loaded( + hints = emptyList(), + isLoading = false, + sortType = HintSortType.PROGRESS, + uploadingImages = false, + uploadProgress = null, + editData = HintEditData(), + sheetType = HintSheetType.None, + ), + onBackClick = {}, + onAddClick = {}, + onHintClick = {}, + onDeleteClick = {}, + onHideSheet = {}, + onCodeChange = {}, + onContentsChange = {}, + onAnswerChange = {}, + onProgressChange = {}, + onSaveHint = {}, + onAddHintImages = {}, + onAddAnswerImages = {}, + onRemoveHintImage = {}, + onRemoveAnswerImage = {}, + onSortTypeChange = {}, + ) +} + +@Preview(name = "힌트 관리 - 힌트 목록", showBackground = true) +@Composable +private fun HintManageScreenWithDataPreview() { + HintManageScreen( + themeTitle = "범인 찾기", + state = HintManageUiState.Loaded( + hints = listOf( + Hint( + id = 1, + code = "1234", + description = "서랍 안을 살펴보세요. 열쇠가 숨겨져 있습니다.", + answer = "책상 서랍", + progress = 25, + hintImageUrlList = emptyList(), + answerImageUrlList = emptyList(), + ), + Hint( + id = 2, + code = "5678", + description = "벽에 걸린 그림을 자세히 관찰하세요.", + answer = "", + progress = 50, + hintImageUrlList = emptyList(), + answerImageUrlList = emptyList(), + ), + Hint( + id = 3, + code = "9012", + description = "책장에서 빨간색 책을 찾아보세요. 그 안에 단서가 있습니다.", + answer = "1945년", + progress = 75, + hintImageUrlList = emptyList(), + answerImageUrlList = emptyList(), + ), + ), + isLoading = false, + sortType = HintSortType.PROGRESS, + uploadingImages = false, + uploadProgress = null, + editData = HintEditData(), + sheetType = HintSheetType.None, + ), + onBackClick = {}, + onAddClick = {}, + onHintClick = {}, + onDeleteClick = {}, + onHideSheet = {}, + onCodeChange = {}, + onContentsChange = {}, + onAnswerChange = {}, + onProgressChange = {}, + onSaveHint = {}, + onAddHintImages = {}, + onAddAnswerImages = {}, + onRemoveHintImage = {}, + onRemoveAnswerImage = {}, + onSortTypeChange = {}, + ) +} + +@Preview(name = "힌트 아이템", showBackground = true) +@Composable +private fun HintItemPreview() { + Box( + modifier = Modifier + .fillMaxWidth() + .background(NRColor.Dark01) + ) { + Column { + HintItem( + hint = Hint( + id = 1, + code = "1234", + description = "서랍 안을 살펴보세요. 열쇠가 숨겨져 있습니다.", + answer = "책상 서랍", + progress = 25, + hintImageUrlList = emptyList(), + answerImageUrlList = emptyList(), + ), + onHintClick = {}, + onDeleteClick = {}, + ) + HorizontalDivider(color = NRColor.Gray03) + HintItem( + hint = Hint( + id = 2, + code = "5678", + description = "벽에 걸린 그림을 자세히 관찰하세요.", + answer = "", + progress = 50, + hintImageUrlList = emptyList(), + answerImageUrlList = emptyList(), + ), + onHintClick = {}, + onDeleteClick = {}, + ) + HorizontalDivider(color = NRColor.Gray03) + HintItem( + hint = Hint( + id = 3, + code = "9012", + description = "", + answer = "", + progress = 0, + hintImageUrlList = emptyList(), + answerImageUrlList = emptyList(), + ), + onHintClick = {}, + onDeleteClick = {}, + ) + } + } +} + +@Preview(name = "힌트 추가 바텀시트", showBackground = true) +@Composable +private fun HintEditSheetAddPreview() { + Box( + modifier = Modifier + .fillMaxWidth() + .background(NRColor.Sub1) + ) { + HintEditSheetContent( + isAdd = true, + editData = HintEditData(code = "12", contents = "서랍 안을 살펴보세요", progress = 25), + uploadingImages = false, + uploadProgress = null, + onCodeChange = {}, + onContentsChange = {}, + onAnswerChange = {}, + onProgressChange = {}, + onCancel = {}, + onSave = {}, + onAddHintImages = {}, + onAddAnswerImages = {}, + onRemoveHintImage = {}, + onRemoveAnswerImage = {}, + ) + } +} + +@Preview(name = "힌트 수정 바텀시트", showBackground = true) +@Composable +private fun HintEditSheetEditPreview() { + Box( + modifier = Modifier + .fillMaxWidth() + .background(NRColor.Sub1) + ) { + HintEditSheetContent( + isAdd = false, + editData = HintEditData( + code = "1234", + contents = "서랍 안을 살펴보세요. 열쇠가 숨겨져 있습니다.", + answer = "책상 서랍", + progress = 25, + ), + uploadingImages = false, + uploadProgress = null, + onCodeChange = {}, + onContentsChange = {}, + onAnswerChange = {}, + onProgressChange = {}, + onCancel = {}, + onSave = {}, + onAddHintImages = {}, + onAddAnswerImages = {}, + onRemoveHintImage = {}, + onRemoveAnswerImage = {}, + ) + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeEditSheetContent.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeEditSheetContent.kt new file mode 100644 index 00000000..3e005379 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeEditSheetContent.kt @@ -0,0 +1,164 @@ +package com.nextroom.nextroom.presentation.ui.manage.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NROutlinedTextField +import com.nextroom.nextroom.presentation.common.compose.NRTypo + +@Composable +fun ThemeEditSheetContent( + isAdd: Boolean, + editingState: ThemeEditingState, + onTitleChange: (String) -> Unit, + onTimeLimitChange: (String?) -> Unit, + onHintLimitChange: (String?) -> Unit, + onCancel: () -> Unit, + onSave: () -> Unit, +) { + val title = if (isAdd) stringResource(R.string.theme_manage_add) + else stringResource(R.string.theme_manage_edit) + + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + .padding(horizontal = 24.dp) + .padding(bottom = 36.dp), + ) { + Text( + text = title, + style = NRTypo.Pretendard.size18SemiBold, + color = NRColor.White, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + NROutlinedTextField( + label = stringResource(R.string.theme_manage_field_name), + value = editingState.title, + onValueChange = onTitleChange, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + NROutlinedTextField( + label = stringResource(R.string.theme_manage_field_time_limit), + value = editingState.timeLimit?.toString() ?: "", + onValueChange = { onTimeLimitChange(it) }, + keyboardType = KeyboardType.Number + ) + + Spacer(modifier = Modifier.height(16.dp)) + + NROutlinedTextField( + label = stringResource(R.string.theme_manage_field_hint_limit), + value = editingState.hintLimit?.toString() ?: "", + onValueChange = onHintLimitChange, + keyboardType = KeyboardType.Number + ) + + Spacer(modifier = Modifier.height(32.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + TextButton( + onClick = onCancel, + modifier = Modifier.weight(1f), + ) { + Text(text = stringResource(R.string.text_cancel), color = NRColor.Gray01) + } + Box( + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(8.dp)) + .background( + if (editingState.title.isBlank() || editingState.timeLimit == null || editingState.hintLimit == null) + NRColor.Gray02 + else + NRColor.Blue + ) + .clickable( + enabled = editingState.title.isNotBlank() && editingState.timeLimit != null && editingState.hintLimit != null, + onClick = onSave + ) + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.save), + style = NRTypo.Pretendard.size16SemiBold, + color = NRColor.White, + ) + } + } + } +} + +@Preview(name = "테마 추가 바텀시트", showBackground = true) +@Composable +private fun ThemeEditSheetAddPreview() { + Box( + modifier = Modifier + .fillMaxWidth() + .background(NRColor.Sub1) + ) { + ThemeEditSheetContent( + isAdd = true, + editingState = ThemeEditingState(), + onTitleChange = {}, + onTimeLimitChange = {}, + onHintLimitChange = {}, + onCancel = {}, + onSave = {}, + ) + } +} + +@Preview(name = "테마 수정 바텀시트", showBackground = true) +@Composable +private fun ThemeEditSheetEditPreview() { + Box( + modifier = Modifier + .fillMaxWidth() + .background(NRColor.Sub1) + ) { + ThemeEditSheetContent( + isAdd = false, + editingState = ThemeEditingState( + themeId = 1, + title = "범인 찾기", + timeLimit = 60, + hintLimit = 5, + ), + onTitleChange = {}, + onTimeLimitChange = {}, + onHintLimitChange = {}, + onCancel = {}, + onSave = {}, + ) + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageFragment.kt new file mode 100644 index 00000000..7eabcd33 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageFragment.kt @@ -0,0 +1,115 @@ +package com.nextroom.nextroom.presentation.ui.manage.theme + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf +import androidx.fragment.app.setFragmentResultListener +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.nextroom.nextroom.domain.model.ThemeInfo +import com.nextroom.nextroom.presentation.NavGraphDirections +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.base.ComposeBaseViewModelFragment +import com.nextroom.nextroom.presentation.common.NRTwoButtonDialog +import com.nextroom.nextroom.presentation.common.compose.NRLoading +import com.nextroom.nextroom.presentation.extension.BUNDLE_KEY_RESULT_DATA +import com.nextroom.nextroom.presentation.extension.getResultData +import com.nextroom.nextroom.presentation.extension.hasResultData +import com.nextroom.nextroom.presentation.extension.safeNavigate +import com.nextroom.nextroom.presentation.extension.snackbar +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class ThemeManageFragment : ComposeBaseViewModelFragment() { + + override val screenName: String = "theme_manage" + override val viewModel: ThemeManageViewModel by viewModels() + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View { + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val uiState by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.uiEvent.collect { event -> + when (event) { + is ThemeManageEvent.ThemeSaved -> snackbar(R.string.theme_manage_save_success) + is ThemeManageEvent.ThemeDeleted -> snackbar(R.string.theme_manage_delete_success) + } + } + } + + when (val state = uiState) { + is ThemeManageUiState.Loading -> NRLoading(isVisible = true) + is ThemeManageUiState.Loaded -> ThemeManageScreen( + state = state, + onBackClick = { findNavController().navigateUp() }, + onAddClick = viewModel::showAddSheet, + onThemeClick = { moveToHintManage(it) }, + onEditClick = viewModel::showEditSheet, + onDeleteClick = { showDeleteConfirmDialog(it) }, + onHideSheet = viewModel::hideSheet, + onTitleChange = viewModel::updateTitle, + onTimeLimitChange = viewModel::updateTimeLimit, + onHintLimitChange = viewModel::updateHintLimit, + onSaveTheme = viewModel::saveTheme, + ) + } + } + } + } + + override fun initSubscribe() { + // Event handling is done in Compose LaunchedEffect + } + + override fun setFragmentResultListeners() { + setFragmentResultListener(DIALOG_KEY_DELETE_THEME) { _, bundle -> + if (bundle.hasResultData()) { + bundle.getResultData()?.toIntOrNull()?.let { themeId -> + viewModel.confirmDelete(themeId) + } + } + } + } + + private fun showDeleteConfirmDialog(theme: ThemeInfo) { + NavGraphDirections.moveToNrTwoButtonDialog( + NRTwoButtonDialog.NRTwoButtonArgument( + title = "'${theme.title}' ${getString(R.string.theme_manage_delete_confirm)}", + message = getString(R.string.theme_manage_delete_confirm_desc), + posBtnText = getString(R.string.text_delete), + negBtnText = getString(R.string.text_cancel), + dialogKey = DIALOG_KEY_DELETE_THEME, + bundle = bundleOf(BUNDLE_KEY_RESULT_DATA to theme.id.toString()) + ) + ).also { + findNavController().safeNavigate(it) + } + } + + fun moveToHintManage(themeInfo: ThemeInfo) { + ThemeManageFragmentDirections.moveToHintManageFragment( + themeId = themeInfo.id, + themeTitle = themeInfo.title, + ).also { + findNavController().safeNavigate(it) + } + } + + companion object { + private const val DIALOG_KEY_DELETE_THEME = "DIALOG_KEY_DELETE_THEME" + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageScreen.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageScreen.kt new file mode 100644 index 00000000..f4846dfd --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageScreen.kt @@ -0,0 +1,332 @@ +package com.nextroom.nextroom.presentation.ui.manage.theme + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.nextroom.nextroom.domain.model.ThemeInfo +import com.nextroom.nextroom.presentation.R +import com.nextroom.nextroom.presentation.common.compose.NRColor +import com.nextroom.nextroom.presentation.common.compose.NRLoading +import com.nextroom.nextroom.presentation.common.compose.NRTypo + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ThemeManageScreen( + state: ThemeManageUiState.Loaded, + onBackClick: () -> Unit, + onAddClick: () -> Unit, + onThemeClick: (ThemeInfo) -> Unit, + onEditClick: (ThemeInfo) -> Unit, + onDeleteClick: (ThemeInfo) -> Unit, + onHideSheet: () -> Unit, + onTitleChange: (String) -> Unit, + onTimeLimitChange: (String?) -> Unit, + onHintLimitChange: (String?) -> Unit, + onSaveTheme: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + Scaffold( + topBar = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(64.dp) + .background(NRColor.Dark01), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(R.string.theme_manage_title), + style = NRTypo.Pretendard.size18SemiBold, + color = NRColor.White, + textAlign = TextAlign.Center, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = onBackClick, + modifier = Modifier + .size(64.dp) + .padding(20.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = null, + tint = NRColor.White, + ) + } + + IconButton( + onClick = onAddClick, + modifier = Modifier + .size(64.dp) + .padding(20.dp) + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = null, + tint = NRColor.White, + ) + } + } + } + }, + containerColor = NRColor.Dark01, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + if (state.themes.isEmpty()) { + Text( + text = stringResource(R.string.theme_manage_empty_guide), + style = NRTypo.Body.size14Regular, + color = NRColor.Gray01, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.Center) + .padding(horizontal = 24.dp), + lineHeight = 22.sp, + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + item { + InfoBox( + modifier = Modifier + .fillMaxWidth() + .padding(all = 20.dp) + ) + } + items(state.themes, key = { it.id }) { theme -> + ThemeItem( + theme = theme, + onThemeClick = { onThemeClick(theme) }, + onEditClick = { onEditClick(theme) }, + onDeleteClick = { onDeleteClick(theme) }, + ) + HorizontalDivider( + modifier = Modifier.padding(top = 16.dp), + color = NRColor.Gray03 + ) + } + } + } + + NRLoading(state.isLoading) + } + } + + if (state.sheetType != ThemeSheetType.None) { + ModalBottomSheet( + onDismissRequest = onHideSheet, + sheetState = sheetState, + containerColor = NRColor.Sub1, + ) { + ThemeEditSheetContent( + isAdd = state.sheetType == ThemeSheetType.Add, + editingState = state.editingState, + onTitleChange = onTitleChange, + onTimeLimitChange = onTimeLimitChange, + onHintLimitChange = onHintLimitChange, + onCancel = onHideSheet, + onSave = onSaveTheme, + ) + } + } +} + +@Composable +private fun InfoBox(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(NRColor.Blue15) + .padding(16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringResource(R.string.theme_manage_bullet_point), + style = NRTypo.Pretendard.size14, + color = NRColor.Blue, + ) + Text( + text = stringResource(R.string.text_theme_manage_web_info), + style = NRTypo.Body.size12Regular, + color = NRColor.Blue, + ) + } + } +} + +@Composable +private fun ThemeItem( + theme: ThemeInfo, + onThemeClick: () -> Unit, + onEditClick: () -> Unit, + onDeleteClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onThemeClick) + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = theme.title, + style = NRTypo.Pretendard.size16SemiBold, + color = NRColor.White, + ) + Text( + text = buildString { + append( + stringResource( + R.string.theme_manage_time_format, + theme.timeLimitInMinute + ) + ) + append(" · ") + append(stringResource(R.string.theme_manage_hint_count, theme.hintLimit)) + }, + style = NRTypo.Body.size12Regular, + color = NRColor.Gray01, + ) + } + IconButton(onClick = onEditClick) { + Icon( + imageVector = Icons.Default.Edit, + contentDescription = null, + tint = NRColor.Gray01, + modifier = Modifier.size(20.dp), + ) + } + IconButton(onClick = onDeleteClick) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = null, + tint = NRColor.Gray01, + modifier = Modifier.size(20.dp), + ) + } + Icon( + painter = painterResource(R.drawable.ic_navigate_next), + contentDescription = null, + tint = NRColor.Gray02, + modifier = Modifier.size(20.dp), + ) + } +} + +// ==================== Previews ==================== + +@Preview(name = "테마 관리 - 비어있음", showBackground = true) +@Composable +private fun ThemeManageScreenEmptyPreview() { + ThemeManageScreen( + state = ThemeManageUiState.Loaded( + themes = emptyList(), + isLoading = false, + ), + onBackClick = {}, + onAddClick = {}, + onThemeClick = {}, + onEditClick = {}, + onDeleteClick = {}, + onHideSheet = {}, + onTitleChange = {}, + onTimeLimitChange = {}, + onHintLimitChange = {}, + onSaveTheme = {}, + ) +} + +@Preview(name = "테마 관리 - 테마 목록", showBackground = true) +@Composable +private fun ThemeManageScreenWithDataPreview() { + ThemeManageScreen( + state = ThemeManageUiState.Loaded( + themes = listOf( + ThemeInfo( + id = 1, + title = "범인 찾기", + timeLimitInMinute = 60, + hintLimit = 5, + ), + ThemeInfo( + id = 2, + title = "보물 찾기 모험", + timeLimitInMinute = 45, + hintLimit = -1, + ), + ThemeInfo( + id = 3, + title = "탈출 게임", + timeLimitInMinute = 90, + hintLimit = 3, + ), + ), + isLoading = false, + ), + onBackClick = {}, + onAddClick = {}, + onThemeClick = {}, + onEditClick = {}, + onDeleteClick = {}, + onHideSheet = {}, + onTitleChange = {}, + onTimeLimitChange = {}, + onHintLimitChange = {}, + onSaveTheme = {}, + ) +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageState.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageState.kt new file mode 100644 index 00000000..9a8d9466 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageState.kt @@ -0,0 +1,27 @@ +package com.nextroom.nextroom.presentation.ui.manage.theme + +import com.nextroom.nextroom.domain.model.ThemeInfo + +data class ThemeEditingState( + val themeId: Int? = null, + val title: String = "", + val timeLimit: Int? = null, + val hintLimit: Int? = null, +) + +sealed interface ThemeManageUiState { + data object Loading : ThemeManageUiState + data class Loaded( + val themes: List, + val isLoading: Boolean, + val sheetType: ThemeSheetType = ThemeSheetType.None, + val editingState: ThemeEditingState = ThemeEditingState(), + ) : ThemeManageUiState +} + +enum class ThemeSheetType { None, Add, Edit } + +sealed interface ThemeManageEvent { + data object ThemeSaved : ThemeManageEvent + data object ThemeDeleted : ThemeManageEvent +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageViewModel.kt new file mode 100644 index 00000000..01b09040 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/manage/theme/ThemeManageViewModel.kt @@ -0,0 +1,153 @@ +package com.nextroom.nextroom.presentation.ui.manage.theme + +import com.nextroom.nextroom.domain.model.ThemeInfo +import com.nextroom.nextroom.domain.model.onSuccess +import com.nextroom.nextroom.domain.repository.ThemeRepository +import com.nextroom.nextroom.domain.request.AddThemeRequest +import com.nextroom.nextroom.domain.request.EditThemeRequest +import com.nextroom.nextroom.presentation.base.NewBaseViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ThemeManageViewModel @Inject constructor( + private val themeRepository: ThemeRepository, +) : NewBaseViewModel() { + + private val _themes = MutableStateFlow?>(null) + private val _isLoading = MutableStateFlow(false) + private val _sheetType = MutableStateFlow(ThemeSheetType.None) + private val _editingState = MutableStateFlow(ThemeEditingState()) + + val uiState = combine( + _themes, + _isLoading, + _sheetType, + _editingState, + ) { themes, isLoading, sheetType, editingState -> + if (themes == null) { + ThemeManageUiState.Loading + } else { + ThemeManageUiState.Loaded( + themes = themes, + isLoading = isLoading, + sheetType = sheetType, + editingState = editingState, + ) + } + }.stateIn(baseViewModelScope, SharingStarted.Lazily, ThemeManageUiState.Loading) + + private val _uiEvent = MutableSharedFlow(extraBufferCapacity = 1) + val uiEvent = _uiEvent.asSharedFlow() + + init { + loadThemes() + } + + fun loadThemes() { + baseViewModelScope.launch { + try { + _isLoading.emit(true) + themeRepository.getThemes().getOrThrow.also { themes -> + _themes.emit(themes) + } + } catch (e: Exception) { + handleError(e) + } finally { + _isLoading.emit(false) + } + } + } + + fun showAddSheet() { + _editingState.value = ThemeEditingState() + _sheetType.value = ThemeSheetType.Add + } + + fun showEditSheet(theme: ThemeInfo) { + _editingState.value = ThemeEditingState( + themeId = theme.id, + title = theme.title, + timeLimit = theme.timeLimitInMinute, + hintLimit = if (theme.hintLimit == -1) null else theme.hintLimit, + ) + _sheetType.value = ThemeSheetType.Edit + } + + fun hideSheet() { + _sheetType.value = ThemeSheetType.None + } + + fun updateTitle(title: String) { + _editingState.update { it.copy(title = title) } + } + + fun updateTimeLimit(timeLimit: String?) { + _editingState.update { it.copy(timeLimit = timeLimit?.toIntOrNull()) } + } + + fun updateHintLimit(hintLimit: String?) { + _editingState.update { it.copy(hintLimit = hintLimit?.toIntOrNull()) } + } + + fun saveTheme() { + _sheetType.value = ThemeSheetType.None + val editing = _editingState.value + baseViewModelScope.launch { + try { + _isLoading.emit(true) + val result = if (editing.themeId == null) { + themeRepository.addTheme( + AddThemeRequest( + title = editing.title, + timeLimit = requireNotNull(editing.timeLimit), + hintLimit = requireNotNull(editing.hintLimit), + ) + ) + } else { + themeRepository.editTheme( + EditThemeRequest( + id = editing.themeId, + title = editing.title, + timeLimit = requireNotNull(editing.timeLimit), + hintLimit = requireNotNull(editing.hintLimit), + ) + ) + } + result + .onSuccess { + loadThemes() + _uiEvent.emit(ThemeManageEvent.ThemeSaved) + } + } catch (e: Exception) { + handleError(e) + } finally { + _isLoading.emit(false) + } + } + } + + fun confirmDelete(themeId: Int) { + baseViewModelScope.launch { + try { + _isLoading.emit(true) + themeRepository.deleteTheme(themeId).onSuccess { + _themes.update { current -> current?.filter { it.id != themeId } } + _uiEvent.emit(ThemeManageEvent.ThemeDeleted) + } + } catch (e: Exception) { + handleError(e) + } finally { + _isLoading.emit(false) + } + } + } +} diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectEvent.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectEvent.kt index 1476b559..2b53b170 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectEvent.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectEvent.kt @@ -11,5 +11,6 @@ sealed interface ThemeSelectEvent { data class ReadyToGameStart(val subscribeStatus: SubscribeStatus) : ThemeSelectEvent data object NeedToSetPassword : ThemeSelectEvent data class NeedToCheckPasswordForStartGame(val themeId: String) : ThemeSelectEvent + data object NeedToCheckPasswordForManageThemes : ThemeSelectEvent data object GuidePopupNotSeen : ThemeSelectEvent } diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectFragment.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectFragment.kt index 4efe78bc..8b8e29bc 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectFragment.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectFragment.kt @@ -85,6 +85,7 @@ class ThemeSelectFragment : private fun initSubscribe() { setFragmentResultListener(requestKeyCheckPassword, ::handleFragmentResults) + setFragmentResultListener(requestKeyCheckPasswordForManageThemes, ::handleFragmentResults) setFragmentResultListener(dialogKeyNeedToSetPassword, ::handleFragmentResults) setFragmentResultListener(SHOW_USAGE_GUIDE_DIALOG_KEY, ::handleFragmentResults) } @@ -104,6 +105,14 @@ class ThemeSelectFragment : } } + requestKeyCheckPasswordForManageThemes -> { + if (bundle.hasResultData()) { + findNavController().safeNavigate( + ThemeSelectFragmentDirections.moveToThemeManageFragment() + ) + } + } + dialogKeyNeedToSetPassword -> moveToSetPassword() SHOW_USAGE_GUIDE_DIALOG_KEY -> { try { @@ -160,6 +169,9 @@ class ThemeSelectFragment : FirebaseAnalytics.getInstance(requireContext()).logEvent("btn_click", bundleOf("btn_name" to "banner")) } + tvManageThemes.setOnClickListener { + viewModel.onManageThemesClicked() + } tvBacgroundSetting.setOnClickListener { findNavController().safeNavigate( ThemeSelectFragmentDirections.moveToBackgroundCustomFragment( @@ -277,6 +289,7 @@ class ThemeSelectFragment : is ThemeSelectEvent.ReadyToGameStart -> moveToGameStart(event.subscribeStatus) ThemeSelectEvent.NeedToSetPassword -> showNeedToSetPasswordDialog() is ThemeSelectEvent.NeedToCheckPasswordForStartGame -> moveToCheckPasswordForGameStart(event.themeId) + ThemeSelectEvent.NeedToCheckPasswordForManageThemes -> moveToCheckPasswordForManageThemes() ThemeSelectEvent.RecommendBackgroundCustom -> showRecommendBackgroundCustomBottomSheet() ThemeSelectEvent.GuidePopupNotSeen -> showSuggestGuidePopup() } @@ -352,6 +365,15 @@ class ThemeSelectFragment : .also { findNavController().safeNavigate(it) } } + private fun moveToCheckPasswordForManageThemes() { + NavGraphDirections + .moveToCheckPassword( + requestKey = requestKeyCheckPasswordForManageThemes, + resultData = "" + ) + .also { findNavController().safeNavigate(it) } + } + private fun showSuggestGuidePopup() { NavGraphDirections.moveToNrTwoButtonDialog( NRTwoButtonDialog.NRTwoButtonArgument( @@ -391,6 +413,8 @@ class ThemeSelectFragment : companion object { private const val requestKeyCheckPassword = "requestKeyCheckPassword" + private const val requestKeyCheckPasswordForManageThemes = + "requestKeyCheckPasswordForManageThemes" private const val dialogKeyNeedToSetPassword = "dialogKeyNeedToSetPassword" private const val SHOW_USAGE_GUIDE_DIALOG_KEY = "SHOW_USAGE_GUIDE_DIALOG_KEY" private const val AUTO_SCROLL_INTERVAL_TIME = 3500L diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectViewModel.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectViewModel.kt index 44f7b09a..a48bffbf 100644 --- a/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectViewModel.kt +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/ui/theme_select/ThemeSelectViewModel.kt @@ -190,6 +190,19 @@ class ThemeSelectViewModel @Inject constructor( } } + fun onManageThemesClicked() { + baseViewModelScope.launch { + intent { + checkNeedToSetPassword() + if (adminRepository.getAppPassword().isEmpty()) { + ThemeSelectEvent.NeedToSetPassword + } else { + ThemeSelectEvent.NeedToCheckPasswordForManageThemes + }.also { postSideEffect(it) } + } + } + } + fun onThemeRefreshClicked() = intent { reduce { state.copy(loading = true) } getThemes() diff --git a/presentation/src/main/java/com/nextroom/nextroom/presentation/util/ImageUtil.kt b/presentation/src/main/java/com/nextroom/nextroom/presentation/util/ImageUtil.kt new file mode 100644 index 00000000..fcb94550 --- /dev/null +++ b/presentation/src/main/java/com/nextroom/nextroom/presentation/util/ImageUtil.kt @@ -0,0 +1,78 @@ +package com.nextroom.nextroom.presentation.util + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import java.io.File +import java.io.FileOutputStream + +object ImageUtil { + const val MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024 // 10MB + + fun uriToFile(context: Context, uri: Uri): File? { + return try { + val contentResolver = context.contentResolver + val fileName = getFileName(context, uri) ?: "temp_${System.currentTimeMillis()}.jpg" + val tempFile = File(context.cacheDir, fileName) + + contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } + tempFile + } catch (e: Exception) { + null + } + } + + private fun getFileName(context: Context, uri: Uri): String? { + var result: String? = null + + // Content Provider에게 파일명 물어보기 + if (uri.scheme == "content") { + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val index = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (index >= 0) { + result = cursor.getString(index) + } + } + } + } + + // Content Provider를 통해 파일명을 알아내지 못했을 경우 URI 경로에서 직접 추출 + if (result == null) { + result = uri.path?.let { path -> + val cut = path.lastIndexOf('/') + if (cut != -1) path.substring(cut + 1) else path + } + } + return result + } + + fun getMimeType(context: Context, uri: Uri): String { + return if (uri.scheme == "content") { + context.contentResolver.getType(uri) ?: "image/jpeg" + } else { + val extension = MimeTypeMap.getFileExtensionFromUrl(uri.toString()) + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "image/jpeg" + } + } + + fun validateImageSize(context: Context, uri: Uri): Boolean { + return try { + val fileSize = context.contentResolver.openFileDescriptor(uri, "r")?.use { + it.statSize + } ?: 0 + fileSize <= MAX_IMAGE_SIZE_BYTES + } catch (e: Exception) { + false + } + } + + fun isValidImageFormat(mimeType: String): Boolean { + return mimeType in listOf("image/jpeg", "image/jpg", "image/png", "image/webp") + } +} diff --git a/presentation/src/main/res/layout/fragment_theme_select.xml b/presentation/src/main/res/layout/fragment_theme_select.xml index bf7200fb..f38b1eac 100644 --- a/presentation/src/main/res/layout/fragment_theme_select.xml +++ b/presentation/src/main/res/layout/fragment_theme_select.xml @@ -41,7 +41,6 @@ android:layout_height="wrap_content" android:layout_marginTop="12dp" android:layout_marginEnd="20dp" - android:contentDescription="@string/mypage_button_description" android:src="@drawable/ic_my" android:visibility="visible" app:layout_constraintEnd_toEndOf="parent" @@ -107,6 +106,20 @@ app:layout_constraintTop_toTopOf="@+id/tv_theme" tools:text="2" /> + + + + + + + + + + + + 구독 기간 구독 상태 구독 정보 - 마이페이지 이동 매장 정보 %d원 %d원/월 @@ -144,12 +143,27 @@ 테마 포스터로 타이머 배경을 커스텀 할 수 있습니다. 각 테마의 독특한 분위기를 더해 몰입감을 높여보세요. 테마 배경 설정 + 테마 관리 + 테마 관리 + 테마 추가 + 테마 수정 + 테마를 정말 삭제하시겠습니까? + 삭제된 테마는 복구할 수 없습니다. + 저장되었습니다. + 삭제되었습니다. + 힌트 관리 + 힌트 추가 + 힌트 수정 + 저장되었습니다. + 힌트를 삭제하시겠습니까? + 삭제되었습니다. 최근 힌트 업데이트 %s 최근 힌트 업데이트를 불러오지 못했습니다. 타이머 배경 설정 PC에서 배경을 등록하면 힌트폰에서 확인할 수 있습니다. 미리보기에서 이미지의 어두움와 위치를 조정할 수 있습니다. 미구독계정은 1개의 배경 이미지만 활성화할 수 있습니다. + PC에서도 관리할 수 있습니다 넥스트룸을 구독해보세요✨ 미구독계정은 1개의 배경이미지만 활성화할 수 있습니다.\n넥스트룸을 구독하면 아래 혜택을 누릴 수 있습니다. 월 ₩%s원으로 구독하기 @@ -185,11 +199,12 @@ 0 안내 - 관리자만 게임을 시작할 수 있도록\n비밀번호 설정이 필요합니다. + 관리자만 사용할 수 있도록\n비밀번호 설정이 필요합니다. 설정하기 지문 인증 지문을 입력하세요. 취소 + 삭제 지문 인증에 실패했습니다. 구독혜택 1. 힌트와 이미지를 함께 제공 @@ -250,4 +265,35 @@ 우리 매장만의 화면 커스텀 오프라인 환경 플레이 등 로그인 화면으로 돌아가기 + + + 등록된 테마가 없습니다.\n오른쪽 상단 + 버튼으로 테마를 추가하세요. + · + %d분 + 힌트 제한 %d개 + 테마 이름 + 제한 시간 (분) + 힌트 제한 개수 + + + 등록된 힌트가 없습니다.\n오른쪽 상단 + 버튼으로 힌트를 추가하세요. + 진행률순 + 힌트코드순 + (내용 없음) + 정답: %s + 힌트 코드 (4자리 숫자) + 예: 1234 + 힌트 내용 + 힌트 이미지 (선택) + 정답 + 정답 이미지 (선택) + 진행률 (0-100) + 이미지 업로드 중… (%d/%d) + 추가 + "이미지 업로드 실패" + 이미지 파일 변환 실패 + 최대 5개까지 선택 가능합니다 + 이미지 크기는 10MB 이하여야 합니다 + 지원하지 않는 이미지 형식입니다 + 구독이 필요한 기능입니다. \ No newline at end of file