From a7f4b51b46bfbd4440eb2c5e4620f08899cdf7f8 Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 31 Oct 2025 11:33:56 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[feat]:=205=EC=A3=BC=EC=B0=A8=20=EC=8B=A4?= =?UTF-8?q?=EC=8A=B5=20&=20=EB=AF=B8=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 117 ------------------------------------------------------ 1 file changed, 117 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index b18ed6b..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,117 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -KUIT6_Android_API is an Android application project using Kotlin and Gradle with the Kotlin DSL. - -**Package name**: `com.example.kuit6_android_api` - -**Build configuration**: -- Kotlin 2.0.21 -- Android Gradle Plugin 8.13.0 -- Compile SDK: 36 -- Min SDK: 28 -- Target SDK: 36 -- Java 11 (sourceCompatibility, targetCompatibility, and jvmTarget) - -## Common Commands - -### Build and Run -```bash -# Clean build -./gradlew clean - -# Build the project -./gradlew build - -# Build debug APK -./gradlew assembleDebug - -# Build release APK -./gradlew assembleRelease - -# Install debug APK to connected device/emulator -./gradlew installDebug -``` - -### Testing -```bash -# Run unit tests (in test/) -./gradlew test - -# Run unit tests for debug variant -./gradlew testDebugUnitTest - -# Run instrumented tests on connected device/emulator (in androidTest/) -./gradlew connectedAndroidTest - -# Run specific test class -./gradlew test --tests com.example.kuit6_android_api.ExampleUnitTest - -# Run specific test method -./gradlew test --tests com.example.kuit6_android_api.ExampleUnitTest.addition_isCorrect -``` - -### Code Quality -```bash -# Lint the project -./gradlew lint - -# Generate lint report -./gradlew lintDebug -``` - -### Project Info -```bash -# List all tasks -./gradlew tasks - -# Show project dependencies -./gradlew dependencies - -# Show app module dependencies -./gradlew :app:dependencies -``` - -## Project Structure - -``` -app/ -├── src/ -│ ├── main/ -│ │ ├── java/com/example/kuit6_android_api/ # Main source code -│ │ ├── res/ # Android resources -│ │ └── AndroidManifest.xml -│ ├── test/ # Unit tests (JVM) -│ └── androidTest/ # Instrumented tests (Android device) -├── build.gradle.kts # App-level build configuration -└── proguard-rules.pro # ProGuard rules for release builds - -build.gradle.kts # Project-level build configuration -settings.gradle.kts # Project settings -gradle/libs.versions.toml # Version catalog for dependencies -``` - -## Dependencies - -Dependencies are managed via the Gradle version catalog in `gradle/libs.versions.toml`. - -Current dependencies: -- AndroidX Core KTX -- AndroidX AppCompat -- Material Components -- JUnit (unit testing) -- AndroidX Test JUnit (instrumented testing) -- Espresso (UI testing) - -To add new dependencies, update `gradle/libs.versions.toml` and reference them in `app/build.gradle.kts`. - -## Architecture Notes - -This is a fresh Android project with minimal code. The main source directory (`app/src/main/java/com/example/kuit6_android_api/`) is currently empty. When implementing features: - -- Place activities, fragments, and UI components in the main package or appropriate subpackages -- Follow standard Android architecture patterns (MVVM, MVI, etc.) as needed -- Use the existing test infrastructure for unit tests (test/) and instrumented tests (androidTest/) \ No newline at end of file From 49b5ffcb3d18bbafb1c150ea912d7acb991fd89b Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Fri, 31 Oct 2025 11:36:07 +0900 Subject: [PATCH 2/5] =?UTF-8?q?5=EC=A3=BC=EC=B0=A8=20=EC=8B=A4=EC=8A=B5=20?= =?UTF-8?q?=EB=B0=8F=20=EB=AF=B8=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/gradle.xml | 4 +- app/build.gradle.kts | 16 ++ .../kuit6_android_api/data/api/ApiService.kt | 54 ++++++ .../data/api/RetrofitClient.kt | 36 ++++ .../kuit6_android_api/data/model/Post.kt | 17 -- .../data/model/request/PostCreateRequest.kt | 11 ++ .../data/model/response/AuthResponse.kt | 11 ++ .../data/model/response/BaseResponse.kt | 12 ++ .../model/response/ImageUploadResponse.kt | 9 + .../data/model/response/PostResponse.kt | 15 ++ .../ui/post/component/PostItem.kt | 10 +- .../ui/post/screen/PostCreateScreen.kt | 86 ++++++++- .../ui/post/screen/PostEditScreen.kt | 62 +++++- .../ui/post/viewmodel/PostViewModel.kt | 179 ++++++++++-------- .../ui/post/viewmodel/UriUtils.kt | 41 ++++ gradle/libs.versions.toml | 1 + 16 files changed, 446 insertions(+), 118 deletions(-) create mode 100644 app/src/main/java/com/example/kuit6_android_api/data/api/ApiService.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/data/api/RetrofitClient.kt delete mode 100644 app/src/main/java/com/example/kuit6_android_api/data/model/Post.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/data/model/request/PostCreateRequest.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/data/model/response/AuthResponse.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/data/model/response/BaseResponse.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/data/model/response/ImageUploadResponse.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/data/model/response/PostResponse.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/UriUtils.kt diff --git a/.idea/gradle.xml b/.idea/gradle.xml index a2cf405..639c779 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -9,8 +9,8 @@ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0c6dbbc..4482f58 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +import java.util.Properties + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.android) @@ -18,6 +20,13 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + val properties = Properties() + val localPropertiesFile = rootProject.file("local.properties") + if (localPropertiesFile.exists()) { + properties.load(localPropertiesFile.inputStream()) + } + val baseUrl = properties.getProperty("BASE_URL") + buildConfigField("String", "BASE_URL", "\"$baseUrl\"") } buildTypes { @@ -38,6 +47,7 @@ android { } buildFeatures { compose = true + buildConfig = true } } @@ -74,4 +84,10 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) debugImplementation(libs.androidx.compose.ui.tooling) debugImplementation(libs.androidx.compose.ui.test.manifest) + + // Retrofit & OkHttp + implementation(libs.retrofit) + implementation(libs.retrofit.converter.kotlinx.serialization) + implementation(libs.okhttp) + implementation(libs.okhttp.logging.interceptor) } diff --git a/app/src/main/java/com/example/kuit6_android_api/data/api/ApiService.kt b/app/src/main/java/com/example/kuit6_android_api/data/api/ApiService.kt new file mode 100644 index 0000000..5e10fb9 --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/data/api/ApiService.kt @@ -0,0 +1,54 @@ +package com.example.kuit6_android_api.data.api + +import com.example.kuit6_android_api.data.model.request.PostCreateRequest +import com.example.kuit6_android_api.data.model.response.BaseResponse +import com.example.kuit6_android_api.data.model.response.PostResponse +import okhttp3.MultipartBody +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +interface ApiService { + // 게시글 목록 조회 + @GET("/api/posts") + suspend fun getPosts(): BaseResponse> + + // 게시글 생성 + @POST("/api/posts") + suspend fun createPost( + @Query("author") author: String = "규빈", + @Body request: PostCreateRequest + ): BaseResponse + + // 게시글 상세 조회 + @GET("/api/posts/{id}") + suspend fun getPostDetail( + @Path("id") id: Long + ): BaseResponse + + // 게시글 수정 + @PUT("/api/posts/{id}") + suspend fun updatePost( + @Path("id") id: Long, + @Body request: PostCreateRequest + ): BaseResponse + + // 게시글 삭제 + @DELETE("/api/posts/{id}") + suspend fun deletePost( + @Path("id") id: Long + ): BaseResponse + + // 이미지 업로드 + @Multipart + @POST("/api/images/upload") + suspend fun uploadImage( + @Part file: MultipartBody.Part + ): BaseResponse> +} \ No newline at end of file diff --git a/app/src/main/java/com/example/kuit6_android_api/data/api/RetrofitClient.kt b/app/src/main/java/com/example/kuit6_android_api/data/api/RetrofitClient.kt new file mode 100644 index 0000000..ed8d5bd --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/data/api/RetrofitClient.kt @@ -0,0 +1,36 @@ +package com.example.kuit6_android_api.data.api + +import com.example.kuit6_android_api.BuildConfig +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory +import java.util.concurrent.TimeUnit + +object RetrofitClient { + private val loggingInterceptor = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + private val okHttpClient = OkHttpClient.Builder() + .addInterceptor(loggingInterceptor) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build() + + private val json = Json { + ignoreUnknownKeys = true // 서버에서 추가 필드가 와도 무시 + coerceInputValues = true // null이 와야 할 곳에 다른 값이 와도 처리 + } + + private val retrofit: Retrofit = Retrofit.Builder() + .baseUrl(BuildConfig.BASE_URL) + .client(okHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + + val apiService: ApiService = retrofit.create(ApiService::class.java) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/kuit6_android_api/data/model/Post.kt b/app/src/main/java/com/example/kuit6_android_api/data/model/Post.kt deleted file mode 100644 index e4c1d08..0000000 --- a/app/src/main/java/com/example/kuit6_android_api/data/model/Post.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.example.kuit6_android_api.data.model - -data class Author( - val id: Long, - val username: String, - val profileImageUrl: String? -) - -data class Post( - val id: Long, - val title: String, - val content: String, - val imageUrl: String?, - val author: Author, - val createdAt: String, - val updatedAt: String -) diff --git a/app/src/main/java/com/example/kuit6_android_api/data/model/request/PostCreateRequest.kt b/app/src/main/java/com/example/kuit6_android_api/data/model/request/PostCreateRequest.kt new file mode 100644 index 0000000..b619dc1 --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/data/model/request/PostCreateRequest.kt @@ -0,0 +1,11 @@ +package com.example.kuit6_android_api.data.model.request + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PostCreateRequest( + @SerialName("title") val title: String, + @SerialName("content") val content: String, + @SerialName("imageUrl") val imageUrl: String? +) \ No newline at end of file diff --git a/app/src/main/java/com/example/kuit6_android_api/data/model/response/AuthResponse.kt b/app/src/main/java/com/example/kuit6_android_api/data/model/response/AuthResponse.kt new file mode 100644 index 0000000..f915021 --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/data/model/response/AuthResponse.kt @@ -0,0 +1,11 @@ +package com.example.kuit6_android_api.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AuthorResponse( + @SerialName("id") val id: Long, + @SerialName("username") val username: String, + @SerialName("profileImageUrl") val profileImageUrl: String? +) \ No newline at end of file diff --git a/app/src/main/java/com/example/kuit6_android_api/data/model/response/BaseResponse.kt b/app/src/main/java/com/example/kuit6_android_api/data/model/response/BaseResponse.kt new file mode 100644 index 0000000..7f3b8b6 --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/data/model/response/BaseResponse.kt @@ -0,0 +1,12 @@ +package com.example.kuit6_android_api.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class BaseResponse( + @SerialName("success") val success: Boolean, + @SerialName("message") val message: String?, + @SerialName("data") val data: T?, + @SerialName("timestamp") val timestamp: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/kuit6_android_api/data/model/response/ImageUploadResponse.kt b/app/src/main/java/com/example/kuit6_android_api/data/model/response/ImageUploadResponse.kt new file mode 100644 index 0000000..e7e8ac7 --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/data/model/response/ImageUploadResponse.kt @@ -0,0 +1,9 @@ +package com.example.kuit6_android_api.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class ImageUploadResponse( + @SerialName("imageUrl") val imageUrl: String +) diff --git a/app/src/main/java/com/example/kuit6_android_api/data/model/response/PostResponse.kt b/app/src/main/java/com/example/kuit6_android_api/data/model/response/PostResponse.kt new file mode 100644 index 0000000..ba78028 --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/data/model/response/PostResponse.kt @@ -0,0 +1,15 @@ +package com.example.kuit6_android_api.data.model.response + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class PostResponse( + @SerialName("id") val id: Long, + @SerialName("title") val title: String, + @SerialName("content") val content: String, + @SerialName("imageUrl") val imageUrl: String?, + @SerialName("author") val author: AuthorResponse, + @SerialName("createdAt") val createdAt: String, + @SerialName("updatedAt") val updatedAt: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/component/PostItem.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/component/PostItem.kt index df2e5fe..b5344a8 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/component/PostItem.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/component/PostItem.kt @@ -29,13 +29,13 @@ 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.example.kuit6_android_api.data.model.Author -import com.example.kuit6_android_api.data.model.Post +import com.example.kuit6_android_api.data.model.response.AuthorResponse +import com.example.kuit6_android_api.data.model.response.PostResponse import com.example.kuit6_android_api.util.formatDateTime @Composable fun PostItem( - post: Post, + post: PostResponse, onClick: () -> Unit ) { Card( @@ -153,12 +153,12 @@ fun PostItem( fun PostItemPreview() { MaterialTheme { PostItem( - post = Post( + post = PostResponse( id = 1, title = "샘플 게시글 제목", content = "이것은 샘플 게시글 내용입니다. 미리보기에서는 두 줄까지만 표시됩니다.", imageUrl = null, - author = Author(1, "testuser", null), + author = AuthorResponse(1, "testuser", null), createdAt = "2025-10-03T12:00:00", updatedAt = "2025-10-03T12:00:00" ), diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostCreateScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostCreateScreen.kt index 57ea2ab..a885113 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostCreateScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostCreateScreen.kt @@ -5,6 +5,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background 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 @@ -12,15 +13,18 @@ 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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon @@ -39,10 +43,14 @@ 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.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage import com.example.kuit6_android_api.ui.post.viewmodel.PostViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -52,6 +60,7 @@ fun PostCreateScreen( onPostCreated: () -> Unit, viewModel: PostViewModel = viewModel() ) { + val context = LocalContext.current var author by remember { mutableStateOf("") } var title by remember { mutableStateOf("") } var content by remember { mutableStateOf("") } @@ -60,7 +69,19 @@ fun PostCreateScreen( val imagePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri: Uri? -> - selectedImageUri = uri + uri?.let { + selectedImageUri = it + viewModel.uploadImage( + context = context, + uri = it, + onSuccess = { imageUrl -> + // 업로드 성공 + }, + onError = { error -> + // 에러 처리 + } + ) + } } Scaffold( @@ -171,7 +192,7 @@ fun PostCreateScreen( color = MaterialTheme.colorScheme.onSurface ) - if (selectedImageUri == null) { + if (selectedImageUri == null && !viewModel.isUploading) { FilledTonalButton( onClick = { imagePickerLauncher.launch("image/*") }, shape = RoundedCornerShape(10.dp) @@ -180,6 +201,61 @@ fun PostCreateScreen( } } } + + // 업로드 중 표시 + if (viewModel.isUploading) { + Spacer(modifier = Modifier.height(12.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth() + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + Spacer(modifier = Modifier.padding(8.dp)) + Text( + text = "업로드 중...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // 업로드된 이미지 미리보기 + if (selectedImageUri != null && !viewModel.isUploading) { + Spacer(modifier = Modifier.height(12.dp)) + Box( + modifier = Modifier.fillMaxWidth() + ) { + AsyncImage( + model = selectedImageUri, + contentDescription = "선택된 이미지", + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop + ) + + IconButton( + onClick = { + selectedImageUri = null + viewModel.clearUploadedImageUrl() + }, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + ) { + Icon( + Icons.Default.Close, + contentDescription = "이미지 제거", + tint = MaterialTheme.colorScheme.error + ) + } + } + } } } @@ -187,15 +263,15 @@ fun PostCreateScreen( Button( onClick = { - val finalAuthor = author.ifBlank { "anonymous" } - viewModel.createPost(finalAuthor, title, content, null) { + val finalAuthor = author + viewModel.createPost(finalAuthor, title, content, viewModel.uploadedImageUrl) { onPostCreated() } }, modifier = Modifier .fillMaxWidth() .height(56.dp), - enabled = title.isNotBlank() && content.isNotBlank(), + enabled = title.isNotBlank() && content.isNotBlank() && !viewModel.isUploading, shape = RoundedCornerShape(16.dp), elevation = ButtonDefaults.buttonElevation( defaultElevation = 4.dp, diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostEditScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostEditScreen.kt index 0e5eaca..3e8e483 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostEditScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostEditScreen.kt @@ -5,13 +5,17 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background import androidx.compose.foundation.border +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -20,6 +24,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Clear import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -30,7 +35,6 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,7 +44,9 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage @@ -54,6 +60,7 @@ fun PostEditScreen( onPostUpdated: () -> Unit, viewModel: PostViewModel = viewModel() ) { + val context = LocalContext.current val post = viewModel.postDetail var title by remember { mutableStateOf("") } @@ -64,7 +71,20 @@ fun PostEditScreen( val imagePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() ) { uri: Uri? -> - selectedImageUri = uri + uri?.let { + selectedImageUri = it + // 이미지 선택 시 자동으로 업로드 + viewModel.uploadImage( + context = context, + uri = it, + onSuccess = { imageUrl -> + // 업로드 성공 + }, + onError = { error -> + // 에러 처리 + } + ) + } } LaunchedEffect(postId) { @@ -171,7 +191,10 @@ fun PostEditScreen( contentScale = ContentScale.Crop ) IconButton( - onClick = { selectedImageUri = null }, + onClick = { + selectedImageUri = null + viewModel.clearUploadedImageUrl() + }, modifier = Modifier .align(Alignment.TopEnd) .padding(8.dp) @@ -202,26 +225,45 @@ fun PostEditScreen( Button( onClick = { - viewModel.updatePost(postId, title, content, null) { + val imageUrl = if (selectedImageUri != null) { + viewModel.uploadedImageUrl + } else { + post?.imageUrl + } + viewModel.updatePost(postId, title, content, imageUrl) { onPostUpdated() } }, modifier = Modifier .fillMaxWidth() .height(56.dp), - enabled = title.isNotBlank() && content.isNotBlank(), + enabled = title.isNotBlank() && content.isNotBlank() && !viewModel.isUploading, shape = RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant ) ) { - Text( - "수정하기", - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.SemiBold + if (viewModel.isUploading) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + color = MaterialTheme.colorScheme.onPrimary + ) + Spacer(modifier = Modifier.width(8.dp)) + Text("업로드 중...") + } + } else { + Text( + "수정하기", + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.SemiBold + ) ) - ) + } } } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt index 444229c..d42740f 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt @@ -1,96 +1,85 @@ package com.example.kuit6_android_api.ui.post.viewmodel +import android.content.Context +import android.net.Uri +import android.util.Log import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.example.kuit6_android_api.data.model.Author -import com.example.kuit6_android_api.data.model.Post -import kotlinx.coroutines.delay +import com.example.kuit6_android_api.data.api.RetrofitClient +import com.example.kuit6_android_api.data.model.request.PostCreateRequest +import com.example.kuit6_android_api.data.model.response.PostResponse import kotlinx.coroutines.launch -import java.time.LocalDateTime +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody class PostViewModel : ViewModel() { - - // 더미 데이터 - private val dummyPosts = mutableStateListOf( - Post( - id = 1, - title = "Jetpack Compose 시작하기", - content = "Jetpack Compose는 Android의 최신 UI 툴킷입니다. 선언형 UI로 더 쉽고 빠르게 UI를 만들 수 있습니다.", - imageUrl = null, - author = Author(1, "개발자A", null), - createdAt = "2025-10-05T10:00:00", - updatedAt = "2025-10-05T10:00:00" - ), - Post( - id = 2, - title = "Kotlin Coroutines 완벽 가이드", - content = "비동기 프로그래밍을 쉽게! Coroutines를 사용하면 복잡한 비동기 코드를 간단하게 작성할 수 있습니다.", - imageUrl = null, - author = Author(2, "개발자B", null), - createdAt = "2025-10-05T11:30:00", - updatedAt = "2025-10-05T11:30:00" - ), - Post( - id = 3, - title = "Android MVVM 아키텍처", - content = "MVVM 패턴으로 코드를 구조화하면 테스트와 유지보수가 쉬워집니다. ViewModel과 LiveData/StateFlow를 활용해봅시다.", - imageUrl = null, - author = Author(1, "개발자A", null), - createdAt = "2025-10-05T14:20:00", - updatedAt = "2025-10-05T14:20:00" - ) - ) - - private var nextId = 4L - - var posts by mutableStateOf>(emptyList()) + var posts by mutableStateOf>(emptyList()) private set - var postDetail by mutableStateOf(null) + var postDetail by mutableStateOf(null) private set var uploadedImageUrl by mutableStateOf(null) private set + var isUploading by mutableStateOf(false) + private set + + private val apiService = RetrofitClient.apiService + fun getPosts() { viewModelScope.launch { - delay(500) // 네트워크 시뮬레이션 - posts = dummyPosts.toList() + runCatching { + apiService.getPosts() + }.onSuccess { response -> + response.data?.let { + if (response.success) { + posts = response.data + } + } + }.onFailure { error -> + Log.e("getPost", error.message.toString()) + } } } fun getPostDetail(postId: Long) { viewModelScope.launch { - delay(300) - postDetail = dummyPosts.find { it.id == postId } + runCatching { + apiService.getPostDetail(postId) + }.onSuccess { response -> + if (response.success && response.data != null) { + postDetail = response.data + } + }.onFailure { error -> + // 에러 처리 + postDetail = null + } } } fun createPost( - author: String = "anonymous", + author: String, title: String, content: String, imageUrl: String? = null, onSuccess: () -> Unit = {} ) { viewModelScope.launch { - delay(500) - val newPost = Post( - id = nextId++, - title = title, - content = content, - imageUrl = imageUrl, - author = Author(nextId, author, null), - createdAt = getCurrentDateTime(), - updatedAt = getCurrentDateTime() - ) - dummyPosts.add(0, newPost) - posts = dummyPosts.toList() - onSuccess() + runCatching { + val request = PostCreateRequest(title, content, imageUrl) + apiService.createPost(author, request) + }.onSuccess { response -> + if (response.success) { + // 이미지 업로드 관련 코드 + clearUploadedImageUrl() + onSuccess() + } + } } } @@ -102,30 +91,31 @@ class PostViewModel : ViewModel() { onSuccess: () -> Unit = {} ) { viewModelScope.launch { - delay(500) - val index = dummyPosts.indexOfFirst { it.id == postId } - if (index != -1) { - val oldPost = dummyPosts[index] - val updatedPost = oldPost.copy( - title = title, - content = content, - imageUrl = imageUrl, - updatedAt = getCurrentDateTime() - ) - dummyPosts[index] = updatedPost - postDetail = updatedPost - posts = dummyPosts.toList() - onSuccess() + runCatching { + val request = PostCreateRequest(title, content, imageUrl) + apiService.updatePost(postId, request) + }.onSuccess { response -> + if (response.success) { + clearUploadedImageUrl() + onSuccess() + } + }.onFailure { error -> + // 에러 처리 } } } fun deletePost(postId: Long, onSuccess: () -> Unit = {}) { viewModelScope.launch { - delay(300) - dummyPosts.removeIf { it.id == postId } - posts = dummyPosts.toList() - onSuccess() + runCatching { + apiService.deletePost(postId) + }.onSuccess { response -> + if (response.success) { + onSuccess() + } + }.onFailure { error -> + + } } } @@ -133,7 +123,38 @@ class PostViewModel : ViewModel() { uploadedImageUrl = null } - private fun getCurrentDateTime(): String { - return LocalDateTime.now().toString() + // 이미지 업로드 함수 + fun uploadImage( + context: Context, + uri: Uri, + onSuccess: (String) -> Unit = {}, + onError: (String) -> Unit = {} + ) { + viewModelScope.launch { + isUploading = true + runCatching { + val file = UriUtils.uriToFile(context, uri) + if (file == null) { + throw Exception("파일 변환 실패") + } + + val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) + val body = MultipartBody.Part.createFormData("file", file.name, requestFile) + + apiService.uploadImage(body) + }.onSuccess { response -> + isUploading = false + if (response.success && response.data != null) { + val imageUrl = response.data["imageUrl"] + if (imageUrl != null) { + uploadedImageUrl = imageUrl + onSuccess(imageUrl) + } + } + }.onFailure { error -> + isUploading = false + onError(error.message ?: "업로드 실패") + } + } } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/UriUtils.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/UriUtils.kt new file mode 100644 index 0000000..3271431 --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/UriUtils.kt @@ -0,0 +1,41 @@ +package com.example.kuit6_android_api.ui.post.viewmodel + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import java.io.File +import java.io.FileOutputStream + +object UriUtils { + fun uriToFile(context: Context, uri: Uri): File? { + return try { + val contentResolver = context.contentResolver + val fileName = getFileName(context, uri) ?: "image_${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) { + e.printStackTrace() + null + } + } + + fun getFileName(context: Context, uri: Uri): String? { + var fileName: String? = null + val cursor = context.contentResolver.query(uri, null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1) { + fileName = it.getString(nameIndex) + } + } + } + return fileName + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9576441..20d5c65 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -59,6 +59,7 @@ kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx- # Retrofit retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-converter-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" } +retrofit-converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } From bc6f5342c037ca8ac81a0781091dad3b4b8735ce Mon Sep 17 00:00:00 2001 From: rbqks529 Date: Wed, 5 Nov 2025 19:05:10 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[feat]:=205=EC=A3=BC=EC=B0=A8=20=EC=8B=A4?= =?UTF-8?q?=EC=8A=B5=20&=20=EB=AF=B8=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/deploymentTargetSelector.xml | 2 +- .../kuit6_android_api/data/model/response/BaseResponse.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 20e701c..d638899 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,7 +4,7 @@