新增头像裁剪

This commit is contained in:
2024-10-09 15:33:08 +08:00
parent 43c27b7e0b
commit 5846622250
7 changed files with 256 additions and 107 deletions

1
.gitignore vendored
View File

@@ -13,3 +13,4 @@
.externalNativeBuild
.cxx
local.properties
/.idea/*

View File

@@ -111,6 +111,7 @@ dependencies {
implementation("com.google.firebase:firebase-messaging-ktx")
implementation ("cn.jiguang.sdk:jpush-google:5.4.0")
api ("com.tencent.imsdk:imsdk-plus:8.1.6116")
implementation("io.github.rroohit:ImageCropView:3.0.1")
}

View File

@@ -32,6 +32,7 @@ import com.aiosman.riderpro.ui.account.ResetPasswordScreen
import com.aiosman.riderpro.ui.chat.ChatScreen
import com.aiosman.riderpro.ui.comment.CommentsScreen
import com.aiosman.riderpro.ui.comment.notice.CommentNoticeScreen
import com.aiosman.riderpro.ui.crop.ImageCropScreen
import com.aiosman.riderpro.ui.favourite.FavouriteListPage
import com.aiosman.riderpro.ui.favourite.FavouriteNoticeScreen
import com.aiosman.riderpro.ui.follower.FollowerListScreen
@@ -88,6 +89,7 @@ sealed class NavigationRoute(
data object FavouriteList : NavigationRoute("FavouriteList")
data object Chat : NavigationRoute("Chat/{id}")
data object CommentNoticeScreen : NavigationRoute("CommentNoticeScreen")
data object ImageCrop : NavigationRoute("ImageCrop")
}
@@ -361,6 +363,13 @@ fun NavigationController(
CommentNoticeScreen()
}
}
composable(route = NavigationRoute.ImageCrop.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
ImageCropScreen()
}
}
}
}

View File

@@ -0,0 +1,52 @@
package com.aiosman.riderpro.ui.account
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.aiosman.riderpro.data.AccountService
import com.aiosman.riderpro.data.AccountServiceImpl
import com.aiosman.riderpro.data.UploadImage
import com.aiosman.riderpro.entity.AccountProfileEntity
import com.aiosman.riderpro.ui.index.tabs.profile.MyProfileViewModel
import java.io.File
object AccountEditViewModel : ViewModel() {
var name by mutableStateOf("")
var bio by mutableStateOf("")
var imageUrl by mutableStateOf<Uri?>(null)
val accountService: AccountService = AccountServiceImpl()
var profile by mutableStateOf<AccountProfileEntity?>(null)
var croppedBitmap by mutableStateOf<Bitmap?>(null)
suspend fun reloadProfile() {
accountService.getMyAccountProfile().let {
profile = it
name = it.nickName
bio = it.bio
}
}
suspend fun updateUserProfile(context: Context) {
val newAvatar = croppedBitmap?.let {
val file = File(context.cacheDir, "avatar.jpg")
it.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
UploadImage(file, "avatar.jpg", "", "jpg")
}
val newName = if (name == profile?.nickName) null else name
accountService.updateProfile(
avatar = newAvatar,
banner = null,
nickName = newName,
bio = bio
)
// 刷新用户资料
reloadProfile()
// 刷新个人资料页面的用户资料
MyProfileViewModel.loadUserProfile()
}
}

View File

@@ -1,9 +1,5 @@
package com.aiosman.riderpro.ui.account
import android.content.Intent
import android.net.Uri
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -18,14 +14,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -35,47 +29,30 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import com.aiosman.riderpro.AppColors
import com.aiosman.riderpro.ConstVars
import com.aiosman.riderpro.LocalNavController
import com.aiosman.riderpro.R
import com.aiosman.riderpro.data.AccountService
import com.aiosman.riderpro.data.AccountServiceImpl
import com.aiosman.riderpro.data.UploadImage
import com.aiosman.riderpro.entity.AccountProfileEntity
import com.aiosman.riderpro.ui.NavigationRoute
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader
import com.aiosman.riderpro.ui.composables.CustomAsyncImage
import com.aiosman.riderpro.ui.composables.StatusBarSpacer
import com.aiosman.riderpro.ui.composables.form.FormTextInput
import com.aiosman.riderpro.ui.composables.pickupAndCompressLauncher
import com.aiosman.riderpro.ui.index.tabs.profile.MyProfileViewModel
import com.aiosman.riderpro.ui.modifiers.noRippleClickable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
/**
* 编辑用户资料界面
*/
@Composable
fun AccountEditScreen2() {
val accountService: AccountService = AccountServiceImpl()
var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") }
var imageUrl by remember { mutableStateOf<Uri?>(null) }
var imageFile by remember { mutableStateOf<File?>(null) }
var profile by remember {
mutableStateOf<AccountProfileEntity?>(
null
)
}
val model = AccountEditViewModel
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
var usernameError by remember { mutableStateOf<String?>(null) }
var bioError by remember { mutableStateOf<String?>(null) }
fun onNicknameChange(value: String) {
name = value
model.name = value
usernameError = when {
value.isEmpty() -> "昵称不能为空"
value.length < 3 -> "昵称长度不能小于3"
@@ -85,7 +62,7 @@ fun AccountEditScreen2() {
}
fun onBioChange(value: String) {
bio = value
model.bio = value
bioError = when {
value.length > 100 -> "个人简介长度不能大于24"
else -> null
@@ -96,76 +73,11 @@ fun AccountEditScreen2() {
return usernameError == null && bioError == null
}
/**
* 加载用户资料
*/
suspend fun reloadProfile() {
accountService.getMyAccountProfile().let {
profile = it
name = it.nickName
bio = it.bio
}
}
/**
* 更新用户资料
*/
fun updateUserProfile() {
if (!validate()) {
Toast.makeText(context, "请检查输入", Toast.LENGTH_SHORT).show()
return
}
scope.launch {
val newAvatar = imageUrl?.let {
// 检查图片文件
val avatarFile = imageFile ?: return@let null
// 读取文件名
val cursor = context.contentResolver.query(it, null, null, null, null)
var newAvatar: UploadImage? = null
cursor?.use { cur ->
val columnIndex = cur.getColumnIndex("_display_name")
if (columnIndex != -1 && cur.moveToFirst()) {
val displayName = cur.getString(columnIndex)
val extension = displayName.substringAfterLast(".")
Log.d("Profile Edit", "File name: $displayName, extension: $extension")
// read as file
Log.d("Profile Edit", "File size: ${avatarFile.length()}")
newAvatar = UploadImage(avatarFile, displayName, it.toString(), extension)
}
}
newAvatar
}
val newName = if (name == profile?.nickName) null else name
accountService.updateProfile(
avatar = newAvatar,
banner = null,
nickName = newName,
bio = bio
)
// 刷新用户资料
reloadProfile()
// 刷新个人资料页面的用户资料
MyProfileViewModel.loadUserProfile()
navController.popBackStack()
}
}
val pickImageLauncher = pickupAndCompressLauncher(
context = context,
scope = scope,
maxSize = ConstVars.AVATAR_IMAGE_MAX_SIZE
) { uri, file ->
if (file.length() <= ConstVars.AVATAR_FILE_SIZE_LIMIT) {
imageUrl = uri
imageFile = file
} else {
scope.launch(Dispatchers.Main) {
Toast.makeText(context, "图片过大", Toast.LENGTH_SHORT).show()
}
}
}
LaunchedEffect(Unit) {
reloadProfile()
if (model.profile == null){
model.reloadProfile()
}
}
Column(
modifier = Modifier
@@ -186,7 +98,10 @@ fun AccountEditScreen2() {
modifier = Modifier
.size(24.dp)
.noRippleClickable {
updateUserProfile()
model.viewModelScope.launch {
model.updateUserProfile(context)
navController.popBackStack()
}
},
imageVector = Icons.Default.Check,
contentDescription = "保存",
@@ -195,14 +110,14 @@ fun AccountEditScreen2() {
}
}
Spacer(modifier = Modifier.height(32.dp))
profile?.let {
model.profile?.let {
Box(
modifier = Modifier.size(112.dp),
contentAlignment = Alignment.Center
) {
CustomAsyncImage(
context,
imageUrl?.toString() ?: it.avatar,
model.croppedBitmap ?: it.avatar,
modifier = Modifier
.size(112.dp)
.clip(
@@ -218,10 +133,7 @@ fun AccountEditScreen2() {
.background(AppColors.mainColor)
.align(Alignment.BottomEnd)
.noRippleClickable {
Intent(Intent.ACTION_PICK).apply {
type = "image/*"
pickImageLauncher.launch(this)
}
navController.navigate(NavigationRoute.ImageCrop.route)
},
contentAlignment = Alignment.Center
) {
@@ -240,7 +152,7 @@ fun AccountEditScreen2() {
.padding(horizontal = 16.dp)
) {
FormTextInput(
value = name,
value = model.name,
label = stringResource(R.string.nickname),
hint = "Input nickname",
modifier = Modifier.fillMaxWidth(),
@@ -250,7 +162,7 @@ fun AccountEditScreen2() {
}
Spacer(modifier = Modifier.height(16.dp))
FormTextInput(
value = bio,
value = model.bio,
label = stringResource(R.string.bio),
hint = "Input bio",
modifier = Modifier.fillMaxWidth(),

View File

@@ -48,7 +48,7 @@ fun rememberImageBitmap(imageUrl: String, imageLoader: ImageLoader): Bitmap? {
@Composable
fun CustomAsyncImage(
context: Context? = null,
imageUrl: String?,
imageUrl: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
blurHash: String? = null,

View File

@@ -0,0 +1,174 @@
package com.aiosman.riderpro.ui.crop
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
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.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import com.aiosman.riderpro.LocalNavController
import com.aiosman.riderpro.R
import com.aiosman.riderpro.ui.account.AccountEditViewModel
import com.aiosman.riderpro.ui.composables.StatusBarSpacer
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.image.cropview.CropType
import com.image.cropview.EdgeType
import com.image.cropview.ImageCrop
import kotlinx.coroutines.launch
import java.io.InputStream
@Composable
fun ImageCropScreen() {
var imageCrop by remember { mutableStateOf<ImageCrop?>(null) }
val context = LocalContext.current
val configuration = LocalConfiguration.current
val screenWidth = configuration.screenWidthDp
var imageWidthInDp by remember { mutableStateOf(0) }
var imageHeightInDp by remember { mutableStateOf(0) }
var density = LocalDensity.current
var navController = LocalNavController.current
var imagePickLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
uri?.let {
val bitmap = uriToBitmap(context = context, uri = it)
if (bitmap != null) {
val aspectRatio = bitmap.height.toFloat() / bitmap.width.toFloat()
imageHeightInDp = (imageWidthInDp.toFloat() * aspectRatio).toInt()
imageCrop = ImageCrop(bitmap)
}
}
if (uri == null) {
navController.popBackStack()
}
}
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(darkIcons = false, color = Color.Black)
imagePickLauncher.launch("image/*")
}
DisposableEffect(Unit) {
onDispose {
imageCrop = null
systemUiController.setStatusBarColor(darkIcons = true, color = Color.White)
}
}
Column(
modifier = Modifier.background(Color.Black).fillMaxSize()
) {
StatusBarSpacer()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp, horizontal = 16.dp)
) {
Image(
painter = painterResource(R.drawable.rider_pro_nav_back),
contentDescription = null,
modifier = Modifier.clickable {
navController.popBackStack()
},
colorFilter = ColorFilter.tint(Color.White)
)
Spacer(
modifier = Modifier.weight(1f)
)
Icon(
Icons.Default.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.clickable {
imageCrop?.let {
val bitmap = it.onCrop()
AccountEditViewModel.croppedBitmap = bitmap
AccountEditViewModel.viewModelScope.launch {
AccountEditViewModel.updateUserProfile(context)
navController.popBackStack()
}
}
}
)
}
// Spacer(
// modifier = Modifier.height(120.dp)
// )
// ActionButton(
// modifier = Modifier.fillMaxWidth(),
// text = "选择图片"
// ) {
// imagePickLauncher.launch("image/*")
// }
Box(
modifier = Modifier.fillMaxWidth().padding(24.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(imageHeightInDp.dp)
.onGloballyPositioned {
with(density) {
imageWidthInDp = it.size.width.toDp().value.toInt()
}
}
) {
imageCrop?.ImageCropView(
modifier = Modifier.fillMaxSize(),
guideLineColor = Color.White,
guideLineWidth = 2.dp,
edgeCircleSize = 5.dp,
cropType = CropType.SQUARE,
edgeType = EdgeType.CIRCULAR
)
}
}
}
}
// Configure ImageCropView.
fun uriToBitmap(context: Context, uri: Uri): Bitmap? {
return try {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
BitmapFactory.decodeStream(inputStream)
} catch (e: Exception) {
e.printStackTrace()
null
}
}