diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c455dc2..7a520aae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/path_provider.xml b/app/src/main/res/xml/path_provider.xml new file mode 100644 index 00000000..5b5e8874 --- /dev/null +++ b/app/src/main/res/xml/path_provider.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/presentation/src/main/java/com/whyranoid/presentation/component/button/WalkieBottomSheetButton.kt b/presentation/src/main/java/com/whyranoid/presentation/component/button/WalkieBottomSheetButton.kt new file mode 100644 index 00000000..e817b175 --- /dev/null +++ b/presentation/src/main/java/com/whyranoid/presentation/component/button/WalkieBottomSheetButton.kt @@ -0,0 +1,41 @@ +package com.whyranoid.presentation.component.button + +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.whyranoid.presentation.theme.WalkieTypography + +@Composable +fun WalkieBottomSheetButton( + modifier: Modifier = Modifier, + buttonText: String, + onClick: () -> Unit = {} +) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(shape = RoundedCornerShape(10.dp)) + .border(width = 1.dp, color = Color(0xFFE4E4E4), shape = RoundedCornerShape(10.dp)) + .clickable { onClick() } + ) { + Text( + textAlign = TextAlign.Center, + text = buttonText, + style = WalkieTypography.Body1_Normal, + modifier = Modifier + .align(Alignment.Center) + .padding(vertical = 6.dp) + ) + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/whyranoid/presentation/component/button/WalkieCircularIconButton.kt b/presentation/src/main/java/com/whyranoid/presentation/component/button/WalkieCircularIconButton.kt new file mode 100644 index 00000000..1bd01c35 --- /dev/null +++ b/presentation/src/main/java/com/whyranoid/presentation/component/button/WalkieCircularIconButton.kt @@ -0,0 +1,44 @@ +package com.whyranoid.presentation.component.button + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +@Composable +fun CircularIconButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + contentDescription: String? = null, + icon: ImageVector, + tint: Color = Color.White, + backgroundColor: Color = Color.Gray, + iconSize: Dp = 24.dp, + buttonSize: Dp = 48.dp, +) { + Box( + modifier = modifier + .size(buttonSize) + .clip(CircleShape) + .background(backgroundColor) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = contentDescription, + tint = tint, + modifier = Modifier.size(iconSize), + ) + } +} \ No newline at end of file diff --git a/presentation/src/main/java/com/whyranoid/presentation/screens/mypage/editprofile/EditProfileScreen.kt b/presentation/src/main/java/com/whyranoid/presentation/screens/mypage/editprofile/EditProfileScreen.kt index e6226943..117d3e82 100644 --- a/presentation/src/main/java/com/whyranoid/presentation/screens/mypage/editprofile/EditProfileScreen.kt +++ b/presentation/src/main/java/com/whyranoid/presentation/screens/mypage/editprofile/EditProfileScreen.kt @@ -1,8 +1,12 @@ package com.whyranoid.presentation.screens.mypage.editprofile +import android.Manifest +import android.net.Uri import android.widget.Toast +import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image -import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.Box @@ -14,11 +18,18 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetState +import androidx.compose.material.ModalBottomSheetValue import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -37,48 +48,140 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController import coil.compose.AsyncImage import com.whyranoid.domain.util.EMPTY import com.whyranoid.presentation.R +import com.whyranoid.presentation.component.button.CircularIconButton +import com.whyranoid.presentation.component.button.WalkieBottomSheetButton import com.whyranoid.presentation.component.button.WalkiePositiveButton import com.whyranoid.presentation.reusable.WalkieTextField import com.whyranoid.presentation.theme.WalkieTypography +import com.whyranoid.presentation.util.createImageFile import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import org.koin.androidx.compose.koinViewModel +import java.util.Objects +@OptIn(ExperimentalMaterialApi::class) @Composable fun EditProfileScreen(navController: NavController) { val viewModel = koinViewModel() val walkieId = viewModel.walkieId.collectAsStateWithLifecycle(initialValue = 0L) val name = viewModel.name.collectAsStateWithLifecycle(initialValue = String.EMPTY) val nick = viewModel.nick.collectAsStateWithLifecycle(initialValue = String.EMPTY) - val profileImg = viewModel.profileImg.collectAsStateWithLifecycle(initialValue = String.EMPTY) - - EditProfileContent( - walkieId = walkieId.value ?: 0L, - name = name.value.orEmpty(), - nick = nick.value.orEmpty(), - profileImg = profileImg.value.orEmpty(), - viewModel = viewModel, + var capturedUri by remember { mutableStateOf(Uri.EMPTY) } + val context = LocalContext.current + + LaunchedEffect(viewModel.profileImg) { + viewModel.profileImg.collectLatest { + it?.let { url -> viewModel.setProfileUrl(url) } + } + } + + val file = context.createImageFile() + val uri = FileProvider.getUriForFile( + Objects.requireNonNull(context), + context.packageName + ".provider", file + ) + + val cameraLauncher = rememberLauncherForActivityResult(contract = ActivityResultContracts.TakePicture()) { + capturedUri = uri + viewModel.setProfileUrl(uri.toString()) + } + + val cameraPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { + if (it) { + cameraLauncher.launch(uri) + } else { + // 권한 거부시 + } + } + + val bottomSheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden + ) + + val coroutineScope = rememberCoroutineScope() + + BackHandler(enabled = bottomSheetState.isVisible) { + coroutineScope.launch { + bottomSheetState.hide() + } + } + + ModalBottomSheetLayout( + sheetState = bottomSheetState, + sheetShape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), + sheetContent = { + Column( + modifier = Modifier + .wrapContentHeight() + .padding(start = 20.dp, end = 20.dp, top = 18.dp, bottom = 26.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "프로필 사진 변경", + style = WalkieTypography.SubTitle + ) + + Spacer(modifier = Modifier.height(15.dp)) + + WalkieBottomSheetButton( + buttonText = "새 프로필 사진 찍기", + onClick = { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + WalkieBottomSheetButton( + buttonText = "앨범에서 프로필 사진 가져오기", + onClick = { + // 앨범 실행 + } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + WalkieBottomSheetButton( + buttonText = "현재 프로필 사진 삭제", + onClick = { } + ) + } + } ) { - navController.popBackStack() + EditProfileContent( + walkieId = walkieId.value ?: 0L, + name = name.value.orEmpty(), + nick = nick.value.orEmpty(), + bottomSheetState = bottomSheetState, + viewModel = viewModel, + ) { + navController.popBackStack() + } } + + } +@OptIn(ExperimentalMaterialApi::class) @Composable fun EditProfileContent( walkieId: Long, name: String, nick: String, - profileImg: String, + bottomSheetState: ModalBottomSheetState, viewModel: EditProfileViewModel, popBackStack: () -> Unit ) { @@ -86,10 +189,28 @@ fun EditProfileContent( val scrollState = rememberScrollState() val coroutineScope = rememberCoroutineScope() val focusRequester = remember { FocusRequester() } + val focusManager = LocalFocusManager.current val keyboardHeight = WindowInsets.ime.getBottom(LocalDensity.current) val isDuplicateNickName by viewModel.isDuplicateNickName.collectAsStateWithLifecycle() + val currentProfileImg by viewModel.currentProfileUrl.collectAsStateWithLifecycle() var isChangeEnabled by remember { mutableStateOf(false) } + var initialProfileImg by remember { mutableStateOf(null) } + LaunchedEffect(viewModel.profileImg) { + viewModel.profileImg.collectLatest { + initialProfileImg = it + } + } + + if (currentProfileImg != null) { + LaunchedEffect(currentProfileImg) { + if (currentProfileImg != initialProfileImg) { + isChangeEnabled = true + } + } + } + + LaunchedEffect(viewModel.isMyInfoChanged) { viewModel.isMyInfoChanged.collectLatest { Toast.makeText(context, "정보가 수정되었습니다.", Toast.LENGTH_SHORT).show() @@ -141,7 +262,7 @@ fun EditProfileContent( .size(90.dp), ) { AsyncImage( - model = profileImg, + model = currentProfileImg, contentDescription = "프로필 이미지", contentScale = ContentScale.Crop, modifier = Modifier @@ -151,7 +272,11 @@ fun EditProfileContent( CircularIconButton( modifier = Modifier.align(Alignment.BottomEnd), - onClick = { /* 버튼 클릭 시 동작 */ }, + onClick = { + coroutineScope.launch { + bottomSheetState.show() + } + }, contentDescription = "프로필 편집", icon = ImageVector.vectorResource(id = R.drawable.ic_edit_icon), tint = Color(0xFF999999), @@ -229,9 +354,9 @@ fun EditProfileContent( ), modifier = Modifier .clickable { - // 닉네임 중복 api 연결 viewModel.checkDuplicateNickName(nickName) // 성공 시 editMode 해제 + focusManager.clearFocus() } ) } @@ -254,38 +379,10 @@ fun EditProfileContent( text = "변경", isEnabled = isChangeEnabled, onClicked = { - viewModel.changeMyInfo(walkieId, nickName, profileImg) + viewModel.changeMyInfo(walkieId, nickName, currentProfileImg) } ) Spacer(modifier = Modifier.height(20.dp)) } -} - -@Composable -fun CircularIconButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - contentDescription: String? = null, - icon: ImageVector, - tint: Color = Color.White, - backgroundColor: Color = Color.Gray, - iconSize: Dp = 24.dp, - buttonSize: Dp = 48.dp, -) { - Box( - modifier = modifier - .size(buttonSize) - .clip(CircleShape) - .background(backgroundColor) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = contentDescription, - tint = tint, - modifier = Modifier.size(iconSize), - ) - } -} +} \ No newline at end of file diff --git a/presentation/src/main/java/com/whyranoid/presentation/screens/mypage/editprofile/EditProfileViewModel.kt b/presentation/src/main/java/com/whyranoid/presentation/screens/mypage/editprofile/EditProfileViewModel.kt index 2bd05d9e..7872537e 100644 --- a/presentation/src/main/java/com/whyranoid/presentation/screens/mypage/editprofile/EditProfileViewModel.kt +++ b/presentation/src/main/java/com/whyranoid/presentation/screens/mypage/editprofile/EditProfileViewModel.kt @@ -21,6 +21,9 @@ class EditProfileViewModel( private val _isMyInfoChanged = MutableSharedFlow() val isMyInfoChanged = _isMyInfoChanged.asSharedFlow() + private val _currentProfileUrl = MutableStateFlow(null) + val currentProfileUrl = _currentProfileUrl.asStateFlow() + val name = accountRepository.userName val nick = accountRepository.nickName val profileImg = accountRepository.profileUrl @@ -38,4 +41,8 @@ class EditProfileViewModel( accountRepository.changeMyInfo(walkieId, nickName, profileUrl) .onSuccess { _isMyInfoChanged.emit(true) } } + + fun setProfileUrl(url: String) { + _currentProfileUrl.update { url } + } } diff --git a/presentation/src/main/java/com/whyranoid/presentation/util/extensions.kt b/presentation/src/main/java/com/whyranoid/presentation/util/extensions.kt index bc9f7f46..d86de8dc 100644 --- a/presentation/src/main/java/com/whyranoid/presentation/util/extensions.kt +++ b/presentation/src/main/java/com/whyranoid/presentation/util/extensions.kt @@ -8,6 +8,10 @@ import android.net.Uri import android.os.Build import android.provider.Settings import androidx.annotation.RequiresApi +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale import kotlin.math.min import kotlin.random.Random @@ -73,3 +77,14 @@ fun Activity.openStatusBar() { e.printStackTrace() } } + +fun Context.createImageFile(): File { + val timeStamp = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val imageFileName = "JPEG_" + timeStamp + "_" + val image = File.createTempFile( + imageFileName, + ".jpg", + externalCacheDir + ) + return image +} \ No newline at end of file