diff --git a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendInitMapper.kt b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendInitMapper.kt index 0efe1617..ae570310 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendInitMapper.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/mapper/FriendInitMapper.kt @@ -1,5 +1,6 @@ package com.alarmy.near.data.mapper +import com.alarmy.near.local.contact.ContactImageData import com.alarmy.near.model.Anniversary import com.alarmy.near.model.ContactFrequency import com.alarmy.near.model.DayOfWeek @@ -7,6 +8,7 @@ import com.alarmy.near.model.Friend import com.alarmy.near.model.Relation import com.alarmy.near.model.ReminderInterval import com.alarmy.near.network.request.ContactFrequencyInitRequest +import com.alarmy.near.network.request.ImageUploadRequest import com.alarmy.near.network.request.FriendInitItemRequest import com.alarmy.near.network.response.AnniversaryInitEntity import com.alarmy.near.network.response.ContactFrequencyInitEntity @@ -27,7 +29,10 @@ private fun String.formatPhoneNumber(): String = PhoneNumberFormatter.formatPhon /** * UI 모델을 서버 요청 모델로 변환 */ -fun FriendContactUIModel.toFriendInitItemRequest(providerType: String): FriendInitItemRequest = +fun FriendContactUIModel.toFriendInitItemRequest( + providerType: String, + imageUploadRequest: ImageUploadRequest?, +): FriendInitItemRequest = FriendInitItemRequest( name = name, phone = phones.firstOrNull()?.formatPhoneNumber() ?: "", @@ -35,7 +40,7 @@ fun FriendContactUIModel.toFriendInitItemRequest(providerType: String): FriendIn birthDay = birthDay, source = providerType, contactFrequency = createContactFrequencyRequest(reminderInterval!!), - imageUploadRequest = null, + imageUploadRequest = imageUploadRequest, anniversary = null, relation = "FRIEND", // 기본값으로 FRIEND 설정 ) @@ -102,3 +107,11 @@ private fun String.toRelation(): Relation = .onFailure { exception -> NearLog.w("잘못된 관계 값: '$this', 기본값(FRIEND) 사용") }.getOrDefault(Relation.FRIEND) + +fun ContactImageData.toImageUploadRequest(category: String): ImageUploadRequest = + ImageUploadRequest( + fileName = fileName, + contentType = contentType, + fileSize = fileSize, + category = category, + ) diff --git a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt index 31045cfa..998f8e8e 100644 --- a/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt +++ b/Near/app/src/main/java/com/alarmy/near/data/repository/DefaultFriendRepository.kt @@ -1,25 +1,34 @@ package com.alarmy.near.data.repository import com.alarmy.near.data.mapper.toFriendInitItemRequest +import com.alarmy.near.data.mapper.toImageUploadRequest import com.alarmy.near.data.mapper.toModel import com.alarmy.near.data.mapper.toRequest +import com.alarmy.near.local.contact.ContactImageData +import com.alarmy.near.local.contact.ContactImageReader import com.alarmy.near.model.Friend import com.alarmy.near.model.FriendRecord import com.alarmy.near.model.friendsummary.FriendSummary import com.alarmy.near.model.monthly.MonthlyFriend +import com.alarmy.near.network.request.FriendInitItemRequest import com.alarmy.near.network.request.FriendInitRequest import com.alarmy.near.network.response.FriendInitItemEntity import com.alarmy.near.network.service.FriendService +import com.alarmy.near.network.uploader.ImageUploader import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel import com.alarmy.near.utils.extensions.apiCallFlow +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch import javax.inject.Inject class DefaultFriendRepository @Inject constructor( private val friendService: FriendService, + private val contactImageReader: ContactImageReader, + private val imageUploader: ImageUploader, ) : FriendRepository { override fun fetchFriends(): Flow> = flow { @@ -79,16 +88,47 @@ class DefaultFriendRepository providerType: String, ): Flow> = apiCallFlow { - // UI 모델을 Data 모델로 변환 - val friendInitRequest = - FriendInitRequest( - friendList = - contacts - .filter { it.reminderInterval != null } - .map { it.toFriendInitItemRequest(providerType) }, - ) - - // 서버 요청 및 응답 반환 - friendService.initFriends(friendInitRequest).friendList + val payloads = + contacts + .filter { it.reminderInterval != null } + .map { contact -> + val imageData = contact.photoUri?.let { uri -> contactImageReader.read(uri) } + val request = + contact.toFriendInitItemRequest( + providerType = providerType, + imageUploadRequest = imageData?.toImageUploadRequest(PROFILE_IMAGE_CATEGORY), + ) + FriendInitRequestPayload( + request = request, + imageData = imageData, + ) + } + val friendInitRequest = FriendInitRequest(friendList = payloads.map { it.request }) + val response = friendService.initFriends(friendInitRequest) + coroutineScope { + response.friendList.forEachIndexed { index, entity -> + val uploadUrl = entity.preSignedImageUrl + val imageData = payloads.getOrNull(index)?.imageData + if (uploadUrl != null && imageData != null) { + launch { + imageUploader.upload( + url = uploadUrl, + contentType = imageData.contentType, + data = imageData.data, + ) + } + } + } + } + response.friendList } + + private data class FriendInitRequestPayload( + val request: FriendInitItemRequest, + val imageData: ContactImageData?, + ) + + companion object { + private const val PROFILE_IMAGE_CATEGORY = "PROFILE" + } } diff --git a/Near/app/src/main/java/com/alarmy/near/local/contact/ContactImageReader.kt b/Near/app/src/main/java/com/alarmy/near/local/contact/ContactImageReader.kt new file mode 100644 index 00000000..4558c20f --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/local/contact/ContactImageReader.kt @@ -0,0 +1,67 @@ +package com.alarmy.near.local.contact + +import android.content.ContentResolver +import android.webkit.MimeTypeMap +import androidx.core.net.toUri +import javax.inject.Inject + +data class ContactImageData( + val fileName: String, + val contentType: String, + val fileSize: Int, + val data: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + other as ContactImageData + if (fileName != other.fileName) return false + if (contentType != other.contentType) return false + if (fileSize != other.fileSize) return false + if (!data.contentEquals(other.data)) return false + return true + } + + override fun hashCode(): Int { + var result = fileName.hashCode() + result = 31 * result + contentType.hashCode() + result = 31 * result + fileSize + result = 31 * result + data.contentHashCode() + return result + } +} + +class ContactImageReader + @Inject + constructor( + private val contentResolver: ContentResolver, + ) { + fun read(uriString: String): ContactImageData? { + val uri = runCatching { uriString.toUri() }.getOrNull() ?: return null + val bytes = contentResolver.openInputStream(uri)?.use { inputStream -> inputStream.readBytes() } ?: return null + val resolvedMimeType = contentResolver.getType(uri) ?: guessMimeType(uriString) ?: DEFAULT_MIME_TYPE + val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(resolvedMimeType) ?: DEFAULT_EXTENSION + val fileName = "contact_${System.currentTimeMillis()}.$extension" + return ContactImageData( + fileName = fileName, + contentType = resolvedMimeType, + fileSize = bytes.size, + data = bytes, + ) + } + + private fun guessMimeType(uriString: String): String? { + val lowerCase = uriString.lowercase() + return when { + lowerCase.endsWith(".png") -> "image/png" + lowerCase.endsWith(".webp") -> "image/webp" + lowerCase.endsWith(".jpg") || lowerCase.endsWith(".jpeg") -> "image/jpeg" + else -> null + } + } + + companion object { + private const val DEFAULT_MIME_TYPE = "image/jpeg" + private const val DEFAULT_EXTENSION = "jpg" + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/network/uploader/ImageUploader.kt b/Near/app/src/main/java/com/alarmy/near/network/uploader/ImageUploader.kt new file mode 100644 index 00000000..e562307f --- /dev/null +++ b/Near/app/src/main/java/com/alarmy/near/network/uploader/ImageUploader.kt @@ -0,0 +1,37 @@ +package com.alarmy.near.network.uploader + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ImageUploader + @Inject + constructor( + private val okHttpClient: OkHttpClient, + ) { + + suspend fun upload( + url: String, + contentType: String, + data: ByteArray, + ) = withContext(Dispatchers.IO) { + val requestBody = data.toRequestBody(contentType.toMediaTypeOrNull()) + val request = + Request + .Builder() + .url(url) + .put(requestBody) + .build() + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException("이미지 업로드에 실패했습니다.") + } + } + } + } diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactLoadContent.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactLoadContent.kt index 0d57214e..1b593a6d 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactLoadContent.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendcontactcycle/components/ContactLoadContent.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -28,6 +29,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.alarmy.near.R import com.alarmy.near.presentation.feature.friendcontactcycle.model.FriendContactUIModel +import com.alarmy.near.presentation.ui.extension.ImageLoader import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme @@ -116,10 +118,15 @@ fun FriendListItem( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f), ) { - Image( - painter = painterResource(R.drawable.img_64_user_gray), + ImageLoader( + uri = contact.photoUri, + modifier = + Modifier + .size(24.dp) + .clip(CircleShape), + placeholder = R.drawable.img_64_user_gray, + error = R.drawable.img_64_user_gray, contentDescription = null, - modifier = Modifier.size(24.dp), ) Spacer(modifier = Modifier.size(12.dp)) diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt index 9ca3720d..50a2eee5 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/friendprofile/FriendProfileScreen.kt @@ -17,10 +17,12 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider @@ -39,6 +41,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed +import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -70,6 +73,7 @@ import com.alarmy.near.presentation.ui.component.appbar.NearTopAppbar import com.alarmy.near.presentation.ui.component.button.NearSolidTypeButton import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenu import com.alarmy.near.presentation.ui.component.dropdown.NearDropdownMenuItem +import com.alarmy.near.presentation.ui.extension.ImageLoader import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme import kotlinx.coroutines.delay @@ -248,11 +252,20 @@ fun FriendProfileScreen( .padding(horizontal = 32.dp), verticalAlignment = Alignment.CenterVertically, ) { - Box { - // 이미지 + 이모지 - Image( - modifier = Modifier.align(Alignment.Center), - painter = painterResource(R.drawable.img_80_user1), + Box( + modifier = + Modifier + .size(80.dp), + contentAlignment = Alignment.Center, + ) { + ImageLoader( + uri = friend.imageUrl, + modifier = + Modifier + .matchParentSize() + .clip(CircleShape), + placeholder = R.drawable.img_80_user1, + error = R.drawable.img_80_user1, contentDescription = null, ) Image( @@ -345,7 +358,7 @@ fun FriendProfileScreen( NearTheme.colors.GRAY01_888888.copy( alpha = 0.3f, ), - selected = currentTabPosition.intValue == 0, + selected = currentTabPosition.intValue == 0, onClick = { currentTabPosition.intValue = 0 }, ) { Text( @@ -375,9 +388,10 @@ fun FriendProfileScreen( .height(50.dp), selected = currentTabPosition.intValue == 1, onClick = { currentTabPosition.intValue = 1 }, - selectedContentColor = NearTheme.colors.GRAY01_888888.copy( - alpha = 0.3f - ), + selectedContentColor = + NearTheme.colors.GRAY01_888888.copy( + alpha = 0.3f, + ), ) { Text( text = stringResource(R.string.friend_profile_tab_text_record), diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt index 892176ff..109fe2da 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/feature/home/component/ContactItem.kt @@ -1,6 +1,7 @@ package com.alarmy.near.presentation.feature.home.component import androidx.compose.foundation.Image +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -8,11 +9,13 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width 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.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -21,6 +24,7 @@ import androidx.compose.ui.unit.dp import com.alarmy.near.R import com.alarmy.near.model.friendsummary.ContactFrequencyLevel import com.alarmy.near.model.friendsummary.FriendSummary +import com.alarmy.near.presentation.ui.extension.ImageLoader import com.alarmy.near.presentation.ui.extension.onNoRippleClick import com.alarmy.near.presentation.ui.theme.NearTheme @@ -40,8 +44,14 @@ fun ContactItem( horizontalAlignment = Alignment.CenterHorizontally, ) { Box { - Image( - painter = painterResource(R.drawable.img_64_user1), + ImageLoader( + uri = friendSummary.profileImageUrl, + modifier = + Modifier + .size(64.dp) + .clip(CircleShape), + placeholder = R.drawable.img_64_user1, + error = R.drawable.img_64_user1, contentDescription = "", ) Image( diff --git a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/bottomsheet/CycleSettingBottomSheet.kt b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/bottomsheet/CycleSettingBottomSheet.kt index 394b7cb9..36823d3a 100644 --- a/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/bottomsheet/CycleSettingBottomSheet.kt +++ b/Near/app/src/main/java/com/alarmy/near/presentation/ui/component/bottomsheet/CycleSettingBottomSheet.kt @@ -126,11 +126,10 @@ fun CycleSettingBottomSheet( Text( text = - stringResource(R.string.friend_contact_cycle_next_cycle_prefix) + - ( - selectedInterval?.let { DateExtension.getNextCycleDate(it) } - ?: DateExtension.getNextWeekSameDay() - ), + "${stringResource(R.string.friend_contact_cycle_next_cycle_prefix)} ${ + selectedInterval?.let { DateExtension.getNextCycleDate(it) } + ?: DateExtension.getNextWeekSameDay() + }", style = NearTheme.typography.B2_14_MEDIUM, color = NearTheme.colors.GRAY01_888888, ) @@ -199,4 +198,3 @@ fun CycleSettingBottomSheetPreview() { ) } } -