일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- jvm 작동 원리
- 2022 플러터 설치
- 객체
- jvm이란
- Rxjava Observable
- ANR이란
- 멤버변수
- 자바 다형성
- 서비스 쓰레드 차이
- 안드로이드 유닛 테스트
- android retrofit login
- 안드로이드 os 구조
- 플러터 설치 2022
- 안드로이드 레트로핏 crud
- 스택 자바 코드
- 안드로이드 라이선스
- 스택 큐 차이
- 안드로이드 유닛 테스트 예시
- 서비스 vs 쓰레드
- 2022 플러터 안드로이드 스튜디오
- 큐 자바 코드
- rxjava hot observable
- android ar 개발
- ar vr 차이
- 클래스
- 안드로이드 라이선스 종류
- rxjava disposable
- 안드로이드 레트로핏 사용법
- rxjava cold observable
- 안드로이드 유닛테스트란
- Today
- Total
나만을 위한 블로그
[Android Compose] Supabase를 활용한 CRUD 구현 - 2 - 본문
※ 모든 코드는 예시 코드기 때문에 실제로 사용하려면 반드시 리팩토링한다
이전 포스팅에서 이어진다.
https://onlyfor-me-blog.tistory.com/1129
코드는 supabase의 안드로이드 예시 문서를 바탕으로 작성했다.
그러나 SQL부터 막히고 문서의 코드들이 하나의 프로젝트로 완벽하게 작동하지 않아서 좀 수정했다.
https://supabase.com/docs/guides/getting-started/tutorials/with-kotlin
그 전에 먼저 supabase 대시보드에서 Storage를 만들어야 한다. 이 Storage에는 카메라나 갤러리를 통해 업로드한 사진들이 저장된다. 이미지는 Storage URL 형태로 supabase에 저장되며 URL은 앞서 만든 products 테이블의 image 컬럼에 저장된다. 앱에서 조회할 때 사용할 것이기 때문에 꼭 만들어 둔다.
대시보드에서 Storage를 누르면 아래 화면이 나올 것이다.
왼쪽 상단의 New bucket을 누르면 아래 화면이 나온다. 여기에 products라고 입력한다.
이제 프로젝트에 hilt, Supabase, ktor 라이브러리들을 추가하고 코드들을 추가한다. hilt 적용은 생략한다.
implementation(platform("io.github.jan-tennert.supabase:bom:2.4.0"))
implementation("io.github.jan-tennert.supabase:gotrue-kt")
implementation("io.github.jan-tennert.supabase:postgrest-kt")
implementation("io.github.jan-tennert.supabase:storage-kt")
implementation("io.ktor:ktor-client-android:2.3.0")
implementation("io.ktor:ktor-utils:2.3.0")
implementation("io.ktor:ktor-client-core:2.3.0")
hilt를 사용하기 때문에 supabase를 쓰기 위한 module을 선언한다.
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import io.github.jan.supabase.SupabaseClient
import io.github.jan.supabase.gotrue.Auth
import io.github.jan.supabase.gotrue.FlowType
import io.github.jan.supabase.gotrue.auth
import io.github.jan.supabase.createSupabaseClient
import io.github.jan.supabase.postgrest.Postgrest
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.storage.Storage
import io.github.jan.supabase.storage.storage
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object SupaBaseModule {
@Provides
@Singleton
fun provideSupaBaseClient(): SupabaseClient =
createSupabaseClient(
supabaseUrl = BuildConfig.SUPABASE_URL,
supabaseKey = BuildConfig.SUPABASE_ANON_KEY
) {
install(Postgrest)
install(Auth) {
flowType = FlowType.PKCE
scheme = "app"
host = "supabase.com"
}
install(Storage)
}
@Provides
@Singleton
fun provideSupaBaseDatabase(client: SupabaseClient): Postgrest = client.postgrest
@Provides
@Singleton
fun provideSupaBaseAuth(client: SupabaseClient): Auth = client.auth
@Provides
@Singleton
fun provideSupaBaseStorage(client: SupabaseClient): Storage = client.storage
}
이제 갤러리에서 사진을 가져오기 위해 xml 폴더에 file_paths.xml을 만들고 매니페스트에 등록한다.
위치는 어디든 상관없는데 난 개인적으로 닫는 application 태그 쪽에 가깝게 두는 걸 선호해서 마지막에 두었다.
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path
name="cache"
path="/" />
</paths>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
권한도 추가한다.
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
이제 data, domain, presentation 패키지로 나눠서 코드들을 작성할 것이다. 완성 후 아래와 같은 형태로 보이면 된다.
아래는 data 패키지 안에 있는 ProductDto다.
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ProductDto(
@SerialName("id")
val id: String,
@SerialName("name")
val name: String,
@SerialName("price")
val price: Double,
@SerialName("image")
val image: String?,
)
domain에 ProductDto와 대응되는 data class를 만든다.
data class ProductEntity(
val id: String,
val name: String,
val price: Double,
val image: String?,
)
그리고 ProductMapper를 구현한다. 특별한 기능은 없다.
import com.example.composepractice.supabase.data.model.ProductDto
import com.example.composepractice.supabase.domain.entity.ProductEntity
fun ProductDto.toEntity(): ProductEntity = ProductEntity(
id = this.id,
name = this.name,
price = this.price,
image = this.image,
)
그리고 domain 패키지 안에 ProductRepository 인터페이스를 만든다. CRUD 함수들의 원형을 제공한다.
import com.example.composepractice.supabase.domain.entity.ProductEntity
import kotlinx.coroutines.flow.Flow
interface ProductRepository {
suspend fun createProduct(product: ProductEntity, imageFile: ByteArray?): Boolean
suspend fun updateProduct(product: ProductEntity, imageFile: ByteArray?): Boolean
suspend fun deleteProduct(productId: String): Boolean
fun getProducts(): Flow<List<ProductEntity>>
}
이 인터페이스의 구현은 data 패키지에 만든다.
storage 설정 시 이름을 products가 아닌 다른 걸로 설정했다면 그 이름을 사용하면 된다.
import com.example.composepractice.BuildConfig
import com.example.composepractice.supabase.data.mapper.toEntity
import com.example.composepractice.supabase.data.model.ProductDto
import com.example.composepractice.supabase.domain.entity.ProductEntity
import com.example.composepractice.supabase.domain.repository.ProductRepository
import io.github.jan.supabase.postgrest.Postgrest
import io.github.jan.supabase.storage.Storage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
class ProductRepositoryImpl @Inject constructor(
private val postgrest: Postgrest,
private val storage: Storage,
): ProductRepository {
override suspend fun createProduct(product: ProductEntity, imageFile: ByteArray?): Boolean {
return try {
val imageUrl = imageFile?.let {
uploadImage(product.id, it)
}
val dto = ProductDto(
id = product.id,
name = product.name,
price = product.price,
image = imageUrl
)
postgrest["products"].insert(dto)
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
override suspend fun updateProduct(productEntity: ProductEntity, imageFile: ByteArray?): Boolean {
return try {
val imageUrl = imageFile?.let {
uploadImage(productEntity.id, it)
} ?: productEntity.image
postgrest["products"].update({
set("name", productEntity.name)
set("price", productEntity.price)
if (imageUrl != null) set("image", imageUrl)
}) {
filter {
eq("id", productEntity.id)
}
}
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
override suspend fun deleteProduct(productId: String): Boolean {
return try {
postgrest["products"].delete {
filter {
eq("id", productId)
}
}
storage["products"].delete("$productId.png")
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
override fun getProducts(): Flow<List<ProductEntity>> = flow {
try {
val response = postgrest["products"].select()
.decodeList<ProductDto>()
emit(response.map { it.toEntity() })
} catch (e: Exception) {
e.printStackTrace()
emit(emptyList())
}
}
private suspend fun uploadImage(productId: String, imageFile: ByteArray): String {
val imagePath = "$productId.png"
return try {
storage["products"].upload(
path = imagePath,
data = imageFile,
upsert = true // 기존 파일 덮어쓰기 허용
)
buildImageUrl(imagePath)
} catch (e: Exception) {
e.printStackTrace()
throw e
}
}
private fun buildImageUrl(filePath: String): String =
"${BuildConfig.SUPABASE_URL}/storage/v1/object/public/products/$filePath"
}
이걸로 data 패키지의 파일들은 완성됐다. domain에서 usecase들을 만든다. 역시 복잡한 기능은 없다.
import com.example.composepractice.supabase.domain.entity.ProductEntity
import com.example.composepractice.supabase.domain.repository.ProductRepository
import javax.inject.Inject
class CreateProductUseCase @Inject constructor(
private val repository: ProductRepository
) {
suspend operator fun invoke(productEntity: ProductEntity, imageFile: ByteArray? = null): Boolean {
return try {
repository.createProduct(productEntity, imageFile)
} catch (e: Exception) {
e.printStackTrace()
false
}
}
}
import com.example.composepractice.supabase.domain.repository.ProductRepository
import javax.inject.Inject
class DeleteProductUseCase @Inject constructor(
private val repository: ProductRepository
) {
suspend operator fun invoke(productId: String) = repository.deleteProduct(productId)
}
import com.example.composepractice.supabase.domain.entity.ProductEntity
import com.example.composepractice.supabase.domain.repository.ProductRepository
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class GetProductsUseCase @Inject constructor(
private val repository: ProductRepository
) {
operator fun invoke(): Flow<List<ProductEntity>> = repository.getProducts()
}
class UpdateProductUseCase @Inject constructor(
private val repository: ProductRepository
) {
suspend operator fun invoke(productEntity: ProductEntity, imageFile: ByteArray? = null) =
repository.updateProduct(productEntity, imageFile)
}
이 usecase들을 뷰모델에 주입할 건데 그러기 위해 usecase들의 의존성을 제공하는 모듈을 object로 만들어야 한다.
@Module
@InstallIn(SingletonComponent::class)
object UseCaseModule {
@Provides
@Singleton
fun provideCreateProductUseCase(repository: ProductRepository): CreateProductUseCase =
CreateProductUseCase(repository)
@Provides
@Singleton
fun provideUpdateProductUseCase(repository: ProductRepository): UpdateProductUseCase =
UpdateProductUseCase(repository)
@Provides
@Singleton
fun provideDeleteProductUseCase(repository: ProductRepository): DeleteProductUseCase =
DeleteProductUseCase(repository)
@Provides
@Singleton
fun provideGetProductsUseCase(repository: ProductRepository): GetProductsUseCase =
GetProductsUseCase(repository)
}
usecase들의 의존성을 주입받을 준비가 됐으니 presentation 패키지에 뷰모델을 구성한다.
getProductById()는 만들어두고 쓰지 않았는데 필요하면 쓰면 된다.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.composepractice.supabase.domain.entity.ProductEntity
import com.example.composepractice.supabase.domain.usecase.CreateProductUseCase
import com.example.composepractice.supabase.domain.usecase.DeleteProductUseCase
import com.example.composepractice.supabase.domain.usecase.GetProductsUseCase
import com.example.composepractice.supabase.domain.usecase.UpdateProductUseCase
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class ProductViewModel @Inject constructor(
private val createProductUseCase: CreateProductUseCase,
private val updateProductUseCase: UpdateProductUseCase,
private val deleteProductUseCase: DeleteProductUseCase,
private val getProductsUseCase: GetProductsUseCase,
) : ViewModel() {
private val _products = MutableStateFlow<List<ProductEntity>>(emptyList())
val products: StateFlow<List<ProductEntity>> = _products.asStateFlow()
private val _operationResult = MutableStateFlow<Boolean?>(null)
val operationResult = _operationResult.asStateFlow()
fun getProducts() = viewModelScope.launch {
getProductsUseCase().collect { products ->
_products.value = products.toList()
}
}
fun getProductById(productId: String): ProductEntity? {
return products.value.find { it.id == productId }
}
fun createProduct(product: ProductEntity, imageFile: ByteArray? = null) = viewModelScope.launch {
val result = createProductUseCase(product, imageFile)
_operationResult.value = result
if (result) getProducts()
}
fun updateProduct(product: ProductEntity, imageFile: ByteArray? = null) = viewModelScope.launch {
val result = updateProductUseCase(product, imageFile)
_operationResult.value = result
if (result) getProducts()
}
fun deleteProduct(productId: String) = viewModelScope.launch {
val result = deleteProductUseCase(productId)
_operationResult.value = result
if (result) getProducts()
}
fun setOperationResultTo(value: Boolean?) {
_operationResult.value = value
}
}
이제 view 폴더에 화면들을 만든다. 먼저 화면을 이동할 것이기 때문에 라우트 정의를 해준다.
object NavigationRoutes {
const val PRODUCT_LIST = "product_list"
const val PRODUCT_DETAIL = "product_detail/{productId}"
const val CREATE_PRODUCT = "create_product?productId={productId}"
}
import androidx.compose.runtime.Composable
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
@Composable
fun SupabaseNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = NavigationRoutes.PRODUCT_LIST) {
composable(NavigationRoutes.PRODUCT_LIST) {
ProductListScreen(navController)
}
composable(
route = NavigationRoutes.PRODUCT_DETAIL,
arguments = listOf(navArgument("productId") { type = NavType.StringType })
) { backStackEntry ->
val productId = backStackEntry.arguments?.getString("productId") ?: ""
ProductDetailScreen(
navController = navController,
productId = productId
)
}
composable(
route = NavigationRoutes.CREATE_PRODUCT,
arguments = listOf(navArgument("productId") {
type = NavType.StringType
nullable = true
})
) { backStackEntry ->
val productId = backStackEntry.arguments?.getString("productId")
CreateProductScreen(
navController = navController,
productId = productId
)
}
}
}
SupabaseNavigation()은 메인 액티비티에서 호출하면 된다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Scaffold
import androidx.compose.ui.Modifier
import com.example.composepractice.supabase.presentation.view.SupabaseNavigation
import com.example.composepractice.ui.theme.ComposePracticeTheme
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
ComposePracticeTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
SupabaseNavigation()
}
}
}
}
}
이제 화면들을 만든다.
import android.widget.Toast
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import coil.compose.rememberAsyncImagePainter
import com.example.composepractice.supabase.presentation.viewmodel.ProductViewModel
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun ProductListScreen(
navController: NavController,
productViewModel: ProductViewModel = hiltViewModel()
) {
val context = LocalContext.current
val products by productViewModel.products.collectAsState()
val operationResult by productViewModel.operationResult.collectAsState()
LaunchedEffect(Unit) {
productViewModel.getProducts()
}
LaunchedEffect(operationResult) {
operationResult?.let {
if (it) {
Toast.makeText(context, "삭제 완료", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "삭제 실패", Toast.LENGTH_SHORT).show()
}
productViewModel.setOperationResultTo(null)
}
}
Scaffold(
floatingActionButton = {
FloatingActionButton(
onClick = { navController.navigate("create_product") }
) {
Icon(Icons.Default.Add, contentDescription = "Add Product")
}
}
) { paddingValues ->
LazyColumn(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.padding(paddingValues)
) {
items(products, key = { it.id }) { product ->
val imageUrl = remember(product.image) {
"${product.image}?timestamp=${System.currentTimeMillis()}"
}
Row(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(
onClick = {
navController.navigate("product_detail/${product.id}")
},
onLongClick = {
productViewModel.deleteProduct(product.id)
}
)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.Top
) {
Image(
painter = rememberAsyncImagePainter(imageUrl),
contentDescription = null,
modifier = Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp))
.background(Color.Gray)
.aspectRatio(1f),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(20.dp))
Column(
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = product.name,
style = MaterialTheme.typography.bodyLarge
)
Text(
text = "${product.price}원",
style = MaterialTheme.typography.bodyMedium
)
}
}
}
}
}
}
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.pm.PackageManager
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import coil.compose.rememberAsyncImagePainter
import com.example.composepractice.R
import com.example.composepractice.supabase.domain.entity.ProductEntity
import com.example.composepractice.supabase.presentation.viewmodel.ProductViewModel
import java.io.File
import java.util.UUID
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")
@Composable
fun CreateProductScreen(
modifier: Modifier = Modifier,
productId: String?,
navController: NavController,
productViewModel: ProductViewModel = hiltViewModel()
) {
val context = LocalContext.current
val products by productViewModel.products.collectAsState()
LaunchedEffect(Unit) {
productViewModel.getProducts()
}
val product = products.find { it.id == productId }
var name by remember(product) { mutableStateOf(product?.name ?: "") }
var price by remember(product) { mutableStateOf(product?.price?.toString() ?: "") }
var imageUri by remember(product) { mutableStateOf(product?.image ?: "") }
val operationResult by productViewModel.operationResult.collectAsState()
var showImagePicker by remember { mutableStateOf(false) }
Scaffold {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = if (imageUri.isNotEmpty()) rememberAsyncImagePainter(imageUri) else painterResource(
id = R.drawable.ic_launcher_foreground
),
contentDescription = "product image",
modifier = Modifier
.size(200.dp)
.clip(RoundedCornerShape(10.dp))
.clickable {
showImagePicker = true
}
)
Spacer(modifier = Modifier.height(20.dp))
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
value = price,
onValueChange = { price = it },
label = { Text("Price") },
modifier = Modifier.fillMaxWidth(),
keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number)
)
Spacer(modifier = Modifier.height(20.dp))
Button(
onClick = {
if (name.isNotBlank() && price.isNotBlank()) {
val productEntity = ProductEntity(
id = productId ?: UUID.randomUUID().toString(),
name = name,
price = price.toDouble(),
image = imageUri
)
val imageByteArray = uriToByteArray(context, Uri.parse(imageUri))
if (productId != null) {
productViewModel.updateProduct(productEntity, imageByteArray)
} else {
productViewModel.createProduct(productEntity, imageByteArray)
}
}
}
) {
Text("저장")
}
LaunchedEffect(operationResult) {
operationResult?.let {
if (it) {
Toast.makeText(context, "저장 성공", Toast.LENGTH_SHORT).show()
navController.popBackStack()
} else {
Toast.makeText(context, "저장 실패", Toast.LENGTH_SHORT).show()
}
productViewModel.setOperationResultTo(null)
}
}
}
if (showImagePicker) {
LaunchCameraOrGallery { uri ->
imageUri = uri
showImagePicker = false
}
}
}
}
private fun uriToByteArray(context: Context, uri: Uri): ByteArray? {
return try {
context.contentResolver.openInputStream(uri)?.use { inputStream ->
inputStream.readBytes()
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
@Composable
private fun LaunchCameraOrGallery(
onImagePicked: (String) -> Unit
) {
val context = LocalContext.current
var pendingPhotoUri by remember { mutableStateOf<Uri?>(null) }
var showDialog by remember { mutableStateOf(true) }
val cameraLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.TakePicture()) { success ->
if (success) {
pendingPhotoUri?.let { uri ->
onImagePicked(uri.toString())
pendingPhotoUri = null
}
}
}
val galleryLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
uri?.let { onImagePicked(it.toString()) }
}
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
pendingPhotoUri?.let { uri ->
cameraLauncher.launch(uri)
}
} else {
Toast.makeText(context, "카메라 권한이 필요합니다.", Toast.LENGTH_SHORT).show()
}
}
if (showDialog) {
AlertDialog(
onDismissRequest = {
showDialog = false
},
title = { Text("이미지 선택") },
text = {
Column {
Button(
onClick = {
val file = File(context.cacheDir, "${System.currentTimeMillis()}.jpg")
val photoUri =
FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
pendingPhotoUri = photoUri
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
) {
cameraLauncher.launch(photoUri)
} else {
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
}
},
modifier = Modifier.fillMaxWidth()
) {
Text("카메라")
}
Button(
onClick = {
galleryLauncher.launch("image/*")
},
modifier = Modifier.fillMaxWidth()
) {
Text("갤러리")
}
}
},
confirmButton = {}
)
}
}
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import coil.compose.rememberAsyncImagePainter
import com.example.composepractice.supabase.presentation.viewmodel.ProductViewModel
@Composable
fun ProductDetailScreen(
modifier: Modifier = Modifier,
navController: NavController,
productId: String,
productViewModel: ProductViewModel = hiltViewModel()
) {
val products by productViewModel.products.collectAsState()
LaunchedEffect(Unit) {
productViewModel.getProducts()
}
val product = products.find { it.id == productId }
val imageUrl = remember(product?.image) {
product?.image?.let { "$it?timestamp=${System.currentTimeMillis()}" }
}
if (product != null) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Image(
painter = rememberAsyncImagePainter(imageUrl),
contentDescription = "Product Image",
modifier = Modifier
.size(200.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surface),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.height(20.dp))
Text("name: ${product.name}", style = MaterialTheme.typography.bodyLarge)
Text("price: ${product.price}", style = MaterialTheme.typography.bodyMedium)
Spacer(modifier = Modifier.height(20.dp))
Button(
onClick = { navController.navigate("create_product?productId=${product.id}") }
) {
Text("수정하기")
}
}
} else {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Text(
"데이터가 없습니다",
style = MaterialTheme.typography.bodyMedium.copy(
color = Color.Red
)
)
}
}
}
이제 실행해서 확인한다. 처음 실행하면 아무 데이터도 저장돼 있지 않기 때문에 빈 화면이 표시된다.
오른쪽 밑의 fab를 눌러 작성 화면으로 이동한다.
안드로이드 아이콘을 누르면 카메라, 갤러리 중 하나를 선택할 수 있는 다이얼로그가 표시된다.
갤러리는 photo picker를 쓰기 때문에 별도의 권한 요청이 없지만 카메라는 권한 허용을 해야 쓸 수 있다.
먼저 카메라부터 확인할 건데, 에뮬에서 카메라 기능을 사용할 때 간단한 팁들이 있다.
- 시프트를 누르고 wasd를 누르면 앞, 뒤, 왼쪽, 오른쪽으로 이동할 수 있다. q를 누르면 화면이 정면을 바라보는 채로 아래로 내려가고 e를 누르면 위로 올라간다
- 시프트를 누르고 에뮬레이터 안에서 마우스를 움직이면 시점을 움직일 수 있다
- 카메라 화면 우측 하단의 점 3개 버튼을 눌러 맨 오른쪽 버튼을 누르면 전면, 후면 카메라를 전환할 수 있다
이제 카메라에서 사진을 찍고 name, price도 적당히 설정한다.
저장을 누르면 첫 화면인 리스트 화면으로 되돌아가는데 사진이 완전히 표시되기까지 이미지 영역이 회색으로 표시될 것이다.
supabase 대시보드에서 Table editor를 선택하고, 왼쪽의 products 테이블을 선택하면 입력했던 대로 데이터가 들어가 있는 걸 볼 수 있다. image 컬럼의 링크를 복사해서 주소창에 붙여넣거나 컨트롤 + 클릭하면 첨부했던 이미지가 들어가 있는 걸 볼 수 있다.
리스트의 아이템을 클릭하면 상세 화면으로 이동한다.
수정하기 버튼을 누르면 생성 화면으로 이동하는데 이 때 값을 물고 이동하기 때문에 사진, name, price가 입력했던 값들로 채워져 있다.
이번엔 갤러리에서 가져온다. name, price는 수정했단 걸 알기 위해 적당히 수정한다.
수정하기를 눌러 데이터를 수정한 후 저장을 누르면 리스트 화면이 아닌 상세화면으로 이동한다.
이 때 뒤로가면 리스트 화면으로 이동하며 수정한 대로 데이터가 표시되는 걸 볼 수 있다.
대시보드를 새로고침하면 수정한 값으로 채워져 있는 걸 볼 수 있다.
이미지 링크도 확인해 보면 수정한 사진으로 표시되는 걸 확인할 수 있다.
아이템 삭제는 리스트 화면에서 아이템을 롱클릭해서 수행한다.
대시보드를 새로고침하면 데이터가 없기 때문에 아래 화면이 표시된다.
'Android > Compose' 카테고리의 다른 글
[Android Compose] Supabase를 활용한 CRUD 구현 - 1 - (0) | 2025.01.06 |
---|---|
[Android Compose] LazyColumn을 활용한 복수 선택 기능 구현 (0) | 2024.12.22 |
[Android Compose] 무한 캐러셀 구현하는 법 (0) | 2024.12.21 |
[Android Compose] 이미지 압축 구현하기 (0) | 2024.12.08 |
[Android Compose] Shared Element Transition 구현 요소 알아보기 (0) | 2024.12.08 |