From 4d8a1f54afcc0f714c2719d9add3b18f5b5a9bc9 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 12 Nov 2025 17:39:25 +0900 Subject: [PATCH 01/17] =?UTF-8?q?refactor:=20DI=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20Repository=20=ED=8C=A8=ED=84=B4=20=EB=8F=84?= =?UTF-8?q?=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 ViewModel에서 직접 API 통신을 하던 구조를 Repository 패턴을 적용하여 개선했습니다. 의존성 주입(DI)을 위해 Application 클래스와 AppContainer를 도입하고, ViewModelProvider.Factory를 사용하여 ViewModel에 Repository를 주입하도록 변경했습니다. * **App, di, repository 패키지 추가**: * `App.kt`: Application 클래스를 생성하여 DI 컨테이너(`AppContainer`)를 초기화합니다. * `AppContainer.kt`: `PostRepository`의 인스턴스를 생성하고 관리합니다. * `PostRepository.kt`, `PostRepositoryImpl.kt`: API 호출 로직을 ViewModel에서 분리하여 Repository 계층으로 이동했습니다. * **ViewModel 리팩토링**: * `PostListViewModel.kt`: 게시글 목록 조회를 담당하는 ViewModel을 새로 생성하고, `StateFlow`를 사용하여 UI 상태(`PostListUiState`)를 관리합니다. * `PostViewModel.kt`: 게시글 목록 조회(`getPosts`) 관련 로직을 `PostListViewModel`으로 이전했습니다. * `postViewModelFactory.kt`: `PostRepository`를 ViewModel에 주입하기 위한 `ViewModelProvider.Factory`를 구현했습니다. * **UI 및 화면 로직 수정**: * `PostListScreen.kt`: `PostListViewModel`과 `PostListUiState`를 사용하도록 변경하여, 로딩, 성공, 에러 상태에 따라 다른 UI를 표시하도록 구현했습니다. * `NavGraph.kt`: `PostListScreen`에 `PostListViewModel`을 `viewModelFactory`를 통해 주입하도록 수정했습니다. * **기타 변경 사항**: * `AndroidManifest.xml`: `Application` 클래스를 `App`으로 지정했습니다. * `UriUtils.kt`: `ui.post.viewmodel` 패키지에서 `util` 패키지로 파일을 이동했습니다. --- app/src/main/AndroidManifest.xml | 1 + .../java/com/example/kuit6_android_api/App.kt | 13 +++++ .../kuit6_android_api/data/di/AppContainer.kt | 16 +++++++ .../data/repository/PostRepository.kt | 7 +++ .../data/repository/PostRepositoryImpl.kt | 22 +++++++++ .../ui/navigation/NavGraph.kt | 6 ++- .../ui/post/screen/PostListScreen.kt | 48 ++++++++++++------- .../ui/post/state/PostListUiState.kt | 15 ++++++ .../ui/post/viewmodel/PostListViewModel.kt | 39 +++++++++++++++ .../ui/post/viewmodel/PostViewModel.kt | 21 +------- .../ui/post/viewmodel/PostViewModelFactory.kt | 22 +++++++++ .../{ui/post/viewmodel => util}/UriUtils.kt | 4 +- 12 files changed, 173 insertions(+), 41 deletions(-) create mode 100644 app/src/main/java/com/example/kuit6_android_api/App.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/data/di/AppContainer.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostListUiState.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt rename app/src/main/java/com/example/kuit6_android_api/{ui/post/viewmodel => util}/UriUtils.kt (95%) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0f991aa..958f28b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -14,6 +14,7 @@ android:maxSdkVersion="32" /> > +} \ No newline at end of file diff --git a/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt new file mode 100644 index 0000000..b7f1676 --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt @@ -0,0 +1,22 @@ +package com.example.kuit6_android_api.data.repository + +import android.util.Log +import com.example.kuit6_android_api.data.api.ApiService +import com.example.kuit6_android_api.data.model.response.PostResponse + +class PostRepositoryImpl ( + private val apiService: ApiService +): PostRepository { + override suspend fun getPosts(): Result> { + return runCatching{ + val response = apiService.getPosts() + if(response.success && response.data!=null){ + response.data + }else{ + throw Exception(response.message ?: "게시긆 불러오기 실패") + } + }.onFailure{error-> + Log.e("PostRepository",error.message.toString()) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt b/app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt index 975565e..bbd692b 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt @@ -2,6 +2,7 @@ package com.example.kuit6_android_api.ui.navigation import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -10,6 +11,8 @@ import com.example.kuit6_android_api.ui.post.screen.PostCreateScreen import com.example.kuit6_android_api.ui.post.screen.PostDetailScreen import com.example.kuit6_android_api.ui.post.screen.PostEditScreen import com.example.kuit6_android_api.ui.post.screen.PostListScreen +import com.example.kuit6_android_api.ui.post.viewmodel.PostListViewModel +import com.example.kuit6_android_api.ui.post.viewmodel.postViewModelFactory @Composable fun NavGraph( @@ -28,7 +31,8 @@ fun NavGraph( }, onCreatePostClick = { navController.navigate(PostCreateRoute) - } + }, + viewModel = viewModel(factory = postViewModelFactory { PostListViewModel(it) }) ) } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt index 8522d79..ec9f704 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon @@ -18,10 +19,14 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.example.kuit6_android_api.ui.post.component.PostItem +import com.example.kuit6_android_api.ui.post.state.PostListUiState +import com.example.kuit6_android_api.ui.post.viewmodel.PostListViewModel import com.example.kuit6_android_api.ui.post.viewmodel.PostViewModel @OptIn(ExperimentalMaterial3Api::class) @@ -29,13 +34,10 @@ import com.example.kuit6_android_api.ui.post.viewmodel.PostViewModel fun PostListScreen( onPostClick: (Long) -> Unit, onCreatePostClick: () -> Unit, - viewModel: PostViewModel = viewModel() + viewModel: PostListViewModel ) { - val posts = viewModel.posts + val uiState by viewModel.uiState.collectAsState() - LaunchedEffect(Unit) { - viewModel.getPosts() - } Scaffold( topBar = { @@ -49,19 +51,29 @@ fun PostListScreen( } } ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .background(MaterialTheme.colorScheme.background), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(posts) { post -> - PostItem( - post = post, - onClick = { onPostClick(post.id) } - ) + when(uiState){ + is PostListUiState.Loading->{ + CircularProgressIndicator() + } + is PostListUiState.Success-> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items((uiState as PostListUiState.Success).posts) { post -> + PostItem( + post = post, + onClick = { onPostClick(post.id) } + ) + } + } + } + is PostListUiState.Error->{ + } } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostListUiState.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostListUiState.kt new file mode 100644 index 0000000..f27b93f --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/state/PostListUiState.kt @@ -0,0 +1,15 @@ +package com.example.kuit6_android_api.ui.post.state + +import com.example.kuit6_android_api.data.model.response.PostResponse + +sealed class PostListUiState { + data object Loading: PostListUiState() + + data class Success( + val posts: List + ):PostListUiState() + + data class Error( + val message: String + ): PostListUiState() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt new file mode 100644 index 0000000..9e1cb77 --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt @@ -0,0 +1,39 @@ +package com.example.kuit6_android_api.ui.post.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.kuit6_android_api.data.repository.PostRepository +import com.example.kuit6_android_api.ui.post.state.PostListUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class PostListViewModel( + private val postRepository: PostRepository +) : ViewModel() { + private val _uiState = MutableStateFlow(PostListUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() + + init { + loadPosts() + } + + private fun loadPosts() { + viewModelScope.launch { + _uiState.value = PostListUiState.Loading + postRepository.getPosts() + .onSuccess { posts -> + _uiState.value = PostListUiState.Success(posts) + } + .onFailure {error-> + _uiState.value = PostListUiState.Error( + message = error.message?:"error" + ) + } + } + } + fun refresh(){ + loadPosts() + } +} \ No newline at end of file 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 d42740f..38b407f 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 @@ -2,7 +2,6 @@ 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.mutableStateOf import androidx.compose.runtime.setValue @@ -11,15 +10,13 @@ import androidx.lifecycle.viewModelScope 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 com.example.kuit6_android_api.util.UriUtils import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody class PostViewModel : ViewModel() { - var posts by mutableStateOf>(emptyList()) - private set - var postDetail by mutableStateOf(null) private set @@ -31,22 +28,6 @@ class PostViewModel : ViewModel() { private val apiService = RetrofitClient.apiService - fun getPosts() { - viewModelScope.launch { - 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 { runCatching { diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt new file mode 100644 index 0000000..6dcdcfc --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt @@ -0,0 +1,22 @@ +package com.example.kuit6_android_api.ui.post.viewmodel + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import com.example.kuit6_android_api.App +import com.example.kuit6_android_api.data.repository.PostRepository + + +inline fun postViewModelFactory( + crossinline create: (PostRepository) -> VM +): ViewModelProvider.Factory = viewModelFactory { + initializer { + val application = this[ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY] + as App + + val postRepository = application.container.postRepository + + create(postRepository) + } +} \ No newline at end of file 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/util/UriUtils.kt similarity index 95% rename from app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/UriUtils.kt rename to app/src/main/java/com/example/kuit6_android_api/util/UriUtils.kt index 3271431..4418e1a 100644 --- 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/util/UriUtils.kt @@ -1,4 +1,4 @@ -package com.example.kuit6_android_api.ui.post.viewmodel +package com.example.kuit6_android_api.util import android.content.Context import android.net.Uri @@ -38,4 +38,4 @@ object UriUtils { } return fileName } -} +} \ No newline at end of file From fb67908fb1840437520100b4d8e221afa2b9e9d4 Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 12 Nov 2025 18:33:31 +0900 Subject: [PATCH 02/17] =?UTF-8?q?refactor:=20ViewModel=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20Repository=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존의 단일 `PostViewModel`을 `PostCreateViewModel`, `PostDetailViewModel`, `PostEditViewModel`로 분리하여 각 화면의 역할을 명확히 했습니다. 또한, `PostRepository`를 도입하여 데이터 소스를 추상화하고 ViewModel과 데이터 계층 간의 의존성을 분리했습니다. * **Repository 추가**: * `PostRepository`: `ApiService`를 사용하여 게시글 CRUD 및 이미지 업로드 API를 호출하는 로직을 캡슐화했습니다. * **ViewModel 분리**: * `PostCreateViewModel`: 게시글 생성 및 이미지 업로드 기능을 담당합니다. * `PostDetailViewModel`: 게시글 상세 조회 및 삭제 기능을 담당합니다. * `PostEditViewModel`: 게시글 수정, 상세 조회 및 이미지 업로드 기능을 담당합니다. * **의존성 주입(DI)**: * `AppContainer`: `PostRepository`의 인스턴스를 생성하고 관리하여 ViewModel에 주입하는 역할을 합니다. * 각 Screen(`PostCreateScreen`, `PostDetailScreen`, `PostEditScreen`)에서는 `AppContainer`를 통해 해당 화면에 맞는 ViewModel을 주입받도록 수정했습니다. --- .../data/repository/PostRepository.kt | 93 ++++++++++++++++++- .../kuit6_android_api/di/AppContainer.kt | 13 +++ .../ui/post/screen/PostCreateScreen.kt | 10 +- .../ui/post/screen/PostDetailScreen.kt | 12 ++- .../ui/post/screen/PostEditScreen.kt | 10 +- .../ui/post/viewmodel/PostCreateViewModel.kt | 74 +++++++++++++++ .../ui/post/viewmodel/PostDetailViewModel.kt | 39 ++++++++ .../ui/post/viewmodel/PostEditViewModel.kt | 90 ++++++++++++++++++ 8 files changed, 331 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt diff --git a/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt index cf7a9a5..bd9e2ae 100644 --- a/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt +++ b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt @@ -1,7 +1,94 @@ package com.example.kuit6_android_api.data.repository +import com.example.kuit6_android_api.data.api.ApiService +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 -interface PostRepository{ - suspend fun getPosts(): Result> -} \ No newline at end of file +class PostRepository( + private val apiService: ApiService +) { + suspend fun getPosts(): Result> { + return runCatching { + val response: BaseResponse> = apiService.getPosts() + if (response.success && response.data != null) { + response.data + } else { + throw Exception(response.message ?: "게시글 목록 조회 실패") + } + } + } + + suspend fun getPostDetail(postId: Long): Result { + return runCatching { + val response: BaseResponse = apiService.getPostDetail(postId) + if (response.success && response.data != null) { + response.data + } else { + throw Exception(response.message ?: "게시글 상세 조회 실패") + } + } + } + + suspend fun createPost( + author: String, + title: String, + content: String, + imageUrl: String? + ): Result { + return runCatching { + val request = PostCreateRequest(title, content, imageUrl) + val response: BaseResponse = apiService.createPost(author, request) + if (response.success && response.data != null) { + response.data + } else { + throw Exception(response.message ?: "게시글 생성 실패") + } + } + } + + suspend fun updatePost( + postId: Long, + title: String, + content: String, + imageUrl: String? + ): Result { + return runCatching { + val request = PostCreateRequest(title, content, imageUrl) + val response: BaseResponse = apiService.updatePost(postId, request) + if (response.success && response.data != null) { + response.data + } else { + throw Exception(response.message ?: "게시글 수정 실패") + } + } + } + + suspend fun deletePost(postId: Long): Result { + return runCatching { + val response: BaseResponse = apiService.deletePost(postId) + if (response.success) { + Unit + } else { + throw Exception(response.message ?: "게시글 삭제 실패") + } + } + } + + suspend fun uploadImage(file: MultipartBody.Part): Result { + return runCatching { + val response: BaseResponse> = apiService.uploadImage(file) + if (response.success && response.data != null) { + val imageUrl = response.data["imageUrl"] + if (imageUrl != null) { + imageUrl + } else { + throw Exception("이미지 URL을 받아오지 못했습니다") + } + } else { + throw Exception(response.message ?: "이미지 업로드 실패") + } + } + } +} diff --git a/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt b/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt new file mode 100644 index 0000000..55cc30b --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt @@ -0,0 +1,13 @@ +package com.example.kuit6_android_api.di + +import com.example.kuit6_android_api.data.api.ApiService +import com.example.kuit6_android_api.data.api.RetrofitClient +import com.example.kuit6_android_api.data.repository.PostRepository + +class AppContainer { + //모든 의존성을 AppContainer 한 곳에서 관리하게 함 + val apiService: ApiService = RetrofitClient.apiService + //ApiService를 AppContainer에서 한 번만 가져 와 Repository에 주입 + // 원래 ApiService를 직접 참조하던 ViewModel들이 Repository를 참조 + val postRepository: PostRepository = PostRepository(apiService) +} \ No newline at end of file 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 58cee96..a883ad1 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 @@ -53,7 +53,8 @@ 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 +import com.example.kuit6_android_api.di.AppContainer +import com.example.kuit6_android_api.ui.post.viewmodel.PostCreateViewModel import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -61,7 +62,12 @@ import kotlinx.coroutines.launch fun PostCreateScreen( onNavigateBack: () -> Unit, onPostCreated: () -> Unit, - viewModel: PostViewModel = viewModel(), + viewModel: PostCreateViewModel = viewModel( + factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> + val appContainer = AppContainer() + PostCreateViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + } + ), snackBarState: SnackbarHostState ) { val context = LocalContext.current diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt index eb617c1..b62e88a 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt @@ -46,7 +46,8 @@ 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 +import com.example.kuit6_android_api.di.AppContainer +import com.example.kuit6_android_api.ui.post.viewmodel.PostDetailViewModel import com.example.kuit6_android_api.util.formatDateTime import kotlinx.coroutines.launch @@ -56,7 +57,12 @@ fun PostDetailScreen( postId: Long, onNavigateBack: () -> Unit, onEditClick: (Long) -> Unit = {}, - viewModel: PostViewModel = viewModel(), + viewModel: PostDetailViewModel = viewModel( + factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> + val appContainer = AppContainer() + PostDetailViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + } + ), snackBarState: SnackbarHostState ) { val post = viewModel.postDetail @@ -64,7 +70,7 @@ fun PostDetailScreen( val scope = rememberCoroutineScope() LaunchedEffect(postId) { -// viewModel.getPostDetail(postId) + viewModel.getPostDetail(postId) } Scaffold( 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 5f8283e..df642f5 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 @@ -52,7 +52,8 @@ 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 +import com.example.kuit6_android_api.di.AppContainer +import com.example.kuit6_android_api.ui.post.viewmodel.PostEditViewModel import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -61,7 +62,12 @@ fun PostEditScreen( postId: Long, onNavigateBack: () -> Unit, onPostUpdated: () -> Unit, - viewModel: PostViewModel = viewModel(), + viewModel: PostEditViewModel = viewModel( + factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> + val appContainer = AppContainer() + PostEditViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + } + ), snackBarState: SnackbarHostState ) { val context = LocalContext.current diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt new file mode 100644 index 0000000..6a05b1f --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt @@ -0,0 +1,74 @@ +package com.example.kuit6_android_api.ui.post.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.getValue +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.repository.PostRepository +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody + +class PostCreateViewModel( + private val repository: PostRepository +) : ViewModel() { + var uploadedImageUrl by mutableStateOf(null) + private set + + var isUploading by mutableStateOf(false) + private set + + fun createPost( + author: String, + title: String, + content: String, + imageUrl: String? = null, + onSuccess: () -> Unit = {} + ) { + viewModelScope.launch { + repository.createPost(author, title, content, imageUrl) + .onSuccess { + clearUploadedImageUrl() + onSuccess() + } + } + } + + fun clearUploadedImageUrl() { + uploadedImageUrl = null + } + + 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) + + repository.uploadImage(body) + }.onSuccess { imageUrl -> + isUploading = false + 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/PostDetailViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt new file mode 100644 index 0000000..ae8e59d --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt @@ -0,0 +1,39 @@ +package com.example.kuit6_android_api.ui.post.viewmodel + +import androidx.compose.runtime.getValue +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.repository.PostRepository +import com.example.kuit6_android_api.data.model.response.PostResponse +import kotlinx.coroutines.launch + +class PostDetailViewModel( + private val repository: PostRepository +) : ViewModel() { + var postDetail by mutableStateOf(null) + private set + + fun getPostDetail(postId: Long) { + viewModelScope.launch { + repository.getPostDetail(postId) + .onSuccess { post -> + postDetail = post + } + .onFailure { + postDetail = null + } + } + } + + fun deletePost(postId: Long, onSuccess: () -> Unit = {}) { + viewModelScope.launch { + repository.deletePost(postId) + .onSuccess { + onSuccess() + } + } + } +} + diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt new file mode 100644 index 0000000..0d3e6a5 --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt @@ -0,0 +1,90 @@ +package com.example.kuit6_android_api.ui.post.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.getValue +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.repository.PostRepository +import com.example.kuit6_android_api.data.model.response.PostResponse +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody + +class PostEditViewModel( + private val repository: PostRepository +) : ViewModel() { + var postDetail by mutableStateOf(null) + private set + + var uploadedImageUrl by mutableStateOf(null) + private set + + var isUploading by mutableStateOf(false) + private set + + fun getPostDetail(postId: Long) { + viewModelScope.launch { + repository.getPostDetail(postId) + .onSuccess { post -> + postDetail = post + } + .onFailure { + postDetail = null + } + } + } + + fun updatePost( + postId: Long, + title: String, + content: String, + imageUrl: String? = null, + onSuccess: () -> Unit = {} + ) { + viewModelScope.launch { + repository.updatePost(postId, title, content, imageUrl) + .onSuccess { + clearUploadedImageUrl() + onSuccess() + } + } + } + + fun clearUploadedImageUrl() { + uploadedImageUrl = null + } + + 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) + + repository.uploadImage(body) + }.onSuccess { imageUrl -> + isUploading = false + uploadedImageUrl = imageUrl + onSuccess(imageUrl) + }.onFailure { error -> + isUploading = false + onError(error.message ?: "업로드 실패") + } + } + } +} + From b801059dfaca57fa96997c7efb6cee2e8dd988b8 Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 12:20:10 +0900 Subject: [PATCH 03/17] =?UTF-8?q?refactor:=20ViewModel=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20UI=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존의 단일 `PostViewModel`을 `PostListViewModel`으로 이름을 변경하고 목록 조회 기능만 남도록 역할을 축소했습니다. 또한, 각 ViewModel에서 개별적으로 관리하던 상태 변수들을 `PostListUiState`, `PostCreateUiState`, `PostDetailUiState`, `PostEditUiState`와 같은 데이터 클래스로 통합하여 상태 관리를 개선했습니다. * **ViewModel 분리 및 리팩토링**: * `PostViewModel.kt`: 기존의 다기능 ViewModel을 삭제했습니다. * `PostListViewModel.kt`: 게시글 목록 조회를 담당하는 ViewModel을 새로 생성하고, `PostListUiState`를 사용하여 UI 상태를 관리합니다. `getPosts()` 메서드는 `refresh()`로 이름을 변경했습니다. * `PostCreateViewModel`, `PostDetailViewModel`, `PostEditViewModel`: 각 ViewModel의 상태 변수들(`uploadedImageUrl`, `isUploading`, `postDetail` 등)을 `UiState` 데이터 클래스로 캡슐화하여 관리하도록 수정했습니다. * **UI 및 화면 로직 수정**: * `PostListScreen.kt`: `PostListViewModel`과 `PostListUiState`를 사용하도록 변경했습니다. * `PostCreateScreen.kt`, `PostDetailScreen.kt`, `PostEditScreen.kt`: 각 화면에서 ViewModel의 `UiState`를 참조하여 UI를 렌더링하도록 수정했습니다. --- .../ui/post/screen/PostCreateScreen.kt | 11 +- .../ui/post/screen/PostDetailScreen.kt | 3 +- .../ui/post/screen/PostEditScreen.kt | 9 +- .../ui/post/screen/PostListScreen.kt | 53 ++++--- .../ui/post/viewmodel/PostCreateViewModel.kt | 25 ++-- .../ui/post/viewmodel/PostDetailViewModel.kt | 10 +- .../ui/post/viewmodel/PostEditViewModel.kt | 32 ++-- .../ui/post/viewmodel/PostListViewModel.kt | 40 +++-- .../ui/post/viewmodel/PostViewModel.kt | 141 ------------------ 9 files changed, 94 insertions(+), 230 deletions(-) delete mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt 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 a883ad1..0449fe6 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 @@ -71,6 +71,7 @@ fun PostCreateScreen( snackBarState: SnackbarHostState ) { val context = LocalContext.current + val uiState = viewModel.uiState var author by remember { mutableStateOf("") } var title by remember { mutableStateOf("") } var content by remember { mutableStateOf("") } @@ -204,7 +205,7 @@ fun PostCreateScreen( color = MaterialTheme.colorScheme.onSurface ) - if (selectedImageUri == null && !viewModel.isUploading) { + if (selectedImageUri == null && !uiState.isUploading) { FilledTonalButton( onClick = { imagePickerLauncher.launch("image/*") }, shape = RoundedCornerShape(10.dp) @@ -215,7 +216,7 @@ fun PostCreateScreen( } // 업로드 중 표시 - if (viewModel.isUploading) { + if (uiState.isUploading) { Spacer(modifier = Modifier.height(12.dp)) Row( verticalAlignment = Alignment.CenterVertically, @@ -236,7 +237,7 @@ fun PostCreateScreen( } // 업로드된 이미지 미리보기 - if (selectedImageUri != null && !viewModel.isUploading) { + if (selectedImageUri != null && !uiState.isUploading) { Spacer(modifier = Modifier.height(12.dp)) Box( modifier = Modifier.fillMaxWidth() @@ -276,7 +277,7 @@ fun PostCreateScreen( Button( onClick = { val finalAuthor = author - viewModel.createPost(finalAuthor, title, content, viewModel.uploadedImageUrl) { + viewModel.createPost(finalAuthor, title, content, uiState.uploadedImageUrl) { onPostCreated() scope.launch { snackBarState.showSnackbar("게시글이 작성되었습니다.") } } @@ -284,7 +285,7 @@ fun PostCreateScreen( modifier = Modifier .fillMaxWidth() .height(56.dp), - enabled = title.isNotBlank() && content.isNotBlank() && !viewModel.isUploading, + enabled = title.isNotBlank() && content.isNotBlank() && !uiState.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/PostDetailScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt index b62e88a..3b377bc 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt @@ -65,7 +65,8 @@ fun PostDetailScreen( ), snackBarState: SnackbarHostState ) { - val post = viewModel.postDetail + val uiState = viewModel.uiState + val post = uiState.postDetail var showDeleteDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() 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 df642f5..552e8a5 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 @@ -71,7 +71,8 @@ fun PostEditScreen( snackBarState: SnackbarHostState ) { val context = LocalContext.current - val post = viewModel.postDetail + val uiState = viewModel.uiState + val post = uiState.postDetail val scope = rememberCoroutineScope() var title by remember { mutableStateOf("") } @@ -237,7 +238,7 @@ fun PostEditScreen( Button( onClick = { val imageUrl = if (selectedImageUri != null) { - viewModel.uploadedImageUrl + uiState.uploadedImageUrl } else { post?.imageUrl } @@ -251,14 +252,14 @@ fun PostEditScreen( modifier = Modifier .fillMaxWidth() .height(56.dp), - enabled = title.isNotBlank() && content.isNotBlank() && !viewModel.isUploading, + enabled = title.isNotBlank() && content.isNotBlank() && !uiState.isUploading, shape = RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant ) ) { - if (viewModel.isUploading) { + if (uiState.isUploading) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt index ec9f704..866b3aa 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt @@ -19,25 +19,32 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.kuit6_android_api.di.AppContainer import com.example.kuit6_android_api.ui.post.component.PostItem -import com.example.kuit6_android_api.ui.post.state.PostListUiState import com.example.kuit6_android_api.ui.post.viewmodel.PostListViewModel -import com.example.kuit6_android_api.ui.post.viewmodel.PostViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun PostListScreen( onPostClick: (Long) -> Unit, onCreatePostClick: () -> Unit, - viewModel: PostListViewModel + viewModel: PostListViewModel = viewModel( + factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> + val appContainer = AppContainer() + PostListViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + } + ) ) { - val uiState by viewModel.uiState.collectAsState() + val uiState = viewModel.uiState + val posts = uiState.posts + LaunchedEffect(Unit) { + viewModel.refresh() + } Scaffold( topBar = { @@ -51,29 +58,19 @@ fun PostListScreen( } } ) { paddingValues -> - when(uiState){ - is PostListUiState.Loading->{ - CircularProgressIndicator() - } - is PostListUiState.Success-> { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .background(MaterialTheme.colorScheme.background), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items((uiState as PostListUiState.Success).posts) { post -> - PostItem( - post = post, - onClick = { onPostClick(post.id) } - ) - } - } - } - is PostListUiState.Error->{ - + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(posts) { post -> + PostItem( + post = post, + onClick = { onPostClick(post.id) } + ) } } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt index 6a05b1f..26f4da8 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt @@ -13,13 +13,16 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody +//uiState를 통해 상태를 한 번에 모아 처리 +data class PostCreateUiState( + val uploadedImageUrl: String? = null, + val isUploading: Boolean = false +) + class PostCreateViewModel( private val repository: PostRepository ) : ViewModel() { - var uploadedImageUrl by mutableStateOf(null) - private set - - var isUploading by mutableStateOf(false) + var uiState by mutableStateOf(PostCreateUiState()) private set fun createPost( @@ -32,14 +35,14 @@ class PostCreateViewModel( viewModelScope.launch { repository.createPost(author, title, content, imageUrl) .onSuccess { - clearUploadedImageUrl() + uiState = uiState.copy(uploadedImageUrl = null) onSuccess() } } } fun clearUploadedImageUrl() { - uploadedImageUrl = null + uiState = uiState.copy(uploadedImageUrl = null) } fun uploadImage( @@ -49,7 +52,7 @@ class PostCreateViewModel( onError: (String) -> Unit = {} ) { viewModelScope.launch { - isUploading = true + uiState = uiState.copy(isUploading = true) runCatching { val file = UriUtils.uriToFile(context, uri) if (file == null) { @@ -61,11 +64,13 @@ class PostCreateViewModel( repository.uploadImage(body) }.onSuccess { imageUrl -> - isUploading = false - uploadedImageUrl = imageUrl + uiState = uiState.copy( + isUploading = false, + uploadedImageUrl = imageUrl + ) onSuccess(imageUrl) }.onFailure { error -> - isUploading = false + uiState = uiState.copy(isUploading = false) onError(error.message ?: "업로드 실패") } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt index ae8e59d..6544df0 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt @@ -9,20 +9,24 @@ import com.example.kuit6_android_api.data.repository.PostRepository import com.example.kuit6_android_api.data.model.response.PostResponse import kotlinx.coroutines.launch +data class PostDetailUiState( + val postDetail: PostResponse? = null +) + class PostDetailViewModel( private val repository: PostRepository ) : ViewModel() { - var postDetail by mutableStateOf(null) + var uiState by mutableStateOf(PostDetailUiState()) private set fun getPostDetail(postId: Long) { viewModelScope.launch { repository.getPostDetail(postId) .onSuccess { post -> - postDetail = post + uiState = uiState.copy(postDetail = post) } .onFailure { - postDetail = null + uiState = uiState.copy(postDetail = null) } } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt index 0d3e6a5..21e5124 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt @@ -14,26 +14,26 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody +data class PostEditUiState( + val postDetail: PostResponse? = null, + val uploadedImageUrl: String? = null, + val isUploading: Boolean = false +) + class PostEditViewModel( private val repository: PostRepository ) : ViewModel() { - var postDetail by mutableStateOf(null) - private set - - var uploadedImageUrl by mutableStateOf(null) - private set - - var isUploading by mutableStateOf(false) + var uiState by mutableStateOf(PostEditUiState()) private set fun getPostDetail(postId: Long) { viewModelScope.launch { repository.getPostDetail(postId) .onSuccess { post -> - postDetail = post + uiState = uiState.copy(postDetail = post) } .onFailure { - postDetail = null + uiState = uiState.copy(postDetail = null) } } } @@ -48,14 +48,14 @@ class PostEditViewModel( viewModelScope.launch { repository.updatePost(postId, title, content, imageUrl) .onSuccess { - clearUploadedImageUrl() + uiState = uiState.copy(uploadedImageUrl = null) onSuccess() } } } fun clearUploadedImageUrl() { - uploadedImageUrl = null + uiState = uiState.copy(uploadedImageUrl = null) } fun uploadImage( @@ -65,7 +65,7 @@ class PostEditViewModel( onError: (String) -> Unit = {} ) { viewModelScope.launch { - isUploading = true + uiState = uiState.copy(isUploading = true) runCatching { val file = UriUtils.uriToFile(context, uri) if (file == null) { @@ -77,11 +77,13 @@ class PostEditViewModel( repository.uploadImage(body) }.onSuccess { imageUrl -> - isUploading = false - uploadedImageUrl = imageUrl + uiState = uiState.copy( + isUploading = false, + uploadedImageUrl = imageUrl + ) onSuccess(imageUrl) }.onFailure { error -> - isUploading = false + uiState = uiState.copy(isUploading = false) onError(error.message ?: "업로드 실패") } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt index 9e1cb77..7040496 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt @@ -1,39 +1,33 @@ package com.example.kuit6_android_api.ui.post.viewmodel +import androidx.compose.runtime.getValue +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.repository.PostRepository -import com.example.kuit6_android_api.ui.post.state.PostListUiState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import com.example.kuit6_android_api.data.model.response.PostResponse import kotlinx.coroutines.launch +data class PostListUiState( + val posts: List = emptyList() +) + class PostListViewModel( - private val postRepository: PostRepository + private val repository: PostRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(PostListUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - loadPosts() - } + var uiState by mutableStateOf(PostListUiState()) + private set - private fun loadPosts() { + fun refresh() { viewModelScope.launch { - _uiState.value = PostListUiState.Loading - postRepository.getPosts() + repository.getPosts() .onSuccess { posts -> - _uiState.value = PostListUiState.Success(posts) + uiState = uiState.copy(posts = posts) } - .onFailure {error-> - _uiState.value = PostListUiState.Error( - message = error.message?:"error" - ) + .onFailure { + uiState = uiState.copy(posts = emptyList()) } } } - fun refresh(){ - loadPosts() - } -} \ No newline at end of file +} 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 deleted file mode 100644 index 38b407f..0000000 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.example.kuit6_android_api.ui.post.viewmodel - -import android.content.Context -import android.net.Uri -import androidx.compose.runtime.getValue -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.api.RetrofitClient -import com.example.kuit6_android_api.data.model.request.PostCreateRequest -import com.example.kuit6_android_api.data.model.response.PostResponse -import com.example.kuit6_android_api.util.UriUtils -import kotlinx.coroutines.launch -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.MultipartBody -import okhttp3.RequestBody.Companion.asRequestBody - -class PostViewModel : ViewModel() { - 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 getPostDetail(postId: Long) { - viewModelScope.launch { - runCatching { - apiService.getPostDetail(postId) - }.onSuccess { response -> - if (response.success && response.data != null) { - postDetail = response.data - } - }.onFailure { error -> - // 에러 처리 - postDetail = null - } - } - } - - fun createPost( - author: String, - title: String, - content: String, - imageUrl: String? = null, - onSuccess: () -> Unit = {} - ) { - viewModelScope.launch { - runCatching { - val request = PostCreateRequest(title, content, imageUrl) - apiService.createPost(author, request) - }.onSuccess { response -> - if (response.success) { - // 이미지 업로드 관련 코드 - clearUploadedImageUrl() - onSuccess() - } - } - } - } - - fun updatePost( - postId: Long, - title: String, - content: String, - imageUrl: String? = null, - onSuccess: () -> Unit = {} - ) { - viewModelScope.launch { - 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 { - runCatching { - apiService.deletePost(postId) - }.onSuccess { response -> - if (response.success) { - onSuccess() - } - }.onFailure { error -> - - } - } - } - - fun clearUploadedImageUrl() { - uploadedImageUrl = null - } - - // 이미지 업로드 함수 - 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 ?: "업로드 실패") - } - } - } -} From ba74537852081f9d6eaf9f0d9a708396d8a3d6bd Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 13:13:08 +0900 Subject: [PATCH 04/17] =?UTF-8?q?refactor:=20ViewModel=20=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=B0=8F=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewModel을 주입하는 방식을 익명 객체 `ViewModelProvider.Factory`를 사용하도록 변경하여 코드를 간소화했습니다. 또한, `PostListScreen`에 `DisposableEffect`와 `LifecycleEventObserver`를 사용하여 화면이 다시 활성화될 때마다 게시글 목록을 자동으로 새로고침하는 기능을 추가했습니다. * **ViewModel 팩토리 리팩토링**: * `PostCreateScreen`, `PostDetailScreen`, `PostEditScreen`, `PostListScreen`: `viewModel()` 함수에 익명 클래스로 `ViewModelProvider.Factory`를 구현하여 ViewModel 인스턴스를 생성하도록 수정했습니다. * **게시글 목록 자동 새로고침**: * `PostListScreen.kt`: 화면의 생명주기(Lifecycle)가 `ON_RESUME` 상태일 때 `viewModel.refresh()`를 호출하도록 `DisposableEffect`를 추가했습니다. 이를 통해 다른 화면에서 게시글을 생성, 수정, 삭제하고 목록 화면으로 돌아왔을 때 변경 사항이 즉시 반영됩니다. * **ViewModel 예외 처리 개선**: * `PostCreateViewModel.kt`, `PostEditViewModel.kt`: 이미지 업로드 시 파일 변환에 실패하는 경우, `runCatching` 대신 `if (file == null)` 체크를 통해 명시적으로 예외를 처리하도록 수정했습니다. --- .../ui/post/screen/PostCreateScreen.kt | 12 ++++-- .../ui/post/screen/PostDetailScreen.kt | 12 ++++-- .../ui/post/screen/PostEditScreen.kt | 12 ++++-- .../ui/post/screen/PostListScreen.kt | 31 ++++++++++++-- .../ui/post/viewmodel/PostCreateViewModel.kt | 40 ++++++++++--------- .../ui/post/viewmodel/PostEditViewModel.kt | 39 +++++++++--------- 6 files changed, 94 insertions(+), 52 deletions(-) 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 0449fe6..7448808 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 @@ -51,6 +51,8 @@ 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 +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.kuit6_android_api.di.AppContainer @@ -62,10 +64,12 @@ import kotlinx.coroutines.launch fun PostCreateScreen( onNavigateBack: () -> Unit, onPostCreated: () -> Unit, - viewModel: PostCreateViewModel = viewModel( - factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> - val appContainer = AppContainer() - PostCreateViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + viewModel: PostCreateViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val appContainer = AppContainer() + return PostCreateViewModel(appContainer.postRepository) as T + } } ), snackBarState: SnackbarHostState diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt index 3b377bc..509b3c1 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt @@ -44,6 +44,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.kuit6_android_api.di.AppContainer @@ -57,10 +59,12 @@ fun PostDetailScreen( postId: Long, onNavigateBack: () -> Unit, onEditClick: (Long) -> Unit = {}, - viewModel: PostDetailViewModel = viewModel( - factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> - val appContainer = AppContainer() - PostDetailViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + viewModel: PostDetailViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val appContainer = AppContainer() + return PostDetailViewModel(appContainer.postRepository) as T + } } ), snackBarState: SnackbarHostState 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 552e8a5..8ce347a 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 @@ -50,6 +50,8 @@ 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 +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.kuit6_android_api.di.AppContainer @@ -62,10 +64,12 @@ fun PostEditScreen( postId: Long, onNavigateBack: () -> Unit, onPostUpdated: () -> Unit, - viewModel: PostEditViewModel = viewModel( - factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> - val appContainer = AppContainer() - PostEditViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + viewModel: PostEditViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val appContainer = AppContainer() + return PostEditViewModel(appContainer.postRepository) as T + } } ), snackBarState: SnackbarHostState diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt index 866b3aa..6d298f1 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt @@ -18,10 +18,16 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import com.example.kuit6_android_api.di.AppContainer import com.example.kuit6_android_api.ui.post.component.PostItem @@ -32,15 +38,32 @@ import com.example.kuit6_android_api.ui.post.viewmodel.PostListViewModel fun PostListScreen( onPostClick: (Long) -> Unit, onCreatePostClick: () -> Unit, - viewModel: PostListViewModel = viewModel( - factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> - val appContainer = AppContainer() - PostListViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + viewModel: PostListViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val appContainer = AppContainer() + return PostListViewModel(appContainer.postRepository) as T + } } ) ) { val uiState = viewModel.uiState val posts = uiState.posts + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) {//lifecycleOwner가 변경될 때마다 실행 + //LaunchedEffect와 달리 화면이 처음 나타날 때뿐만 아니라 다시 나타날 때도 실행 + val observer = LifecycleEventObserver { _, event -> //lifecycle 이벤트를 감지하는 옵저버 + //event로 상태 변화 받음 + if (event == Lifecycle.Event.ON_RESUME) {//화면이 다시 활성화되면 + viewModel.refresh() + } + } + lifecycleOwner.lifecycle.addObserver(observer)//화면 상태가 바뀔 때마다 알림을 받기 위해 옵저버 등록 + onDispose {//화면이 사라질 때 + lifecycleOwner.lifecycle.removeObserver(observer) + } + } LaunchedEffect(Unit) { viewModel.refresh() diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt index 26f4da8..34eeb67 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt @@ -8,11 +8,13 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.kuit6_android_api.data.repository.PostRepository +import com.example.kuit6_android_api.util.UriUtils import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody +//PostViewModel을 PostListViewModel, PostCreateViewModel, PostDetailViewModel, PostEditViewModel로 분리 //uiState를 통해 상태를 한 번에 모아 처리 data class PostCreateUiState( val uploadedImageUrl: String? = null, @@ -53,26 +55,28 @@ class PostCreateViewModel( ) { viewModelScope.launch { uiState = uiState.copy(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) - - repository.uploadImage(body) - }.onSuccess { imageUrl -> - uiState = uiState.copy( - isUploading = false, - uploadedImageUrl = imageUrl - ) - onSuccess(imageUrl) - }.onFailure { error -> + val file = UriUtils.uriToFile(context, uri) + if (file == null) { uiState = uiState.copy(isUploading = false) - onError(error.message ?: "업로드 실패") + onError("파일 변환 실패") + return@launch } + + val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) + val body = MultipartBody.Part.createFormData("file", file.name, requestFile) + + repository.uploadImage(body) + .onSuccess { imageUrl -> + uiState = uiState.copy( + isUploading = false, + uploadedImageUrl = imageUrl + ) + onSuccess(imageUrl) + } + .onFailure { error -> + uiState = uiState.copy(isUploading = false) + onError(error.message ?: "업로드 실패") + } } } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt index 21e5124..6234394 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.kuit6_android_api.data.repository.PostRepository import com.example.kuit6_android_api.data.model.response.PostResponse +import com.example.kuit6_android_api.util.UriUtils import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody @@ -66,26 +67,28 @@ class PostEditViewModel( ) { viewModelScope.launch { uiState = uiState.copy(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) - - repository.uploadImage(body) - }.onSuccess { imageUrl -> - uiState = uiState.copy( - isUploading = false, - uploadedImageUrl = imageUrl - ) - onSuccess(imageUrl) - }.onFailure { error -> + val file = UriUtils.uriToFile(context, uri) + if (file == null) { uiState = uiState.copy(isUploading = false) - onError(error.message ?: "업로드 실패") + onError("파일 변환 실패") + return@launch } + + val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) + val body = MultipartBody.Part.createFormData("file", file.name, requestFile) + + repository.uploadImage(body) + .onSuccess { imageUrl -> + uiState = uiState.copy( + isUploading = false, + uploadedImageUrl = imageUrl + ) + onSuccess(imageUrl) + } + .onFailure { error -> + uiState = uiState.copy(isUploading = false) + onError(error.message ?: "업로드 실패") + } } } } From cb5021297de92e3a7e508db301d44dd02953cc81 Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 14:10:37 +0900 Subject: [PATCH 05/17] =?UTF-8?q?refactor:=20ViewModel=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PostCreateScreen`에서 ViewModel을 생성하는 방식을 수정했습니다. * **PostCreateScreen.kt**: `ViewModelProvider.Factory`를 사용하여 `PostCreateViewModel`을 생성하도록 변경했습니다. 이를 통해 수동으로 생성한 `AppContainer`에서 Repository를 가져와 ViewModel에 주입할 수 있도록 구조를 개선했습니다. --- .../kuit6_android_api/ui/post/screen/PostCreateScreen.kt | 3 +++ 1 file changed, 3 insertions(+) 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 7448808..be6a63e 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 @@ -65,6 +65,9 @@ fun PostCreateScreen( onNavigateBack: () -> Unit, onPostCreated: () -> Unit, viewModel: PostCreateViewModel = viewModel( + //레포지토리 패턴을 위해 레포지토리를 뷰모델에 파라미터로 전달 + //레포지토리는 수동 주입(App Container)을 통해 가져옴 + //뷰모델에 파라미터를 전달하기 위해서 Factory 패턴을 사용 factory = object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { val appContainer = AppContainer() From 179fd7d610021967b99d008bb038d6099480631d Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 12 Nov 2025 18:33:31 +0900 Subject: [PATCH 06/17] =?UTF-8?q?refactor:=20[=EB=AF=B8=EC=85=98]=20ViewMo?= =?UTF-8?q?del=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20Repository=20=ED=8C=A8?= =?UTF-8?q?=ED=84=B4=20=EC=A0=81=EC=9A=A9=20=EC=8B=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존의 단일 PostViewModel을 PostCreateViewModel, PostDetailViewModel, PostEditViewModel로 분리하여 각 화면의 역할을 명확히 했습니다. 또한, PostRepository를 도입하여 데이터 소스를 추상화하고 ViewModel과 데이터 계층 간의 의존성을 분리했습니다. * **Repository 추가**:PostRepository: ApiService를 사용하여 게시글 CRUD 및 이미지 업로드 API를 호출하는 로직을 캡슐화했습니다. * **ViewModel 분리**:PostCreateViewModel: 게시글 생성 및 이미지 업로드 기능을 담당합니다.PostDetailViewModel: 게시글 상세 조회 및 삭제 기능을 담당합니다.PostEditViewModel: 게시글 수정, 상세 조회 및 이미지 업로드 기능을 담당합니다. * **의존성 주입(DI)**:AppContainer: PostRepository의 인스턴스를 생성하고 관리하여 ViewModel에 주입하는 역할을 합니다.각 Screen(PostCreateScreen, PostDetailScreen, PostEditScreen)에서는 AppContainer를 통해 해당 화면에 맞는 ViewModel을 주입받도록 수정했습니다. --- .../data/repository/PostRepository.kt | 93 ++++++++++++++++++- .../kuit6_android_api/di/AppContainer.kt | 13 +++ .../ui/post/screen/PostCreateScreen.kt | 10 +- .../ui/post/screen/PostDetailScreen.kt | 12 ++- .../ui/post/screen/PostEditScreen.kt | 10 +- .../ui/post/viewmodel/PostCreateViewModel.kt | 74 +++++++++++++++ .../ui/post/viewmodel/PostDetailViewModel.kt | 39 ++++++++ .../ui/post/viewmodel/PostEditViewModel.kt | 90 ++++++++++++++++++ 8 files changed, 331 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt create mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt diff --git a/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt index cf7a9a5..bd9e2ae 100644 --- a/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt +++ b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt @@ -1,7 +1,94 @@ package com.example.kuit6_android_api.data.repository +import com.example.kuit6_android_api.data.api.ApiService +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 -interface PostRepository{ - suspend fun getPosts(): Result> -} \ No newline at end of file +class PostRepository( + private val apiService: ApiService +) { + suspend fun getPosts(): Result> { + return runCatching { + val response: BaseResponse> = apiService.getPosts() + if (response.success && response.data != null) { + response.data + } else { + throw Exception(response.message ?: "게시글 목록 조회 실패") + } + } + } + + suspend fun getPostDetail(postId: Long): Result { + return runCatching { + val response: BaseResponse = apiService.getPostDetail(postId) + if (response.success && response.data != null) { + response.data + } else { + throw Exception(response.message ?: "게시글 상세 조회 실패") + } + } + } + + suspend fun createPost( + author: String, + title: String, + content: String, + imageUrl: String? + ): Result { + return runCatching { + val request = PostCreateRequest(title, content, imageUrl) + val response: BaseResponse = apiService.createPost(author, request) + if (response.success && response.data != null) { + response.data + } else { + throw Exception(response.message ?: "게시글 생성 실패") + } + } + } + + suspend fun updatePost( + postId: Long, + title: String, + content: String, + imageUrl: String? + ): Result { + return runCatching { + val request = PostCreateRequest(title, content, imageUrl) + val response: BaseResponse = apiService.updatePost(postId, request) + if (response.success && response.data != null) { + response.data + } else { + throw Exception(response.message ?: "게시글 수정 실패") + } + } + } + + suspend fun deletePost(postId: Long): Result { + return runCatching { + val response: BaseResponse = apiService.deletePost(postId) + if (response.success) { + Unit + } else { + throw Exception(response.message ?: "게시글 삭제 실패") + } + } + } + + suspend fun uploadImage(file: MultipartBody.Part): Result { + return runCatching { + val response: BaseResponse> = apiService.uploadImage(file) + if (response.success && response.data != null) { + val imageUrl = response.data["imageUrl"] + if (imageUrl != null) { + imageUrl + } else { + throw Exception("이미지 URL을 받아오지 못했습니다") + } + } else { + throw Exception(response.message ?: "이미지 업로드 실패") + } + } + } +} diff --git a/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt b/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt new file mode 100644 index 0000000..55cc30b --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt @@ -0,0 +1,13 @@ +package com.example.kuit6_android_api.di + +import com.example.kuit6_android_api.data.api.ApiService +import com.example.kuit6_android_api.data.api.RetrofitClient +import com.example.kuit6_android_api.data.repository.PostRepository + +class AppContainer { + //모든 의존성을 AppContainer 한 곳에서 관리하게 함 + val apiService: ApiService = RetrofitClient.apiService + //ApiService를 AppContainer에서 한 번만 가져 와 Repository에 주입 + // 원래 ApiService를 직접 참조하던 ViewModel들이 Repository를 참조 + val postRepository: PostRepository = PostRepository(apiService) +} \ No newline at end of file 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 58cee96..a883ad1 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 @@ -53,7 +53,8 @@ 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 +import com.example.kuit6_android_api.di.AppContainer +import com.example.kuit6_android_api.ui.post.viewmodel.PostCreateViewModel import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -61,7 +62,12 @@ import kotlinx.coroutines.launch fun PostCreateScreen( onNavigateBack: () -> Unit, onPostCreated: () -> Unit, - viewModel: PostViewModel = viewModel(), + viewModel: PostCreateViewModel = viewModel( + factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> + val appContainer = AppContainer() + PostCreateViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + } + ), snackBarState: SnackbarHostState ) { val context = LocalContext.current diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt index eb617c1..b62e88a 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt @@ -46,7 +46,8 @@ 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 +import com.example.kuit6_android_api.di.AppContainer +import com.example.kuit6_android_api.ui.post.viewmodel.PostDetailViewModel import com.example.kuit6_android_api.util.formatDateTime import kotlinx.coroutines.launch @@ -56,7 +57,12 @@ fun PostDetailScreen( postId: Long, onNavigateBack: () -> Unit, onEditClick: (Long) -> Unit = {}, - viewModel: PostViewModel = viewModel(), + viewModel: PostDetailViewModel = viewModel( + factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> + val appContainer = AppContainer() + PostDetailViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + } + ), snackBarState: SnackbarHostState ) { val post = viewModel.postDetail @@ -64,7 +70,7 @@ fun PostDetailScreen( val scope = rememberCoroutineScope() LaunchedEffect(postId) { -// viewModel.getPostDetail(postId) + viewModel.getPostDetail(postId) } Scaffold( 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 5f8283e..df642f5 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 @@ -52,7 +52,8 @@ 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 +import com.example.kuit6_android_api.di.AppContainer +import com.example.kuit6_android_api.ui.post.viewmodel.PostEditViewModel import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -61,7 +62,12 @@ fun PostEditScreen( postId: Long, onNavigateBack: () -> Unit, onPostUpdated: () -> Unit, - viewModel: PostViewModel = viewModel(), + viewModel: PostEditViewModel = viewModel( + factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> + val appContainer = AppContainer() + PostEditViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + } + ), snackBarState: SnackbarHostState ) { val context = LocalContext.current diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt new file mode 100644 index 0000000..6a05b1f --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt @@ -0,0 +1,74 @@ +package com.example.kuit6_android_api.ui.post.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.getValue +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.repository.PostRepository +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody + +class PostCreateViewModel( + private val repository: PostRepository +) : ViewModel() { + var uploadedImageUrl by mutableStateOf(null) + private set + + var isUploading by mutableStateOf(false) + private set + + fun createPost( + author: String, + title: String, + content: String, + imageUrl: String? = null, + onSuccess: () -> Unit = {} + ) { + viewModelScope.launch { + repository.createPost(author, title, content, imageUrl) + .onSuccess { + clearUploadedImageUrl() + onSuccess() + } + } + } + + fun clearUploadedImageUrl() { + uploadedImageUrl = null + } + + 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) + + repository.uploadImage(body) + }.onSuccess { imageUrl -> + isUploading = false + 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/PostDetailViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt new file mode 100644 index 0000000..ae8e59d --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt @@ -0,0 +1,39 @@ +package com.example.kuit6_android_api.ui.post.viewmodel + +import androidx.compose.runtime.getValue +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.repository.PostRepository +import com.example.kuit6_android_api.data.model.response.PostResponse +import kotlinx.coroutines.launch + +class PostDetailViewModel( + private val repository: PostRepository +) : ViewModel() { + var postDetail by mutableStateOf(null) + private set + + fun getPostDetail(postId: Long) { + viewModelScope.launch { + repository.getPostDetail(postId) + .onSuccess { post -> + postDetail = post + } + .onFailure { + postDetail = null + } + } + } + + fun deletePost(postId: Long, onSuccess: () -> Unit = {}) { + viewModelScope.launch { + repository.deletePost(postId) + .onSuccess { + onSuccess() + } + } + } +} + diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt new file mode 100644 index 0000000..0d3e6a5 --- /dev/null +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt @@ -0,0 +1,90 @@ +package com.example.kuit6_android_api.ui.post.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.getValue +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.repository.PostRepository +import com.example.kuit6_android_api.data.model.response.PostResponse +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody + +class PostEditViewModel( + private val repository: PostRepository +) : ViewModel() { + var postDetail by mutableStateOf(null) + private set + + var uploadedImageUrl by mutableStateOf(null) + private set + + var isUploading by mutableStateOf(false) + private set + + fun getPostDetail(postId: Long) { + viewModelScope.launch { + repository.getPostDetail(postId) + .onSuccess { post -> + postDetail = post + } + .onFailure { + postDetail = null + } + } + } + + fun updatePost( + postId: Long, + title: String, + content: String, + imageUrl: String? = null, + onSuccess: () -> Unit = {} + ) { + viewModelScope.launch { + repository.updatePost(postId, title, content, imageUrl) + .onSuccess { + clearUploadedImageUrl() + onSuccess() + } + } + } + + fun clearUploadedImageUrl() { + uploadedImageUrl = null + } + + 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) + + repository.uploadImage(body) + }.onSuccess { imageUrl -> + isUploading = false + uploadedImageUrl = imageUrl + onSuccess(imageUrl) + }.onFailure { error -> + isUploading = false + onError(error.message ?: "업로드 실패") + } + } + } +} + From aca901537c872cdf67c80c910eeed32e4bff364a Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 12:20:10 +0900 Subject: [PATCH 07/17] =?UTF-8?q?refactor:=20ViewModel=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20UI=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존의 단일 `PostViewModel`을 `PostListViewModel`으로 이름을 변경하고 목록 조회 기능만 남도록 역할을 축소했습니다. 또한, 각 ViewModel에서 개별적으로 관리하던 상태 변수들을 `PostListUiState`, `PostCreateUiState`, `PostDetailUiState`, `PostEditUiState`와 같은 데이터 클래스로 통합하여 상태 관리를 개선했습니다. * **ViewModel 분리 및 리팩토링**: * `PostViewModel.kt`: 기존의 다기능 ViewModel을 삭제했습니다. * `PostListViewModel.kt`: 게시글 목록 조회를 담당하는 ViewModel을 새로 생성하고, `PostListUiState`를 사용하여 UI 상태를 관리합니다. `getPosts()` 메서드는 `refresh()`로 이름을 변경했습니다. * `PostCreateViewModel`, `PostDetailViewModel`, `PostEditViewModel`: 각 ViewModel의 상태 변수들(`uploadedImageUrl`, `isUploading`, `postDetail` 등)을 `UiState` 데이터 클래스로 캡슐화하여 관리하도록 수정했습니다. * **UI 및 화면 로직 수정**: * `PostListScreen.kt`: `PostListViewModel`과 `PostListUiState`를 사용하도록 변경했습니다. * `PostCreateScreen.kt`, `PostDetailScreen.kt`, `PostEditScreen.kt`: 각 화면에서 ViewModel의 `UiState`를 참조하여 UI를 렌더링하도록 수정했습니다. --- .../ui/post/screen/PostCreateScreen.kt | 11 +- .../ui/post/screen/PostDetailScreen.kt | 3 +- .../ui/post/screen/PostEditScreen.kt | 9 +- .../ui/post/screen/PostListScreen.kt | 53 ++++--- .../ui/post/viewmodel/PostCreateViewModel.kt | 25 ++-- .../ui/post/viewmodel/PostDetailViewModel.kt | 10 +- .../ui/post/viewmodel/PostEditViewModel.kt | 32 ++-- .../ui/post/viewmodel/PostListViewModel.kt | 40 +++-- .../ui/post/viewmodel/PostViewModel.kt | 141 ------------------ 9 files changed, 94 insertions(+), 230 deletions(-) delete mode 100644 app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt 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 a883ad1..0449fe6 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 @@ -71,6 +71,7 @@ fun PostCreateScreen( snackBarState: SnackbarHostState ) { val context = LocalContext.current + val uiState = viewModel.uiState var author by remember { mutableStateOf("") } var title by remember { mutableStateOf("") } var content by remember { mutableStateOf("") } @@ -204,7 +205,7 @@ fun PostCreateScreen( color = MaterialTheme.colorScheme.onSurface ) - if (selectedImageUri == null && !viewModel.isUploading) { + if (selectedImageUri == null && !uiState.isUploading) { FilledTonalButton( onClick = { imagePickerLauncher.launch("image/*") }, shape = RoundedCornerShape(10.dp) @@ -215,7 +216,7 @@ fun PostCreateScreen( } // 업로드 중 표시 - if (viewModel.isUploading) { + if (uiState.isUploading) { Spacer(modifier = Modifier.height(12.dp)) Row( verticalAlignment = Alignment.CenterVertically, @@ -236,7 +237,7 @@ fun PostCreateScreen( } // 업로드된 이미지 미리보기 - if (selectedImageUri != null && !viewModel.isUploading) { + if (selectedImageUri != null && !uiState.isUploading) { Spacer(modifier = Modifier.height(12.dp)) Box( modifier = Modifier.fillMaxWidth() @@ -276,7 +277,7 @@ fun PostCreateScreen( Button( onClick = { val finalAuthor = author - viewModel.createPost(finalAuthor, title, content, viewModel.uploadedImageUrl) { + viewModel.createPost(finalAuthor, title, content, uiState.uploadedImageUrl) { onPostCreated() scope.launch { snackBarState.showSnackbar("게시글이 작성되었습니다.") } } @@ -284,7 +285,7 @@ fun PostCreateScreen( modifier = Modifier .fillMaxWidth() .height(56.dp), - enabled = title.isNotBlank() && content.isNotBlank() && !viewModel.isUploading, + enabled = title.isNotBlank() && content.isNotBlank() && !uiState.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/PostDetailScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt index b62e88a..3b377bc 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt @@ -65,7 +65,8 @@ fun PostDetailScreen( ), snackBarState: SnackbarHostState ) { - val post = viewModel.postDetail + val uiState = viewModel.uiState + val post = uiState.postDetail var showDeleteDialog by remember { mutableStateOf(false) } val scope = rememberCoroutineScope() 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 df642f5..552e8a5 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 @@ -71,7 +71,8 @@ fun PostEditScreen( snackBarState: SnackbarHostState ) { val context = LocalContext.current - val post = viewModel.postDetail + val uiState = viewModel.uiState + val post = uiState.postDetail val scope = rememberCoroutineScope() var title by remember { mutableStateOf("") } @@ -237,7 +238,7 @@ fun PostEditScreen( Button( onClick = { val imageUrl = if (selectedImageUri != null) { - viewModel.uploadedImageUrl + uiState.uploadedImageUrl } else { post?.imageUrl } @@ -251,14 +252,14 @@ fun PostEditScreen( modifier = Modifier .fillMaxWidth() .height(56.dp), - enabled = title.isNotBlank() && content.isNotBlank() && !viewModel.isUploading, + enabled = title.isNotBlank() && content.isNotBlank() && !uiState.isUploading, shape = RoundedCornerShape(12.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary, disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant ) ) { - if (viewModel.isUploading) { + if (uiState.isUploading) { Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt index ec9f704..866b3aa 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt @@ -19,25 +19,32 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.kuit6_android_api.di.AppContainer import com.example.kuit6_android_api.ui.post.component.PostItem -import com.example.kuit6_android_api.ui.post.state.PostListUiState import com.example.kuit6_android_api.ui.post.viewmodel.PostListViewModel -import com.example.kuit6_android_api.ui.post.viewmodel.PostViewModel @OptIn(ExperimentalMaterial3Api::class) @Composable fun PostListScreen( onPostClick: (Long) -> Unit, onCreatePostClick: () -> Unit, - viewModel: PostListViewModel + viewModel: PostListViewModel = viewModel( + factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> + val appContainer = AppContainer() + PostListViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + } + ) ) { - val uiState by viewModel.uiState.collectAsState() + val uiState = viewModel.uiState + val posts = uiState.posts + LaunchedEffect(Unit) { + viewModel.refresh() + } Scaffold( topBar = { @@ -51,29 +58,19 @@ fun PostListScreen( } } ) { paddingValues -> - when(uiState){ - is PostListUiState.Loading->{ - CircularProgressIndicator() - } - is PostListUiState.Success-> { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .background(MaterialTheme.colorScheme.background), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items((uiState as PostListUiState.Success).posts) { post -> - PostItem( - post = post, - onClick = { onPostClick(post.id) } - ) - } - } - } - is PostListUiState.Error->{ - + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items(posts) { post -> + PostItem( + post = post, + onClick = { onPostClick(post.id) } + ) } } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt index 6a05b1f..26f4da8 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt @@ -13,13 +13,16 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody +//uiState를 통해 상태를 한 번에 모아 처리 +data class PostCreateUiState( + val uploadedImageUrl: String? = null, + val isUploading: Boolean = false +) + class PostCreateViewModel( private val repository: PostRepository ) : ViewModel() { - var uploadedImageUrl by mutableStateOf(null) - private set - - var isUploading by mutableStateOf(false) + var uiState by mutableStateOf(PostCreateUiState()) private set fun createPost( @@ -32,14 +35,14 @@ class PostCreateViewModel( viewModelScope.launch { repository.createPost(author, title, content, imageUrl) .onSuccess { - clearUploadedImageUrl() + uiState = uiState.copy(uploadedImageUrl = null) onSuccess() } } } fun clearUploadedImageUrl() { - uploadedImageUrl = null + uiState = uiState.copy(uploadedImageUrl = null) } fun uploadImage( @@ -49,7 +52,7 @@ class PostCreateViewModel( onError: (String) -> Unit = {} ) { viewModelScope.launch { - isUploading = true + uiState = uiState.copy(isUploading = true) runCatching { val file = UriUtils.uriToFile(context, uri) if (file == null) { @@ -61,11 +64,13 @@ class PostCreateViewModel( repository.uploadImage(body) }.onSuccess { imageUrl -> - isUploading = false - uploadedImageUrl = imageUrl + uiState = uiState.copy( + isUploading = false, + uploadedImageUrl = imageUrl + ) onSuccess(imageUrl) }.onFailure { error -> - isUploading = false + uiState = uiState.copy(isUploading = false) onError(error.message ?: "업로드 실패") } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt index ae8e59d..6544df0 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt @@ -9,20 +9,24 @@ import com.example.kuit6_android_api.data.repository.PostRepository import com.example.kuit6_android_api.data.model.response.PostResponse import kotlinx.coroutines.launch +data class PostDetailUiState( + val postDetail: PostResponse? = null +) + class PostDetailViewModel( private val repository: PostRepository ) : ViewModel() { - var postDetail by mutableStateOf(null) + var uiState by mutableStateOf(PostDetailUiState()) private set fun getPostDetail(postId: Long) { viewModelScope.launch { repository.getPostDetail(postId) .onSuccess { post -> - postDetail = post + uiState = uiState.copy(postDetail = post) } .onFailure { - postDetail = null + uiState = uiState.copy(postDetail = null) } } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt index 0d3e6a5..21e5124 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt @@ -14,26 +14,26 @@ import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody +data class PostEditUiState( + val postDetail: PostResponse? = null, + val uploadedImageUrl: String? = null, + val isUploading: Boolean = false +) + class PostEditViewModel( private val repository: PostRepository ) : ViewModel() { - var postDetail by mutableStateOf(null) - private set - - var uploadedImageUrl by mutableStateOf(null) - private set - - var isUploading by mutableStateOf(false) + var uiState by mutableStateOf(PostEditUiState()) private set fun getPostDetail(postId: Long) { viewModelScope.launch { repository.getPostDetail(postId) .onSuccess { post -> - postDetail = post + uiState = uiState.copy(postDetail = post) } .onFailure { - postDetail = null + uiState = uiState.copy(postDetail = null) } } } @@ -48,14 +48,14 @@ class PostEditViewModel( viewModelScope.launch { repository.updatePost(postId, title, content, imageUrl) .onSuccess { - clearUploadedImageUrl() + uiState = uiState.copy(uploadedImageUrl = null) onSuccess() } } } fun clearUploadedImageUrl() { - uploadedImageUrl = null + uiState = uiState.copy(uploadedImageUrl = null) } fun uploadImage( @@ -65,7 +65,7 @@ class PostEditViewModel( onError: (String) -> Unit = {} ) { viewModelScope.launch { - isUploading = true + uiState = uiState.copy(isUploading = true) runCatching { val file = UriUtils.uriToFile(context, uri) if (file == null) { @@ -77,11 +77,13 @@ class PostEditViewModel( repository.uploadImage(body) }.onSuccess { imageUrl -> - isUploading = false - uploadedImageUrl = imageUrl + uiState = uiState.copy( + isUploading = false, + uploadedImageUrl = imageUrl + ) onSuccess(imageUrl) }.onFailure { error -> - isUploading = false + uiState = uiState.copy(isUploading = false) onError(error.message ?: "업로드 실패") } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt index 9e1cb77..7040496 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt @@ -1,39 +1,33 @@ package com.example.kuit6_android_api.ui.post.viewmodel +import androidx.compose.runtime.getValue +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.repository.PostRepository -import com.example.kuit6_android_api.ui.post.state.PostListUiState -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import com.example.kuit6_android_api.data.model.response.PostResponse import kotlinx.coroutines.launch +data class PostListUiState( + val posts: List = emptyList() +) + class PostListViewModel( - private val postRepository: PostRepository + private val repository: PostRepository ) : ViewModel() { - private val _uiState = MutableStateFlow(PostListUiState.Loading) - val uiState: StateFlow = _uiState.asStateFlow() - - init { - loadPosts() - } + var uiState by mutableStateOf(PostListUiState()) + private set - private fun loadPosts() { + fun refresh() { viewModelScope.launch { - _uiState.value = PostListUiState.Loading - postRepository.getPosts() + repository.getPosts() .onSuccess { posts -> - _uiState.value = PostListUiState.Success(posts) + uiState = uiState.copy(posts = posts) } - .onFailure {error-> - _uiState.value = PostListUiState.Error( - message = error.message?:"error" - ) + .onFailure { + uiState = uiState.copy(posts = emptyList()) } } } - fun refresh(){ - loadPosts() - } -} \ No newline at end of file +} 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 deleted file mode 100644 index 38b407f..0000000 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt +++ /dev/null @@ -1,141 +0,0 @@ -package com.example.kuit6_android_api.ui.post.viewmodel - -import android.content.Context -import android.net.Uri -import androidx.compose.runtime.getValue -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.api.RetrofitClient -import com.example.kuit6_android_api.data.model.request.PostCreateRequest -import com.example.kuit6_android_api.data.model.response.PostResponse -import com.example.kuit6_android_api.util.UriUtils -import kotlinx.coroutines.launch -import okhttp3.MediaType.Companion.toMediaTypeOrNull -import okhttp3.MultipartBody -import okhttp3.RequestBody.Companion.asRequestBody - -class PostViewModel : ViewModel() { - 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 getPostDetail(postId: Long) { - viewModelScope.launch { - runCatching { - apiService.getPostDetail(postId) - }.onSuccess { response -> - if (response.success && response.data != null) { - postDetail = response.data - } - }.onFailure { error -> - // 에러 처리 - postDetail = null - } - } - } - - fun createPost( - author: String, - title: String, - content: String, - imageUrl: String? = null, - onSuccess: () -> Unit = {} - ) { - viewModelScope.launch { - runCatching { - val request = PostCreateRequest(title, content, imageUrl) - apiService.createPost(author, request) - }.onSuccess { response -> - if (response.success) { - // 이미지 업로드 관련 코드 - clearUploadedImageUrl() - onSuccess() - } - } - } - } - - fun updatePost( - postId: Long, - title: String, - content: String, - imageUrl: String? = null, - onSuccess: () -> Unit = {} - ) { - viewModelScope.launch { - 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 { - runCatching { - apiService.deletePost(postId) - }.onSuccess { response -> - if (response.success) { - onSuccess() - } - }.onFailure { error -> - - } - } - } - - fun clearUploadedImageUrl() { - uploadedImageUrl = null - } - - // 이미지 업로드 함수 - 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 ?: "업로드 실패") - } - } - } -} From fd56889a6347776340ee86e5cfedeb774035edf7 Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 13:13:08 +0900 Subject: [PATCH 08/17] =?UTF-8?q?refactor:=20ViewModel=20=ED=8C=A9?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=B0=8F=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ViewModel을 주입하는 방식을 익명 객체 `ViewModelProvider.Factory`를 사용하도록 변경하여 코드를 간소화했습니다. 또한, `PostListScreen`에 `DisposableEffect`와 `LifecycleEventObserver`를 사용하여 화면이 다시 활성화될 때마다 게시글 목록을 자동으로 새로고침하는 기능을 추가했습니다. * **ViewModel 팩토리 리팩토링**: * `PostCreateScreen`, `PostDetailScreen`, `PostEditScreen`, `PostListScreen`: `viewModel()` 함수에 익명 클래스로 `ViewModelProvider.Factory`를 구현하여 ViewModel 인스턴스를 생성하도록 수정했습니다. * **게시글 목록 자동 새로고침**: * `PostListScreen.kt`: 화면의 생명주기(Lifecycle)가 `ON_RESUME` 상태일 때 `viewModel.refresh()`를 호출하도록 `DisposableEffect`를 추가했습니다. 이를 통해 다른 화면에서 게시글을 생성, 수정, 삭제하고 목록 화면으로 돌아왔을 때 변경 사항이 즉시 반영됩니다. * **ViewModel 예외 처리 개선**: * `PostCreateViewModel.kt`, `PostEditViewModel.kt`: 이미지 업로드 시 파일 변환에 실패하는 경우, `runCatching` 대신 `if (file == null)` 체크를 통해 명시적으로 예외를 처리하도록 수정했습니다. --- .../ui/post/screen/PostCreateScreen.kt | 12 ++++-- .../ui/post/screen/PostDetailScreen.kt | 12 ++++-- .../ui/post/screen/PostEditScreen.kt | 12 ++++-- .../ui/post/screen/PostListScreen.kt | 31 ++++++++++++-- .../ui/post/viewmodel/PostCreateViewModel.kt | 40 ++++++++++--------- .../ui/post/viewmodel/PostEditViewModel.kt | 39 +++++++++--------- 6 files changed, 94 insertions(+), 52 deletions(-) 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 0449fe6..7448808 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 @@ -51,6 +51,8 @@ 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 +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.kuit6_android_api.di.AppContainer @@ -62,10 +64,12 @@ import kotlinx.coroutines.launch fun PostCreateScreen( onNavigateBack: () -> Unit, onPostCreated: () -> Unit, - viewModel: PostCreateViewModel = viewModel( - factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> - val appContainer = AppContainer() - PostCreateViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + viewModel: PostCreateViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val appContainer = AppContainer() + return PostCreateViewModel(appContainer.postRepository) as T + } } ), snackBarState: SnackbarHostState diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt index 3b377bc..509b3c1 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt @@ -44,6 +44,8 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.kuit6_android_api.di.AppContainer @@ -57,10 +59,12 @@ fun PostDetailScreen( postId: Long, onNavigateBack: () -> Unit, onEditClick: (Long) -> Unit = {}, - viewModel: PostDetailViewModel = viewModel( - factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> - val appContainer = AppContainer() - PostDetailViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + viewModel: PostDetailViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val appContainer = AppContainer() + return PostDetailViewModel(appContainer.postRepository) as T + } } ), snackBarState: SnackbarHostState 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 552e8a5..8ce347a 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 @@ -50,6 +50,8 @@ 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 +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage import com.example.kuit6_android_api.di.AppContainer @@ -62,10 +64,12 @@ fun PostEditScreen( postId: Long, onNavigateBack: () -> Unit, onPostUpdated: () -> Unit, - viewModel: PostEditViewModel = viewModel( - factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> - val appContainer = AppContainer() - PostEditViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + viewModel: PostEditViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val appContainer = AppContainer() + return PostEditViewModel(appContainer.postRepository) as T + } } ), snackBarState: SnackbarHostState diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt index 866b3aa..6d298f1 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt @@ -18,10 +18,16 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import com.example.kuit6_android_api.di.AppContainer import com.example.kuit6_android_api.ui.post.component.PostItem @@ -32,15 +38,32 @@ import com.example.kuit6_android_api.ui.post.viewmodel.PostListViewModel fun PostListScreen( onPostClick: (Long) -> Unit, onCreatePostClick: () -> Unit, - viewModel: PostListViewModel = viewModel( - factory = androidx.lifecycle.ViewModelProvider.Factory { modelClass -> - val appContainer = AppContainer() - PostListViewModel(appContainer.postRepository) as androidx.lifecycle.ViewModel + viewModel: PostListViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + val appContainer = AppContainer() + return PostListViewModel(appContainer.postRepository) as T + } } ) ) { val uiState = viewModel.uiState val posts = uiState.posts + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) {//lifecycleOwner가 변경될 때마다 실행 + //LaunchedEffect와 달리 화면이 처음 나타날 때뿐만 아니라 다시 나타날 때도 실행 + val observer = LifecycleEventObserver { _, event -> //lifecycle 이벤트를 감지하는 옵저버 + //event로 상태 변화 받음 + if (event == Lifecycle.Event.ON_RESUME) {//화면이 다시 활성화되면 + viewModel.refresh() + } + } + lifecycleOwner.lifecycle.addObserver(observer)//화면 상태가 바뀔 때마다 알림을 받기 위해 옵저버 등록 + onDispose {//화면이 사라질 때 + lifecycleOwner.lifecycle.removeObserver(observer) + } + } LaunchedEffect(Unit) { viewModel.refresh() diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt index 26f4da8..34eeb67 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt @@ -8,11 +8,13 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.kuit6_android_api.data.repository.PostRepository +import com.example.kuit6_android_api.util.UriUtils import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody +//PostViewModel을 PostListViewModel, PostCreateViewModel, PostDetailViewModel, PostEditViewModel로 분리 //uiState를 통해 상태를 한 번에 모아 처리 data class PostCreateUiState( val uploadedImageUrl: String? = null, @@ -53,26 +55,28 @@ class PostCreateViewModel( ) { viewModelScope.launch { uiState = uiState.copy(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) - - repository.uploadImage(body) - }.onSuccess { imageUrl -> - uiState = uiState.copy( - isUploading = false, - uploadedImageUrl = imageUrl - ) - onSuccess(imageUrl) - }.onFailure { error -> + val file = UriUtils.uriToFile(context, uri) + if (file == null) { uiState = uiState.copy(isUploading = false) - onError(error.message ?: "업로드 실패") + onError("파일 변환 실패") + return@launch } + + val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) + val body = MultipartBody.Part.createFormData("file", file.name, requestFile) + + repository.uploadImage(body) + .onSuccess { imageUrl -> + uiState = uiState.copy( + isUploading = false, + uploadedImageUrl = imageUrl + ) + onSuccess(imageUrl) + } + .onFailure { error -> + uiState = uiState.copy(isUploading = false) + onError(error.message ?: "업로드 실패") + } } } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt index 21e5124..6234394 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.example.kuit6_android_api.data.repository.PostRepository import com.example.kuit6_android_api.data.model.response.PostResponse +import com.example.kuit6_android_api.util.UriUtils import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody @@ -66,26 +67,28 @@ class PostEditViewModel( ) { viewModelScope.launch { uiState = uiState.copy(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) - - repository.uploadImage(body) - }.onSuccess { imageUrl -> - uiState = uiState.copy( - isUploading = false, - uploadedImageUrl = imageUrl - ) - onSuccess(imageUrl) - }.onFailure { error -> + val file = UriUtils.uriToFile(context, uri) + if (file == null) { uiState = uiState.copy(isUploading = false) - onError(error.message ?: "업로드 실패") + onError("파일 변환 실패") + return@launch } + + val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) + val body = MultipartBody.Part.createFormData("file", file.name, requestFile) + + repository.uploadImage(body) + .onSuccess { imageUrl -> + uiState = uiState.copy( + isUploading = false, + uploadedImageUrl = imageUrl + ) + onSuccess(imageUrl) + } + .onFailure { error -> + uiState = uiState.copy(isUploading = false) + onError(error.message ?: "업로드 실패") + } } } } From 460b888ae69235f77b1640947f11a503cc1e0e09 Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 14:10:37 +0900 Subject: [PATCH 09/17] =?UTF-8?q?refactor:=20ViewModel=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PostCreateScreen`에서 ViewModel을 생성하는 방식을 수정했습니다. * **PostCreateScreen.kt**: `ViewModelProvider.Factory`를 사용하여 `PostCreateViewModel`을 생성하도록 변경했습니다. 이를 통해 수동으로 생성한 `AppContainer`에서 Repository를 가져와 ViewModel에 주입할 수 있도록 구조를 개선했습니다. --- .../kuit6_android_api/ui/post/screen/PostCreateScreen.kt | 3 +++ 1 file changed, 3 insertions(+) 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 7448808..be6a63e 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 @@ -65,6 +65,9 @@ fun PostCreateScreen( onNavigateBack: () -> Unit, onPostCreated: () -> Unit, viewModel: PostCreateViewModel = viewModel( + //레포지토리 패턴을 위해 레포지토리를 뷰모델에 파라미터로 전달 + //레포지토리는 수동 주입(App Container)을 통해 가져옴 + //뷰모델에 파라미터를 전달하기 위해서 Factory 패턴을 사용 factory = object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { val appContainer = AppContainer() From da6b8bba04eb16dfbd20a863da95e1ce902c9dde Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 14:31:18 +0900 Subject: [PATCH 10/17] =?UTF-8?q?chore:=20Gradle=20=ED=94=8C=EB=9F=AC?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=B2=84=EC=A0=84=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android Gradle Plugin(agp)의 버전을 8.13.0에서 8.13.1로 업데이트했습니다. --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 20d5c65..d8f06bb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.13.0" +agp = "8.13.1" kotlin = "2.0.21" coreKtx = "1.17.0" junit = "4.13.2" From 04f83ac8279f8e52ca6fed76d47de2f765b32978 Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 14:48:26 +0900 Subject: [PATCH 11/17] =?UTF-8?q?refactor:=20PostRepository=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20PostRepositoryImpl=EC=97=90=20?= =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/PostRepository.kt | 84 ++-------------- .../data/repository/PostRepositoryImpl.kt | 95 +++++++++++++++++-- .../kuit6_android_api/di/AppContainer.kt | 3 +- .../ui/navigation/NavGraph.kt | 6 ++ .../ui/post/screen/PostCreateScreen.kt | 16 +--- .../ui/post/screen/PostDetailScreen.kt | 13 +-- .../ui/post/screen/PostEditScreen.kt | 13 +-- .../ui/post/screen/PostListScreen.kt | 13 +-- 8 files changed, 108 insertions(+), 135 deletions(-) diff --git a/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt index bd9e2ae..763366b 100644 --- a/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt +++ b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt @@ -1,94 +1,24 @@ package com.example.kuit6_android_api.data.repository -import com.example.kuit6_android_api.data.api.ApiService 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 -class PostRepository( - private val apiService: ApiService -) { - suspend fun getPosts(): Result> { - return runCatching { - val response: BaseResponse> = apiService.getPosts() - if (response.success && response.data != null) { - response.data - } else { - throw Exception(response.message ?: "게시글 목록 조회 실패") - } - } - } - - suspend fun getPostDetail(postId: Long): Result { - return runCatching { - val response: BaseResponse = apiService.getPostDetail(postId) - if (response.success && response.data != null) { - response.data - } else { - throw Exception(response.message ?: "게시글 상세 조회 실패") - } - } - } - +interface PostRepository { + suspend fun getPosts(): Result> + suspend fun getPostDetail(postId: Long): Result suspend fun createPost( author: String, title: String, content: String, imageUrl: String? - ): Result { - return runCatching { - val request = PostCreateRequest(title, content, imageUrl) - val response: BaseResponse = apiService.createPost(author, request) - if (response.success && response.data != null) { - response.data - } else { - throw Exception(response.message ?: "게시글 생성 실패") - } - } - } - + ): Result suspend fun updatePost( postId: Long, title: String, content: String, imageUrl: String? - ): Result { - return runCatching { - val request = PostCreateRequest(title, content, imageUrl) - val response: BaseResponse = apiService.updatePost(postId, request) - if (response.success && response.data != null) { - response.data - } else { - throw Exception(response.message ?: "게시글 수정 실패") - } - } - } - - suspend fun deletePost(postId: Long): Result { - return runCatching { - val response: BaseResponse = apiService.deletePost(postId) - if (response.success) { - Unit - } else { - throw Exception(response.message ?: "게시글 삭제 실패") - } - } - } - - suspend fun uploadImage(file: MultipartBody.Part): Result { - return runCatching { - val response: BaseResponse> = apiService.uploadImage(file) - if (response.success && response.data != null) { - val imageUrl = response.data["imageUrl"] - if (imageUrl != null) { - imageUrl - } else { - throw Exception("이미지 URL을 받아오지 못했습니다") - } - } else { - throw Exception(response.message ?: "이미지 업로드 실패") - } - } - } + ): Result + suspend fun deletePost(postId: Long): Result + suspend fun uploadImage(file: MultipartBody.Part): Result } diff --git a/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt index b7f1676..b7904fe 100644 --- a/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt +++ b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt @@ -2,21 +2,96 @@ package com.example.kuit6_android_api.data.repository import android.util.Log import com.example.kuit6_android_api.data.api.ApiService +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 -class PostRepositoryImpl ( +class PostRepositoryImpl( private val apiService: ApiService -): PostRepository { +) : PostRepository { override suspend fun getPosts(): Result> { - return runCatching{ - val response = apiService.getPosts() - if(response.success && response.data!=null){ + return runCatching { + val response: BaseResponse> = apiService.getPosts() + if (response.success && response.data != null) { response.data - }else{ - throw Exception(response.message ?: "게시긆 불러오기 실패") + } else { + throw Exception(response.message ?: "게시글 목록 조회 실패") } - }.onFailure{error-> - Log.e("PostRepository",error.message.toString()) + }.onFailure { error -> + Log.e("PostRepository", error.message.toString()) } } -} \ No newline at end of file + + override suspend fun getPostDetail(postId: Long): Result { + return runCatching { + val response: BaseResponse = apiService.getPostDetail(postId) + if (response.success && response.data != null) { + response.data + } else { + throw Exception(response.message ?: "게시글 상세 조회 실패") + } + } + } + + override suspend fun createPost( + author: String, + title: String, + content: String, + imageUrl: String? + ): Result { + return runCatching { + val request = PostCreateRequest(title, content, imageUrl) + val response: BaseResponse = apiService.createPost(author, request) + if (response.success && response.data != null) { + response.data + } else { + throw Exception(response.message ?: "게시글 생성 실패") + } + } + } + + override suspend fun updatePost( + postId: Long, + title: String, + content: String, + imageUrl: String? + ): Result { + return runCatching { + val request = PostCreateRequest(title, content, imageUrl) + val response: BaseResponse = apiService.updatePost(postId, request) + if (response.success && response.data != null) { + response.data + } else { + throw Exception(response.message ?: "게시글 수정 실패") + } + } + } + + override suspend fun deletePost(postId: Long): Result { + return runCatching { + val response: BaseResponse = apiService.deletePost(postId) + if (response.success) { + Unit + } else { + throw Exception(response.message ?: "게시글 삭제 실패") + } + } + } + + override suspend fun uploadImage(file: MultipartBody.Part): Result { + return runCatching { + val response: BaseResponse> = apiService.uploadImage(file) + if (response.success && response.data != null) { + val imageUrl = response.data["imageUrl"] + if (imageUrl != null) { + imageUrl + } else { + throw Exception("이미지 URL을 받아오지 못했습니다") + } + } else { + throw Exception(response.message ?: "이미지 업로드 실패") + } + } + } +} diff --git a/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt b/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt index 55cc30b..79b98b4 100644 --- a/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt +++ b/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt @@ -3,11 +3,12 @@ package com.example.kuit6_android_api.di import com.example.kuit6_android_api.data.api.ApiService import com.example.kuit6_android_api.data.api.RetrofitClient import com.example.kuit6_android_api.data.repository.PostRepository +import com.example.kuit6_android_api.data.repository.PostRepositoryImpl class AppContainer { //모든 의존성을 AppContainer 한 곳에서 관리하게 함 val apiService: ApiService = RetrofitClient.apiService //ApiService를 AppContainer에서 한 번만 가져 와 Repository에 주입 // 원래 ApiService를 직접 참조하던 ViewModel들이 Repository를 참조 - val postRepository: PostRepository = PostRepository(apiService) + val postRepository: PostRepository = PostRepositoryImpl(apiService) } \ No newline at end of file diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt b/app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt index bbd692b..cb02fd1 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt @@ -11,6 +11,9 @@ import com.example.kuit6_android_api.ui.post.screen.PostCreateScreen import com.example.kuit6_android_api.ui.post.screen.PostDetailScreen import com.example.kuit6_android_api.ui.post.screen.PostEditScreen import com.example.kuit6_android_api.ui.post.screen.PostListScreen +import com.example.kuit6_android_api.ui.post.viewmodel.PostCreateViewModel +import com.example.kuit6_android_api.ui.post.viewmodel.PostDetailViewModel +import com.example.kuit6_android_api.ui.post.viewmodel.PostEditViewModel import com.example.kuit6_android_api.ui.post.viewmodel.PostListViewModel import com.example.kuit6_android_api.ui.post.viewmodel.postViewModelFactory @@ -47,6 +50,7 @@ fun NavGraph( onEditClick = { postId -> navController.navigate(PostEditRoute(postId)) }, + viewModel = viewModel(factory = postViewModelFactory { PostDetailViewModel(it) }), snackBarState = snackBarState ) } @@ -59,6 +63,7 @@ fun NavGraph( onPostCreated = { navController.popBackStack() }, + viewModel = viewModel(factory = postViewModelFactory { PostCreateViewModel(it) }), snackBarState = snackBarState ) } @@ -74,6 +79,7 @@ fun NavGraph( onPostUpdated = { navController.popBackStack() }, + viewModel = viewModel(factory = postViewModelFactory { PostEditViewModel(it) }), snackBarState = snackBarState ) } 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 be6a63e..2f3a1d2 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 @@ -51,12 +51,10 @@ 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 -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage -import com.example.kuit6_android_api.di.AppContainer import com.example.kuit6_android_api.ui.post.viewmodel.PostCreateViewModel +import com.example.kuit6_android_api.ui.post.viewmodel.postViewModelFactory import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -64,17 +62,7 @@ import kotlinx.coroutines.launch fun PostCreateScreen( onNavigateBack: () -> Unit, onPostCreated: () -> Unit, - viewModel: PostCreateViewModel = viewModel( - //레포지토리 패턴을 위해 레포지토리를 뷰모델에 파라미터로 전달 - //레포지토리는 수동 주입(App Container)을 통해 가져옴 - //뷰모델에 파라미터를 전달하기 위해서 Factory 패턴을 사용 - factory = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - val appContainer = AppContainer() - return PostCreateViewModel(appContainer.postRepository) as T - } - } - ), + viewModel: PostCreateViewModel = viewModel(factory = postViewModelFactory { PostCreateViewModel(it) }), snackBarState: SnackbarHostState ) { val context = LocalContext.current diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt index 509b3c1..856a727 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt @@ -44,12 +44,10 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage -import com.example.kuit6_android_api.di.AppContainer import com.example.kuit6_android_api.ui.post.viewmodel.PostDetailViewModel +import com.example.kuit6_android_api.ui.post.viewmodel.postViewModelFactory import com.example.kuit6_android_api.util.formatDateTime import kotlinx.coroutines.launch @@ -59,14 +57,7 @@ fun PostDetailScreen( postId: Long, onNavigateBack: () -> Unit, onEditClick: (Long) -> Unit = {}, - viewModel: PostDetailViewModel = viewModel( - factory = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - val appContainer = AppContainer() - return PostDetailViewModel(appContainer.postRepository) as T - } - } - ), + viewModel: PostDetailViewModel = viewModel(factory = postViewModelFactory { PostDetailViewModel(it) }), snackBarState: SnackbarHostState ) { val uiState = viewModel.uiState 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 8ce347a..82c3848 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 @@ -50,12 +50,10 @@ 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 -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage -import com.example.kuit6_android_api.di.AppContainer import com.example.kuit6_android_api.ui.post.viewmodel.PostEditViewModel +import com.example.kuit6_android_api.ui.post.viewmodel.postViewModelFactory import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -64,14 +62,7 @@ fun PostEditScreen( postId: Long, onNavigateBack: () -> Unit, onPostUpdated: () -> Unit, - viewModel: PostEditViewModel = viewModel( - factory = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - val appContainer = AppContainer() - return PostEditViewModel(appContainer.postRepository) as T - } - } - ), + viewModel: PostEditViewModel = viewModel(factory = postViewModelFactory { PostEditViewModel(it) }), snackBarState: SnackbarHostState ) { val context = LocalContext.current diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt index 6d298f1..43832c6 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt @@ -26,26 +26,17 @@ import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel -import com.example.kuit6_android_api.di.AppContainer import com.example.kuit6_android_api.ui.post.component.PostItem import com.example.kuit6_android_api.ui.post.viewmodel.PostListViewModel +import com.example.kuit6_android_api.ui.post.viewmodel.postViewModelFactory @OptIn(ExperimentalMaterial3Api::class) @Composable fun PostListScreen( onPostClick: (Long) -> Unit, onCreatePostClick: () -> Unit, - viewModel: PostListViewModel = viewModel( - factory = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - val appContainer = AppContainer() - return PostListViewModel(appContainer.postRepository) as T - } - } - ) + viewModel: PostListViewModel = viewModel(factory = postViewModelFactory { PostListViewModel(it) }) ) { val uiState = viewModel.uiState val posts = uiState.posts From 924862d8bbe497dc53bb29deee4a1f89de7a3c26 Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 14:54:48 +0900 Subject: [PATCH 12/17] =?UTF-8?q?docs:=20ViewModel=20=EC=88=98=EB=8F=99=20?= =?UTF-8?q?=EC=A3=BC=EC=9E=85=20=EA=B4=80=EB=A0=A8=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 각 화면(Screen)에서 `ViewModel`을 주입받는 부분에 `postViewModelFactory`를 통해 `Repository`가 수동으로 주입된다는 내용의 주석을 추가했습니다. * `PostCreateScreen.kt` * `PostDetailScreen.kt` * `PostEditScreen.kt` * `PostViewModelFactory.kt` --- .../kuit6_android_api/ui/post/screen/PostCreateScreen.kt | 1 + .../kuit6_android_api/ui/post/screen/PostDetailScreen.kt | 1 + .../kuit6_android_api/ui/post/screen/PostEditScreen.kt | 1 + .../ui/post/viewmodel/PostViewModelFactory.kt | 6 +++++- 4 files changed, 8 insertions(+), 1 deletion(-) 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 2f3a1d2..48997f1 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 @@ -62,6 +62,7 @@ import kotlinx.coroutines.launch fun PostCreateScreen( onNavigateBack: () -> Unit, onPostCreated: () -> Unit, + // Repository는 postViewModelFactory를 통해 수동 주입(App Container)됩니다 viewModel: PostCreateViewModel = viewModel(factory = postViewModelFactory { PostCreateViewModel(it) }), snackBarState: SnackbarHostState ) { diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt index 856a727..5a83bde 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt @@ -57,6 +57,7 @@ fun PostDetailScreen( postId: Long, onNavigateBack: () -> Unit, onEditClick: (Long) -> Unit = {}, + // Repository는 postViewModelFactory를 통해 수동 주입(App Container)됩니다 viewModel: PostDetailViewModel = viewModel(factory = postViewModelFactory { PostDetailViewModel(it) }), snackBarState: SnackbarHostState ) { 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 82c3848..4978458 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 @@ -62,6 +62,7 @@ fun PostEditScreen( postId: Long, onNavigateBack: () -> Unit, onPostUpdated: () -> Unit, + // Repository는 postViewModelFactory를 통해 수동 주입(App Container)됩니다 viewModel: PostEditViewModel = viewModel(factory = postViewModelFactory { PostEditViewModel(it) }), snackBarState: SnackbarHostState ) { diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt index 6dcdcfc..96bc814 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt @@ -7,7 +7,11 @@ import androidx.lifecycle.viewmodel.viewModelFactory import com.example.kuit6_android_api.App import com.example.kuit6_android_api.data.repository.PostRepository - +/** + * Repository 패턴을 위해 Repository를 ViewModel에 파라미터로 전달하는 Factory + * Repository는 수동 주입(App Container)을 통해 가져오며, + * ViewModel에 파라미터를 전달하기 위해 Factory 패턴을 사용합니다. + */ inline fun postViewModelFactory( crossinline create: (PostRepository) -> VM ): ViewModelProvider.Factory = viewModelFactory { From f8173336318ced2f3effe64ce20afb0e267610e7 Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 16:13:13 +0900 Subject: [PATCH 13/17] =?UTF-8?q?refactor:=20ViewModel=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20Repository=20=ED=8C=A8=ED=84=B4=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존의 단일 `PostViewModel`을 기능별로 분리하여 각 화면의 역할을 명확히 했습니다. 또한, `PostRepository`를 도입하여 데이터 소스를 추상화하고 ViewModel과 데이터 계층 간의 의존성을 분리했습니다. * **Repository 추가 및 수정**: * `PostRepository`: 게시글 CRUD(생성, 상세 조회, 수정, 삭제) 및 이미지 업로드 기능을 위한 메서드를 추가했습니다. * `PostRepositoryImpl`: 추가된 인터페이스 메서드들을 구현했습니다. * **ViewModel 분리**: * `PostViewModel`을 삭제하고, 기능에 따라 아래의 ViewModel들로 분리했습니다. * `PostCreateViewModel`: 게시글 생성 및 이미지 업로드 기능을 담당합니다. * `PostDetailViewModel`: 게시글 상세 조회 및 삭제 기능을 담당합니다. * `PostEditViewModel`: 게시글 수정, 상세 조회 및 이미지 업로드 기능을 담당합니다. * **의존성 주입(DI)**: * `AppContainer`: `PostRepository`의 인스턴스를 생성하고 관리하여 ViewModel에 주입하는 역할을 합니다. * `NavGraph`: 각 화면(`PostCreateScreen`, `PostDetailScreen`, `PostEditScreen`)에서 `AppContainer`를 통해 해당 화면에 맞는 ViewModel을 주입받도록 수정했습니다. --- BRANCH_DIFFERENCES.md | 300 ++++++++++++++++++ PROJECT_CONTEXT.md | 69 ++++ .../ui/post/screen/PostCreateScreen.kt | 2 +- .../ui/post/viewmodel/PostCreateViewModel.kt | 4 +- 4 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 BRANCH_DIFFERENCES.md create mode 100644 PROJECT_CONTEXT.md diff --git a/BRANCH_DIFFERENCES.md b/BRANCH_DIFFERENCES.md new file mode 100644 index 0000000..13fe180 --- /dev/null +++ b/BRANCH_DIFFERENCES.md @@ -0,0 +1,300 @@ +# practice-only 브랜치와 JeongIlhyuk/week7 브랜치 차이점 정리 + +## 변경 요약 +- **추가된 파일**: 4개 +- **수정된 파일**: 10개 +- **삭제된 파일**: 1개 + +--- + +## 1. 추가된 파일 (A) + +### 1.1 `app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt` +**새로 생성된 파일** +- 의존성 주입을 위한 AppContainer 클래스 추가 +- ApiService와 PostRepository를 한 곳에서 관리 +- Repository 패턴 구현을 위한 의존성 관리 + +**주요 내용:** +```kotlin +class AppContainer { + val apiService: ApiService = RetrofitClient.apiService + val postRepository: PostRepository = PostRepositoryImpl(apiService) +} +``` + +### 1.2 `app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt` +**새로 생성된 파일** +- PostViewModel을 기능별로 분리한 ViewModel 중 하나 +- 게시글 생성 관련 로직 담당 +- PostCreateUiState를 통한 상태 관리 + +**주요 기능:** +- `createPost()`: 게시글 생성 +- `uploadImage()`: 이미지 업로드 +- `clearUploadedImageUrl()`: 업로드된 이미지 URL 초기화 + +### 1.3 `app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostDetailViewModel.kt` +**새로 생성된 파일** +- 게시글 상세 조회 및 삭제 관련 ViewModel +- PostDetailUiState를 통한 상태 관리 + +**주요 기능:** +- `getPostDetail()`: 게시글 상세 조회 +- `deletePost()`: 게시글 삭제 + +### 1.4 `app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostEditViewModel.kt` +**새로 생성된 파일** +- 게시글 수정 관련 ViewModel +- PostEditUiState를 통한 상태 관리 + +**주요 기능:** +- `getPostDetail()`: 게시글 상세 조회 +- `updatePost()`: 게시글 수정 +- `uploadImage()`: 이미지 업로드 +- `clearUploadedImageUrl()`: 업로드된 이미지 URL 초기화 + +--- + +## 2. 삭제된 파일 (D) + +### 2.1 `app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModel.kt` +**삭제된 파일** +- 기존의 통합 PostViewModel이 기능별로 분리됨 +- PostCreateViewModel, PostDetailViewModel, PostEditViewModel로 분리 +- ApiService를 직접 참조하던 구조에서 Repository 패턴으로 변경 + +--- + +## 3. 수정된 파일 (M) + +### 3.1 `app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt` + +**변경 사항:** +1. **Import 추가:** + - `PostCreateRequest` 추가 + - `okhttp3.MultipartBody` 추가 + +2. **인터페이스 포맷팅:** + - `PostRepository{` → `PostRepository {` (공백 추가) + +3. **메서드 추가:** + - `getPostDetail(postId: Long): Result` - 게시글 상세 조회 + - `createPost(author, title, content, imageUrl): Result` - 게시글 생성 + - `updatePost(postId, title, content, imageUrl): Result` - 게시글 수정 + - `deletePost(postId: Long): Result` - 게시글 삭제 + - `uploadImage(file: MultipartBody.Part): Result` - 이미지 업로드 + +4. **파일 끝 개행 추가** + +### 3.2 `app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepositoryImpl.kt` + +**변경 사항:** +1. **Import 추가:** + - `PostCreateRequest` 추가 + - `BaseResponse` 추가 + - `okhttp3.MultipartBody` 추가 + +2. **클래스 포맷팅:** + - `PostRepositoryImpl (` → `PostRepositoryImpl(` (공백 제거) + - `): PostRepository {` → `) : PostRepository {` (공백 추가) + +3. **getPosts() 메서드 개선:** + - 반환 타입 명시: `BaseResponse>` + - 에러 메시지 변경: "게시긆 불러오기 실패" → "게시글 목록 조회 실패" + - 포맷팅 개선 (공백 추가) + +4. **새로운 메서드 구현 추가:** + - `getPostDetail()`: 게시글 상세 조회 구현 + - `createPost()`: 게시글 생성 구현 + - `updatePost()`: 게시글 수정 구현 + - `deletePost()`: 게시글 삭제 구현 + - `uploadImage()`: 이미지 업로드 구현 + +5. **파일 끝 개행 추가** + +### 3.3 `app/src/main/java/com/example/kuit6_android_api/ui/navigation/NavGraph.kt` + +**변경 사항:** +1. **Import 추가:** + - `PostCreateViewModel` 추가 + - `PostDetailViewModel` 추가 + - `PostEditViewModel` 추가 + +2. **PostDetailScreen에 ViewModel 주입:** + ```kotlin + viewModel = viewModel(factory = postViewModelFactory { PostDetailViewModel(it) }) + ``` + +3. **PostCreateScreen에 ViewModel 주입:** + ```kotlin + viewModel = viewModel(factory = postViewModelFactory { PostCreateViewModel(it) }) + ``` + +4. **PostEditScreen에 ViewModel 주입:** + ```kotlin + viewModel = viewModel(factory = postViewModelFactory { PostEditViewModel(it) }) + ``` + +### 3.4 `app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostCreateScreen.kt` + +**변경 사항:** +1. **Import 변경:** + - `PostViewModel` → `PostCreateViewModel` + - `postViewModelFactory` 추가 + +2. **ViewModel 타입 변경:** + - `PostViewModel` → `PostCreateViewModel` + - Factory를 통한 의존성 주입 추가 + +3. **상태 접근 방식 변경:** + - `viewModel.isUploading` → `uiState.isUploading` + - `viewModel.uploadedImageUrl` → `uiState.uploadedImageUrl` + - `val uiState = viewModel.uiState` 추가 + +4. **주석 추가:** + - Repository 주입 방식에 대한 설명 주석 추가 + +### 3.5 `app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostDetailScreen.kt` + +**변경 사항:** +1. **Import 변경:** + - `PostViewModel` → `PostDetailViewModel` + - `postViewModelFactory` 추가 + +2. **ViewModel 타입 변경:** + - `PostViewModel` → `PostDetailViewModel` + - Factory를 통한 의존성 주입 추가 + +3. **상태 접근 방식 변경:** + - `viewModel.postDetail` → `uiState.postDetail` + - `val uiState = viewModel.uiState` 추가 + +4. **LaunchedEffect 주석 해제:** + - `viewModel.getPostDetail(postId)` 호출 활성화 + +5. **주석 추가:** + - Repository 주입 방식에 대한 설명 주석 추가 + +### 3.6 `app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostEditScreen.kt` + +**변경 사항:** +1. **Import 변경:** + - `PostViewModel` → `PostEditViewModel` + - `postViewModelFactory` 추가 + +2. **ViewModel 타입 변경:** + - `PostViewModel` → `PostEditViewModel` + - Factory를 통한 의존성 주입 추가 + +3. **상태 접근 방식 변경:** + - `viewModel.postDetail` → `uiState.postDetail` + - `viewModel.uploadedImageUrl` → `uiState.uploadedImageUrl` + - `viewModel.isUploading` → `uiState.isUploading` + - `val uiState = viewModel.uiState` 추가 + +4. **주석 추가:** + - Repository 주입 방식에 대한 설명 주석 추가 + +### 3.7 `app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt` + +**변경 사항:** +1. **Import 변경:** + - `PostViewModel` 제거 + - `PostListUiState` 제거 + - `collectAsState` 제거 + - `DisposableEffect`, `LocalLifecycleOwner`, `Lifecycle`, `LifecycleEventObserver` 추가 + - `postViewModelFactory` 추가 + +2. **ViewModel 주입 방식 변경:** + - Factory를 통한 의존성 주입 추가 + - 기본값으로 ViewModel 생성 + +3. **상태 관리 방식 변경:** + - `StateFlow` 기반 → `mutableStateOf` 기반으로 변경 + - `collectAsState()` 제거, 직접 `uiState` 접근 + +4. **Lifecycle 관리 추가:** + - `DisposableEffect`를 사용한 Lifecycle 이벤트 감지 + - `ON_RESUME` 이벤트 시 자동 새로고침 + +5. **UI 구조 단순화:** + - `PostListUiState`의 sealed class 구조 제거 + - Loading, Success, Error 상태 분기 제거 + - 단순한 LazyColumn으로 변경 + +6. **LaunchedEffect 추가:** + - 초기 로드 시 `viewModel.refresh()` 호출 + +### 3.8 `app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt` + +**변경 사항:** +1. **Import 변경:** + - `PostListUiState` 제거 (파일 내부로 이동) + - `MutableStateFlow`, `StateFlow`, `asStateFlow` 제거 + - `mutableStateOf`, `getValue`, `setValue` 추가 + - `PostResponse` 추가 + +2. **상태 관리 방식 변경:** + - `StateFlow` 기반 → `mutableStateOf` 기반으로 변경 + - `_uiState`와 `uiState` 분리 구조 제거 + +3. **PostListUiState 정의 변경:** + - sealed class에서 data class로 변경 + - Loading, Success, Error 상태 제거 + - 단순히 `posts: List`만 포함 + +4. **생성자 파라미터 이름 변경:** + - `postRepository` → `repository` + +5. **초기화 로직 변경:** + - `init` 블록 제거 + - `loadPosts()` 메서드 제거 + - `refresh()` 메서드로 통합 + +6. **에러 처리 단순화:** + - Error 상태 제거, 실패 시 빈 리스트로 설정 + +7. **파일 끝 개행 추가** + +### 3.9 `app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostViewModelFactory.kt` + +**변경 사항:** +1. **주석 추가:** + - Repository 패턴과 수동 주입(App Container)에 대한 설명 주석 추가 + - Factory 패턴 사용 목적 설명 + +### 3.10 `gradle/libs.versions.toml` + +**변경 사항:** +1. **AGP 버전 업데이트:** + - `agp = "8.13.0"` → `agp = "8.13.1"` + +--- + +## 주요 아키텍처 변경 사항 + +### 1. Repository 패턴 도입 +- ApiService를 직접 참조하던 구조에서 Repository를 통한 추상화 +- 의존성 주입을 통한 테스트 용이성 향상 + +### 2. ViewModel 분리 +- 단일 `PostViewModel`을 기능별로 분리: + - `PostListViewModel`: 목록 조회 + - `PostCreateViewModel`: 게시글 생성 + - `PostDetailViewModel`: 상세 조회 및 삭제 + - `PostEditViewModel`: 게시글 수정 + +### 3. 상태 관리 방식 변경 +- `StateFlow` 기반 → `mutableStateOf` 기반 +- Sealed class 기반 상태 → Data class 기반 상태 +- UI 상태를 UiState data class로 명확히 정의 + +### 4. 의존성 주입 구조 +- `AppContainer`를 통한 중앙 집중식 의존성 관리 +- Factory 패턴을 통한 ViewModel 생성 시 Repository 주입 + +### 5. Lifecycle 관리 개선 +- `DisposableEffect`를 사용한 화면 재진입 시 자동 새로고침 +- Lifecycle 이벤트 기반 데이터 갱신 + diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md new file mode 100644 index 0000000..5e151ac --- /dev/null +++ b/PROJECT_CONTEXT.md @@ -0,0 +1,69 @@ +# 프로젝트 컨텍스트 및 개발 가이드라인 + +## 브랜치 구조 + +### practice-only 브랜치 +- **의미**: 미션 반영 전 상태 +- **내용**: 강의자가 실습한 코드를 그대로 구현한 것 +- **역할**: 기준점(baseline)으로 사용 +- **중요**: 이 브랜치의 코드 스타일과 패턴을 크게 벗어나지 않아야 함 + +### JeongIlhyuk/week7 브랜치 +- **의미**: 미션 반영 후 상태 +- **내용**: 미션 요구사항을 반영한 코드 +- **역할**: 미션 완료 버전 + +--- + +## 미션 요구사항 (체크리스트) + +- [x] 수동 주입(App Container) 구현 +- [x] Repository 패턴 사용 +- [x] ViewModel 분리 + - PostEditViewModel + - PostDetailViewModel (삭제까지 같이 하시면 됩니다) + - PostCreateViewModel +- [x] UiState 구현 +- [x] PostViewModel 삭제 +- [x] PostListViewModel의 refresh 함수를 메인화면에서 sideEffect로 항상 실행시키기 + +--- + +## 개발 원칙 + +### ⚠️ 중요 사항 +1. **미션 요구사항에 벗어나는 불필요한 변경 금지** + - 미션에서 요구하지 않은 변경사항은 만들지 않기 + - 예: StateFlow → mutableStateOf 변경은 미션 요구사항이 아님 (단순히 선택된 방식) + +2. **practice-only 브랜치의 코드 스타일 유지** + - 강의자가 실습한 코드의 패턴과 스타일을 존중 + - 불필요한 리팩토링이나 스타일 변경 지양 + +3. **변경사항 검증** + - 모든 변경사항이 미션 요구사항과 직접적으로 연관되어 있는지 확인 + - practice-only 브랜치와 비교하여 불필요한 차이점이 없는지 확인 + +--- + +## 브랜치 비교 시 주의사항 + +### 비교 방법 +```bash +# practice-only (미션 전) vs JeongIlhyuk/week7 (미션 후) +git diff practice-only JeongIlhyuk/week7 +``` + +### 확인해야 할 사항 +1. 변경사항이 미션 요구사항과 직접 연관이 있는가? +2. practice-only의 코드 스타일을 크게 벗어나지 않는가? +3. 불필요한 리팩토링이나 개선이 포함되어 있지 않은가? + +--- + +## 참고사항 + +- practice-only 브랜치는 강의자의 실습 코드이므로, 이를 기준으로 미션만 반영해야 함 +- 미션 요구사항 외의 "개선"이나 "최적화"는 지양 +- 코드 스타일, 네이밍, 구조는 practice-only 브랜치를 따르는 것을 원칙으로 함 + 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 48997f1..e7cc4c1 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 @@ -62,7 +62,7 @@ import kotlinx.coroutines.launch fun PostCreateScreen( onNavigateBack: () -> Unit, onPostCreated: () -> Unit, - // Repository는 postViewModelFactory를 통해 수동 주입(App Container)됩니다 + // Repository는 postViewModelFactory를 통해 수동 주입(App Container) viewModel: PostCreateViewModel = viewModel(factory = postViewModelFactory { PostCreateViewModel(it) }), snackBarState: SnackbarHostState ) { diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt index 34eeb67..a0c3acf 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt @@ -15,8 +15,7 @@ import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody //PostViewModel을 PostListViewModel, PostCreateViewModel, PostDetailViewModel, PostEditViewModel로 분리 -//uiState를 통해 상태를 한 번에 모아 처리 -data class PostCreateUiState( +data class PostCreateUiState( //uiState를 통해 상태를 한 번에 모아 처리 val uploadedImageUrl: String? = null, val isUploading: Boolean = false ) @@ -24,6 +23,7 @@ data class PostCreateUiState( class PostCreateViewModel( private val repository: PostRepository ) : ViewModel() { + //st var uiState by mutableStateOf(PostCreateUiState()) private set From 25834c3325dfa814b2ffe999a163ae550a0f8f3b Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 16:20:54 +0900 Subject: [PATCH 14/17] =?UTF-8?q?refactor:=20StateFlow=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EB=B0=8F=20UI=20=EC=83=81=ED=83=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PostListViewModel`에서 `StateFlow`를 사용하여 UI 상태를 관리하도록 변경하고, `PostListScreen`에서는 로딩, 성공, 에러 상태에 따라 다른 UI를 표시하도록 개선했습니다. * **PostListViewModel.kt**: * 기존 `mutableStateOf`로 관리하던 `uiState`를 `StateFlow`로 변경했습니다. * 게시글 목록 조회 결과에 따라 `Loading`, `Success`, `Error` 상태를 갖는 `PostListUiState`를 `emit`하도록 수정했습니다. * 게시글을 불러오는 `loadPosts()` 함수를 추가하고, `refresh()`에서 이를 호출하도록 변경했습니다. * **PostListScreen.kt**: * `collectAsState()`를 사용하여 `ViewModel`의 `uiState`를 구독하도록 변경했습니다. * `when` 문을 사용하여 `PostListUiState`의 상태(Loading, Success, Error)에 따라 `CircularProgressIndicator` 또는 `LazyColumn`을 표시하도록 UI 로직을 수정했습니다. * 기존 `DisposableEffect`를 사용한 생명주기 기반의 새로고침 로직을 제거하고, `LaunchedEffect`를 사용하여 화면 진입 시 데이터를 불러오도록 단순화했습니다. --- .../ui/post/screen/PostListScreen.kt | 61 ++++++++----------- .../ui/post/viewmodel/PostListViewModel.kt | 31 +++++----- 2 files changed, 44 insertions(+), 48 deletions(-) diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt index 43832c6..1d8ab8f 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt @@ -18,16 +18,14 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel import com.example.kuit6_android_api.ui.post.component.PostItem +import com.example.kuit6_android_api.ui.post.state.PostListUiState import com.example.kuit6_android_api.ui.post.viewmodel.PostListViewModel import com.example.kuit6_android_api.ui.post.viewmodel.postViewModelFactory @@ -38,24 +36,9 @@ fun PostListScreen( onCreatePostClick: () -> Unit, viewModel: PostListViewModel = viewModel(factory = postViewModelFactory { PostListViewModel(it) }) ) { - val uiState = viewModel.uiState - val posts = uiState.posts - val lifecycleOwner = LocalLifecycleOwner.current - - DisposableEffect(lifecycleOwner) {//lifecycleOwner가 변경될 때마다 실행 - //LaunchedEffect와 달리 화면이 처음 나타날 때뿐만 아니라 다시 나타날 때도 실행 - val observer = LifecycleEventObserver { _, event -> //lifecycle 이벤트를 감지하는 옵저버 - //event로 상태 변화 받음 - if (event == Lifecycle.Event.ON_RESUME) {//화면이 다시 활성화되면 - viewModel.refresh() - } - } - lifecycleOwner.lifecycle.addObserver(observer)//화면 상태가 바뀔 때마다 알림을 받기 위해 옵저버 등록 - onDispose {//화면이 사라질 때 - lifecycleOwner.lifecycle.removeObserver(observer) - } - } + val uiState by viewModel.uiState.collectAsState() + // 미션 요구사항: refresh 함수를 메인화면에서 sideEffect로 항상 실행시키기 LaunchedEffect(Unit) { viewModel.refresh() } @@ -72,19 +55,29 @@ fun PostListScreen( } } ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .background(MaterialTheme.colorScheme.background), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - items(posts) { post -> - PostItem( - post = post, - onClick = { onPostClick(post.id) } - ) + when (uiState) { + is PostListUiState.Loading -> { + CircularProgressIndicator() + } + is PostListUiState.Success -> { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + items((uiState as PostListUiState.Success).posts) { post -> + PostItem( + post = post, + onClick = { onPostClick(post.id) } + ) + } + } + } + is PostListUiState.Error -> { + // Error 상태 처리 (practice-only와 동일하게 빈 상태) } } } diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt index 7040496..117499b 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostListViewModel.kt @@ -1,33 +1,36 @@ package com.example.kuit6_android_api.ui.post.viewmodel -import androidx.compose.runtime.getValue -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.repository.PostRepository -import com.example.kuit6_android_api.data.model.response.PostResponse +import com.example.kuit6_android_api.ui.post.state.PostListUiState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -data class PostListUiState( - val posts: List = emptyList() -) - class PostListViewModel( private val repository: PostRepository ) : ViewModel() { - var uiState by mutableStateOf(PostListUiState()) - private set + private val _uiState = MutableStateFlow(PostListUiState.Loading) + val uiState: StateFlow = _uiState.asStateFlow() - fun refresh() { + private fun loadPosts() { viewModelScope.launch { + _uiState.value = PostListUiState.Loading repository.getPosts() .onSuccess { posts -> - uiState = uiState.copy(posts = posts) + _uiState.value = PostListUiState.Success(posts) } - .onFailure { - uiState = uiState.copy(posts = emptyList()) + .onFailure { error -> + _uiState.value = PostListUiState.Error( + message = error.message ?: "error" + ) } } } + + fun refresh() { + loadPosts() + } } From e64229ac86b73a014c206d55328d81e99fd8d61f Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 17:05:34 +0900 Subject: [PATCH 15/17] =?UTF-8?q?refactor:=20AppContainer=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=A3=BC=EC=9E=85=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DI 컨테이너인 `AppContainer`의 패키지 경로를 `di`에서 `data/di`로 변경하여 데이터 계층의 일부임을 명확히 했습니다. 또한, `lazy` 초기화를 사용하여 `AppContainer` 내에서 `ApiService`와 `PostRepository`의 인스턴스 생성을 최적화했습니다. * **`AppContainer` 패키지 이동**: * 기존 `di/AppContainer.kt` 파일을 `data/di/AppContainer.kt`로 이동했습니다. * **`lazy` 초기화 적용**: * `AppContainer` 내의 `apiService`와 `postRepository` 프로퍼티를 `lazy`를 사용하여 초기화하도록 수정했습니다. 이를 통해 해당 인스턴스들이 실제로 처음 사용될 때 생성되도록 변경했습니다. * **`PostRepository` 인터페이스 확장**: * `getPostDetail`, `createPost`, `deletePost`, `editPost` 함수를 추가하여 게시글의 상세 조회 및 CRUD 기능을 위한 메서드를 정의했습니다. --- BRANCH_DIFFERENCES.md | 32 +++++++++++-------- .../kuit6_android_api/data/di/AppContainer.kt | 6 +++- .../data/repository/PostRepository.kt | 1 + .../kuit6_android_api/di/AppContainer.kt | 14 -------- .../ui/post/viewmodel/PostCreateViewModel.kt | 1 + 5 files changed, 25 insertions(+), 29 deletions(-) delete mode 100644 app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt diff --git a/BRANCH_DIFFERENCES.md b/BRANCH_DIFFERENCES.md index 13fe180..b86a660 100644 --- a/BRANCH_DIFFERENCES.md +++ b/BRANCH_DIFFERENCES.md @@ -121,20 +121,23 @@ class AppContainer { - `PostDetailViewModel` 추가 - `PostEditViewModel` 추가 -2. **PostDetailScreen에 ViewModel 주입:** +2. **PostDetailScreen에서 Factory를 통해 Repository를 ViewModel에 주입:** ```kotlin viewModel = viewModel(factory = postViewModelFactory { PostDetailViewModel(it) }) ``` + - Factory가 AppContainer에서 Repository를 가져와 ViewModel 생성자에 전달 -3. **PostCreateScreen에 ViewModel 주입:** +3. **PostCreateScreen에서 Factory를 통해 Repository를 ViewModel에 주입:** ```kotlin viewModel = viewModel(factory = postViewModelFactory { PostCreateViewModel(it) }) ``` + - Factory가 AppContainer에서 Repository를 가져와 ViewModel 생성자에 전달 -4. **PostEditScreen에 ViewModel 주입:** +4. **PostEditScreen에서 Factory를 통해 Repository를 ViewModel에 주입:** ```kotlin viewModel = viewModel(factory = postViewModelFactory { PostEditViewModel(it) }) ``` + - Factory가 AppContainer에서 Repository를 가져와 ViewModel 생성자에 전달 ### 3.4 `app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostCreateScreen.kt` @@ -145,7 +148,7 @@ class AppContainer { 2. **ViewModel 타입 변경:** - `PostViewModel` → `PostCreateViewModel` - - Factory를 통한 의존성 주입 추가 + - Factory를 통해 Repository를 ViewModel에 주입 3. **상태 접근 방식 변경:** - `viewModel.isUploading` → `uiState.isUploading` @@ -164,7 +167,7 @@ class AppContainer { 2. **ViewModel 타입 변경:** - `PostViewModel` → `PostDetailViewModel` - - Factory를 통한 의존성 주입 추가 + - Factory를 통해 Repository를 ViewModel에 주입 3. **상태 접근 방식 변경:** - `viewModel.postDetail` → `uiState.postDetail` @@ -185,7 +188,7 @@ class AppContainer { 2. **ViewModel 타입 변경:** - `PostViewModel` → `PostEditViewModel` - - Factory를 통한 의존성 주입 추가 + - Factory를 통해 Repository를 ViewModel에 주입 3. **상태 접근 방식 변경:** - `viewModel.postDetail` → `uiState.postDetail` @@ -206,8 +209,8 @@ class AppContainer { - `DisposableEffect`, `LocalLifecycleOwner`, `Lifecycle`, `LifecycleEventObserver` 추가 - `postViewModelFactory` 추가 -2. **ViewModel 주입 방식 변경:** - - Factory를 통한 의존성 주입 추가 +2. **Factory를 통한 Repository 주입 추가:** + - Factory가 AppContainer에서 Repository를 가져와 ViewModel 생성자에 전달 - 기본값으로 ViewModel 생성 3. **상태 관리 방식 변경:** @@ -285,16 +288,17 @@ class AppContainer { - `PostDetailViewModel`: 상세 조회 및 삭제 - `PostEditViewModel`: 게시글 수정 -### 3. 상태 관리 방식 변경 -- `StateFlow` 기반 → `mutableStateOf` 기반 -- Sealed class 기반 상태 → Data class 기반 상태 -- UI 상태를 UiState data class로 명확히 정의 +### 3. UiState 구현 +- 각 ViewModel에 UiState data class 추가 (PostCreateUiState, PostDetailUiState, PostEditUiState) +- PostListViewModel은 practice-only와 동일하게 StateFlow + sealed class 기반 PostListUiState 유지 +- UI 상태를 UiState로 명확히 정의하여 상태 관리 일관성 향상 ### 4. 의존성 주입 구조 - `AppContainer`를 통한 중앙 집중식 의존성 관리 - Factory 패턴을 통한 ViewModel 생성 시 Repository 주입 +- ViewModel이 Repository를 파라미터로 받아 사용 (의존성 주입) ### 5. Lifecycle 관리 개선 -- `DisposableEffect`를 사용한 화면 재진입 시 자동 새로고침 -- Lifecycle 이벤트 기반 데이터 갱신 +- `LaunchedEffect`를 사용한 초기 로드 시 refresh 함수 실행 (미션 요구사항) +- PostListScreen에서 화면 진입 시 자동으로 데이터 새로고침 diff --git a/app/src/main/java/com/example/kuit6_android_api/data/di/AppContainer.kt b/app/src/main/java/com/example/kuit6_android_api/data/di/AppContainer.kt index cf4ecc4..13ce606 100644 --- a/app/src/main/java/com/example/kuit6_android_api/data/di/AppContainer.kt +++ b/app/src/main/java/com/example/kuit6_android_api/data/di/AppContainer.kt @@ -5,12 +5,16 @@ import com.example.kuit6_android_api.data.api.RetrofitClient import com.example.kuit6_android_api.data.repository.PostRepository import com.example.kuit6_android_api.data.repository.PostRepositoryImpl -class AppContainer { +class AppContainer { //모든 의존성을 AppContainer 한 곳에서 관리하게 함 private val apiService: ApiService by lazy{ RetrofitClient.apiService } val postRepository: PostRepository by lazy{ + //ApiService를 AppContainer에서 한 번만 가져 와 Repository에 주입 + + // 원래:뷰모델 -> ApiService 직접 참조 + // 현재:뷰모델 -> Repository -> ApiService Repository 거쳐 참조 PostRepositoryImpl(apiService) } } \ No newline at end of file diff --git a/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt index 763366b..0b045b9 100644 --- a/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt +++ b/app/src/main/java/com/example/kuit6_android_api/data/repository/PostRepository.kt @@ -6,6 +6,7 @@ import okhttp3.MultipartBody interface PostRepository { suspend fun getPosts(): Result> + //레포지토리 패턴을 사용하기 위해 다음 함수들을 추가 suspend fun getPostDetail(postId: Long): Result suspend fun createPost( author: String, diff --git a/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt b/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt deleted file mode 100644 index a8d0d3d..0000000 --- a/app/src/main/java/com/example/kuit6_android_api/di/AppContainer.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.example.kuit6_android_api.di - -import com.example.kuit6_android_api.data.api.ApiService -import com.example.kuit6_android_api.data.api.RetrofitClient -import com.example.kuit6_android_api.data.repository.PostRepository -import com.example.kuit6_android_api.data.repository.PostRepositoryImpl - -class AppContainer { - //모든 의존성을 AppContainer 한 곳에서 관리하게 함 - val apiService: ApiService = RetrofitClient.apiService - //ApiService를 AppContainer에서 한 번만 가져 와 Repository에 주입 - // 원래 ApiService를 직접 참조하던 ViewModel들이 Repository를 참조 - val postRepository: PostRepository = PostRepositoryImpl(apiService) -} diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt index a0c3acf..de192e1 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/viewmodel/PostCreateViewModel.kt @@ -22,6 +22,7 @@ data class PostCreateUiState( //uiState를 통해 상태를 한 번에 모아 class PostCreateViewModel( private val repository: PostRepository + // 의존성 주입:뷰모델의 파라미터로 Repository를 전달하는 것 ) : ViewModel() { //st var uiState by mutableStateOf(PostCreateUiState()) From 874812ce38a61b451791bf2706e969b13a24827f Mon Sep 17 00:00:00 2001 From: Zach Date: Thu, 13 Nov 2025 17:12:24 +0900 Subject: [PATCH 16/17] =?UTF-8?q?docs:=20PostListScreen=EC=97=90=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `PostListScreen`의 `LaunchedEffect` 블록에 Side Effect에 대한 설명을 주석으로 추가하여 코드의 가독성을 높였습니다. --- .../example/kuit6_android_api/ui/post/screen/PostListScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt index 1d8ab8f..236f8cb 100644 --- a/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt +++ b/app/src/main/java/com/example/kuit6_android_api/ui/post/screen/PostListScreen.kt @@ -38,8 +38,9 @@ fun PostListScreen( ) { val uiState by viewModel.uiState.collectAsState() - // 미션 요구사항: refresh 함수를 메인화면에서 sideEffect로 항상 실행시키기 LaunchedEffect(Unit) { + // Side Effect:UI 렌더링 외의 작업 + // UI를 직접 그리지 않고 변화하게 했으므로 Side Effect viewModel.refresh() } From 8e1e0353230ef9adcc63e04e61c7245055cb29a4 Mon Sep 17 00:00:00 2001 From: Chae Minji <162430621+alswlekk@users.noreply.github.com> Date: Fri, 14 Nov 2025 11:00:20 +0900 Subject: [PATCH 17/17] Add configuration for automatic reviews and exclusions --- .coderabbit.yaml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .coderabbit.yaml diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..24636cb --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,34 @@ +version: 1 + +reviews: + auto_review: true # 자동 리뷰 활성화 + review_status: true # 체크 메시지 표시 + max_review_comments: 50 + + # 모든 브랜치 PR에 대해 자동으로 리뷰 + triggers: + pull_request: true # PR 생성 시 자동 리뷰 + pull_request_branches: # 어떤 브랜치든지 허용 + include: + - "*" # 모든 브랜치 + push: false + pull_request_review: false + +style: + tone: professional + detail_level: medium + include_line_comments: true + +exclude: + paths: + - "*.md" + - "*.png" + - "*.jpg" + - "*.gif" + - "*.svg" + - "build/**" + - "gradle/**" + +model: + provider: openai + name: gpt-4o-mini