Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(profile): Implement avatar upload to the server #2

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions app/src/main/java/me/floow/app/navigation/FlowNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ fun FlowNavHost(
name = editProfileScreen?.name ?: "",
username = editProfileScreen?.username ?: "",
description = editProfileScreen?.description ?: "",
avatarUrl = editProfileScreen?.avatarUrl ?: "",
),
onBackClick = {
navController.popBackStack()
Expand Down Expand Up @@ -126,12 +127,13 @@ fun FlowNavHost(

composable<SelfProfileScreen> {
ProfileRoute(
goToProfileEditScreen = { name, username, description ->
goToProfileEditScreen = { name, username, description, avatarUrl ->
navController.navigate(
EditProfileScreen(
name = name,
username = username,
description = description
description = description,
avatarUrl = avatarUrl
)
)
},
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/me/floow/app/navigation/NavigationGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ data object SelfProfileScreen : NavigationRoute
data class EditProfileScreen(
val name: String = "",
val username: String = "",
val description: String = ""
val description: String = "",
val avatarUrl: String = "",
) : NavigationRoute

val bottomNavigationItems = listOf(
Expand Down
43 changes: 40 additions & 3 deletions core/api/src/main/java/me/floow/api/ProfileApiImpl.kt
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
package me.floow.api

import io.ktor.client.plugins.timeout
import io.ktor.client.request.*
import io.ktor.client.request.forms.FormDataContent
import io.ktor.client.request.forms.MultiPartFormDataContent
import io.ktor.client.request.forms.formData
import io.ktor.client.request.forms.submitForm
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.util.*
import io.ktor.util.network.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import me.floow.api.util.ApiConfig
Expand Down Expand Up @@ -107,6 +106,44 @@ class ProfileApiImpl(
}
}

override suspend fun uploadAvatar(
avatarBytes: ByteArray,
): UploadAvatarResponse {
return safeApiCall(errorResponse = UploadAvatarResponse(status = UploadAvatarResponseStatus.ERROR)) {
val authToken = authenticationManager.getAuthTokenOrNull()
?: return@safeApiCall UploadAvatarResponse(UploadAvatarResponseStatus.ERROR)

val response: HttpResponse = httpClient.post("${config.apiUrl}/user/avatar") {
addAuthTokenHeader(authToken)
setBody(
MultiPartFormDataContent(
formData {
append("file", avatarBytes, Headers.build {
append(HttpHeaders.ContentDisposition, "filename=\"avatar\"")
})
},
)
)
timeout { requestTimeoutMillis = 5000 }
}

logger.logKtorRequest("ProfileApiImpl uploadAvatar", response.request)

val bodyText = response.bodyAsText()

if (!response.status.isSuccess()) {
logger.logFailureResponse("ProfileApiImpl uploadAvatar", response.status, bodyText)

return@safeApiCall UploadAvatarResponse(UploadAvatarResponseStatus.ERROR)
}


return@safeApiCall UploadAvatarResponse(UploadAvatarResponseStatus.SUCCESS)

}

}

override suspend fun deleteProfile(): DeleteProfileResponse {
return safeApiCall(errorResponse = DeleteProfileResponse(DeleteProfileResponseStatus.SUCCESS)) {
val authToken = authenticationManager.getAuthTokenOrNull()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package me.floow.api.util

import io.ktor.client.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.HttpTimeout

class HttpClientProvider {
private val lazyClient: HttpClient by lazy {
HttpClient(CIO)
HttpClient(CIO) {
install(HttpTimeout)
}
}

fun getClient(): HttpClient {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import me.floow.domain.api.ProfileApi
import me.floow.domain.api.models.EditProfileData
import me.floow.domain.api.models.EditProfileResponseStatus
import me.floow.domain.api.models.GetSelfResponse
import me.floow.domain.api.models.UploadAvatarResponseStatus
import me.floow.domain.data.GetDataError
import me.floow.domain.data.GetDataResponse
import me.floow.domain.data.UpdateDataResponse
Expand Down Expand Up @@ -54,4 +55,17 @@ class ProfileRepositoryImpl(
EditProfileResponseStatus.SUCCESS -> UpdateDataResponse.Success
}
}

override suspend fun uploadAvatar(
avatarBytes: ByteArray,
): UpdateDataResponse {
val result = profileApi.uploadAvatar(avatarBytes)

return when (result.status) {
UploadAvatarResponseStatus.ERROR -> UpdateDataResponse.Failure()

UploadAvatarResponseStatus.SUCCESS -> UpdateDataResponse.Success
}

}
}
14 changes: 14 additions & 0 deletions core/database/src/main/java/me/floow/database/utils/FileUtils.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package me.floow.database.utils

import android.content.Context
import android.net.Uri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

object FileUtils {
suspend fun getBytesFromUri(uri: Uri, context: Context): ByteArray =
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)
?.use { inputStream -> inputStream.readBytes() } ?: byteArrayOf()
}
}
3 changes: 3 additions & 0 deletions core/domain/src/main/java/me/floow/domain/api/ProfileApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ import me.floow.domain.api.models.DeleteProfileResponse
import me.floow.domain.api.models.EditProfileData
import me.floow.domain.api.models.EditProfileResponse
import me.floow.domain.api.models.GetSelfResponse
import me.floow.domain.api.models.UploadAvatarResponse

interface ProfileApi {
suspend fun getSelf(): GetSelfResponse

suspend fun edit(data: EditProfileData): EditProfileResponse

suspend fun uploadAvatar(avatarBytes: ByteArray): UploadAvatarResponse

suspend fun deleteProfile(): DeleteProfileResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package me.floow.domain.api.models

enum class UploadAvatarResponseStatus {
SUCCESS,
ERROR
}

data class UploadAvatarResponse(
val status: UploadAvatarResponseStatus,
)
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ interface ProfileRepository {
suspend fun getSelfData(): GetDataResponse<SelfProfile>

suspend fun edit(data: EditProfileData): UpdateDataResponse

suspend fun uploadAvatar(avatarBytes: ByteArray): UpdateDataResponse
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,10 @@ class MockProfileRepository : ProfileRepository {

return UpdateDataResponse.Success
}

override suspend fun uploadAvatar(
avatarBytes: ByteArray
): UpdateDataResponse {
return UpdateDataResponse.Success
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package me.floow.uikit.components.pickers

import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
Expand All @@ -25,15 +29,16 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.rememberAsyncImagePainter
import me.floow.uikit.R
import me.floow.uikit.theme.ElevanagonShape
import me.floow.uikit.theme.LocalTypography

@Composable
fun AvatarAndBackgroundPicker(
avatarImagePainter: Painter? = null,
backgroundImagePainter: Painter? = null,
onAvatarPickerClick: () -> Unit,
avatarUri: Uri? = null,
onAvatarChanged: (Uri) -> Unit,
onBackgroundPickerClick: () -> Unit,
modifier: Modifier = Modifier
) {
Expand Down Expand Up @@ -69,17 +74,22 @@ fun AvatarAndBackgroundPicker(
)
}

val pickMediaLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) onAvatarChanged(uri)
}

Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(120.dp)
.clip(ElevanagonShape)
.setAvatarBoxImage(avatarImagePainter)
.setAvatarBoxImage(avatarUri)
.clickable {
onAvatarPickerClick()
pickMediaLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
}
) {
Icon(
if (avatarUri == null) Icon(
painter = painterResource(R.drawable.photo_icon),
contentDescription = null,
tint = Color.White,
Expand All @@ -88,18 +98,21 @@ fun AvatarAndBackgroundPicker(
}
}

private fun Modifier.setAvatarBoxImage(avatarImagePainter: Painter?): Modifier {
return if (avatarImagePainter == null) {
@Composable
private fun Modifier.setAvatarBoxImage(avatarUri: Uri?): Modifier {
return if (avatarUri == null) {
this.then(
Modifier
.background(Color.LightGray)
)
} else {
Modifier
.paint(
painter = avatarImagePainter,
contentScale = ContentScale.FillBounds
)
this.then(
Modifier
.paint(
painter = rememberAsyncImagePainter(avatarUri.toString()),
contentScale = ContentScale.FillBounds
)
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,27 @@
package me.floow.uikit.util.state

/// Перечисление возможных состояний валидации значения поля
enum class ValidationErrorType {
ShouldNotBeEmpty,
TextTooLong,
Other
}

/// Интерфейс поля с валидацией значения
interface ValidatedField {
/// Значение поля с валидацией
val value: String

companion object {
val initialField = Valid("")
}

/// Поле со значением без ошибки валидации
data class Valid(
override val value: String
) : ValidatedField

/// Поле со значением с ошибкой валидации
data class Invalid(
override val value: String,
val errorType: ValidationErrorType? = null
Expand Down
1 change: 1 addition & 0 deletions feature/login/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ dependencies {
api(platform(libs.koin.bom))
api(libs.koin.core)
api(libs.koin.android)
implementation(project(":core:database"))

testImplementation(libs.junit)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,6 @@ fun CreateProfileRoute(

CreateProfileScreen(
state = state,
onAvatarPickerClick = {
Toast.makeText(context, "Фича ещё разрабатывается…", Toast.LENGTH_SHORT).show()
},
onNameChange = vm::updateName,
onUsernameChange = vm::updateUsername,
onBiographyChange = vm::updateBio,
Expand All @@ -54,6 +51,16 @@ fun CreateProfileRoute(
}
)
},
onAvatarChanged = {
vm.uploadAvatar(
newAvatarUri = it,
onFailure = {
Toast.makeText(context, "Failure", Toast.LENGTH_SHORT).show()
},
context = context,
)

},
modifier = modifier
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package me.floow.login.ui.createprofile

import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
Expand All @@ -19,7 +20,7 @@ import me.flowme.login.R
@Composable
fun CreateProfileScreen(
state: CreateProfileState,
onAvatarPickerClick: () -> Unit = {},
onAvatarChanged: (Uri) -> Unit = {},
onNameChange: (String) -> Unit = {},
onUsernameChange: (String) -> Unit = {},
onBiographyChange: (String) -> Unit = {},
Expand Down Expand Up @@ -48,7 +49,7 @@ fun CreateProfileScreen(
is CreateProfileState.Edit -> {
EditState(
state = state,
onAvatarPickerClick = onAvatarPickerClick,
onAvatarChanged = onAvatarChanged,
onNameChange = onNameChange,
onBiographyChange = onBiographyChange,
onUsernameChange = onUsernameChange,
Expand Down Expand Up @@ -80,7 +81,7 @@ fun CreateProfileScreenTopBar(onDoneClick: () -> Unit) {
fun CreateProfileScreenPreview() {
CreateProfileScreen(
state = CreateProfileState.Edit(),
onAvatarPickerClick = {},
onAvatarChanged = {},
modifier = Modifier.fillMaxSize()
)
}
Loading