改包名com.aiosman.ravenow

This commit is contained in:
2024-11-17 20:07:42 +08:00
parent 914cfca6be
commit 074244c0f8
168 changed files with 897 additions and 970 deletions

View File

@@ -0,0 +1,271 @@
package com.aiosman.ravenow.ui.post
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.filter
import androidx.paging.map
import com.aiosman.ravenow.data.CommentRemoteDataSource
import com.aiosman.ravenow.data.CommentService
import com.aiosman.ravenow.data.CommentServiceImpl
import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.entity.CommentPagingSource
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class CommentsViewModel(
var postId: String = 0.toString(),
) : ViewModel() {
var commentService: CommentService = CommentServiceImpl()
private var _commentsFlow = MutableStateFlow<PagingData<CommentEntity>>(PagingData.empty())
val commentsFlow = _commentsFlow.asStateFlow()
var order: String by mutableStateOf("like")
var addedCommentList by mutableStateOf<List<CommentEntity>>(emptyList())
var subCommentLoadingMap by mutableStateOf(mutableMapOf<Int, Boolean>())
var highlightCommentId by mutableStateOf<Int?>(null)
var highlightComment by mutableStateOf<CommentEntity?>(null)
/**
* 预加载,在跳转到 PostScreen 之前设置好内容
*/
fun preTransit() {
viewModelScope.launch {
Pager(config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
CommentPagingSource(
CommentRemoteDataSource(commentService),
postId = postId.toInt()
)
}).flow.cachedIn(viewModelScope).collectLatest {
_commentsFlow.value = it
}
}
}
/**
* 加载评论
*/
fun reloadComment() {
viewModelScope.launch {
try {
Pager(config = PagingConfig(pageSize = 20, enablePlaceholders = false),
pagingSourceFactory = {
CommentPagingSource(
CommentRemoteDataSource(commentService),
postId = postId.toInt(),
order = order
)
}).flow.cachedIn(viewModelScope).collectLatest {
_commentsFlow.value = it
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
suspend fun highlightComment(commentId: Int) {
highlightCommentId = commentId
val resp = commentService.getCommentById(commentId)
highlightComment = resp
}
/**
* 更新高亮评论点赞状态
*/
private fun updateHighlightCommentLike(commentId: Int, isLike: Boolean): Boolean {
var isUpdate = false
highlightComment?.let {
if (it.id == commentId) {
highlightComment =
it.copy(liked = isLike, likes = if (isLike) it.likes + 1 else it.likes - 1)
isUpdate = true
}
highlightComment = it.copy(
reply = it.reply.map { replyComment ->
if (replyComment.id == commentId) {
isUpdate = true
replyComment.copy(
liked = isLike,
likes = if (isLike) replyComment.likes + 1 else replyComment.likes - 1
)
} else {
replyComment
}
}
)
}
return isUpdate
}
/**
* 更新添加的评论点赞状态
*/
private fun updateAddedCommentLike(commentId: Int, isLike: Boolean): Boolean {
var isUpdate = false
addedCommentList = addedCommentList.map {
if (it.id == commentId) {
isUpdate = true
it.copy(liked = isLike, likes = if (isLike) it.likes + 1 else it.likes - 1)
} else {
it
}
}
return isUpdate
}
/**
* 更新评论点赞状态
*/
private fun updateCommentLike(commentId: Int, isLike: Boolean) {
val currentPagingData = commentsFlow.value
val updatedPagingData = currentPagingData.map { comment ->
if (comment.id == commentId) {
comment.copy(
liked = isLike,
likes = if (isLike) comment.likes + 1 else comment.likes - 1
)
} else {
// 可能是回复的评论
comment.copy(reply = comment.reply.map { replyComment ->
if (replyComment.id == commentId) {
replyComment.copy(
liked = isLike,
likes = if (isLike) replyComment.likes + 1 else replyComment.likes - 1
)
} else {
replyComment
}
})
}
}
_commentsFlow.value = updatedPagingData
}
/**
* 点赞评论
*/
suspend fun likeComment(commentId: Int) {
try {
commentService.likeComment(commentId)
// 更新addCommentList
if (updateHighlightCommentLike(commentId, true)) {
return
}
if (updateAddedCommentLike(commentId, true)) {
return
}
updateCommentLike(commentId, true)
} catch (e: Exception) {
e.printStackTrace()
}
}
// 取消点赞评论
suspend fun unlikeComment(commentId: Int) {
commentService.dislikeComment(commentId)
// 更新高亮评论点赞状态
if (updateHighlightCommentLike(commentId, false)) {
return
}
// 更新添加的评论点赞状态
if (updateAddedCommentLike(commentId, false)) {
return
}
// 更新评论点赞状态
updateCommentLike(commentId, false)
}
suspend fun createComment(
content: String,
parentCommentId: Int? = null,
replyUserId: Int? = null,
replyCommentId: Int? = null
) {
val comment =
commentService.createComment(
postId = postId.toInt(),
content = content,
parentCommentId = parentCommentId,
replyUserId = replyUserId,
replyCommentId = replyCommentId
)
TimelineMomentViewModel.updateCommentCount(postId.toInt())
// add to first
addedCommentList = listOf(comment) + addedCommentList
}
fun deleteComment(commentId: Int) {
viewModelScope.launch {
commentService.DeleteComment(commentId)
// 如果是刚刚创建的评论则从addedCommentList中删除
if (addedCommentList.any { it.id == commentId }) {
addedCommentList = addedCommentList.filter { it.id != commentId }
} else {
val currentPagingData = commentsFlow.value
val updatedPagingData = currentPagingData.filter { it.id != commentId }
_commentsFlow.value = updatedPagingData
}
}
}
fun loadMoreSubComments(commentId: Int) {
if (highlightComment?.id == commentId) {
// 高亮的评论,更新高亮评论的回复
highlightComment?.let {
viewModelScope.launch {
val subCommentList = commentService.getComments(
postId = postId.toInt(),
parentCommentId = commentId,
pageNumber = it.replyPage + 1,
pageSize = 3,
).list
highlightComment = it.copy(
reply = it.reply.plus(subCommentList),
replyPage = it.replyPage + 1
)
}
}
} else {
// 普通评论
viewModelScope.launch {
val currentPagingData = commentsFlow.value
val updatedPagingData = currentPagingData.map { comment ->
if (comment.id == commentId) {
try {
subCommentLoadingMap[commentId] = true
val subCommentList = commentService.getComments(
postId = postId.toInt(),
parentCommentId = commentId,
pageNumber = comment.replyPage + 1,
pageSize = 3,
order = "earliest"
).list
return@map comment.copy(
reply = comment.reply.plus(subCommentList),
replyPage = comment.replyPage + 1
)
} catch (e: Exception) {
return@map comment.copy()
} finally {
subCommentLoadingMap[commentId] = false
}
}
comment
}
_commentsFlow.value = updatedPagingData
}
}
}
}

View File

@@ -0,0 +1,544 @@
package com.aiosman.ravenow.ui.post
import android.net.Uri
import android.widget.Toast
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.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.DraggableGrid
import com.aiosman.ravenow.ui.composables.RelPostCard
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.launch
import java.io.File
@Preview
@Composable
fun NewPostScreen() {
val AppColors = LocalAppTheme.current
val model = NewPostViewModel
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setNavigationBarColor(color = Color.Transparent)
model.init()
}
StatusBarMaskLayout(
darkIcons = !AppState.darkMode,
modifier = Modifier
.fillMaxSize()
.background(
AppColors.background
)
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
) {
NewPostTopBar {
}
NewPostTextField("Share your adventure…", NewPostViewModel.textContent) {
NewPostViewModel.textContent = it
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
model.relMoment?.let {
Text("Share with")
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(color = Color(0xFFEEEEEE))
.padding(24.dp)
) {
RelPostCard(
momentEntity = it,
modifier = Modifier.fillMaxWidth()
)
}
}
}
AddImageGrid()
// AdditionalPostItem()
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NewPostTopBar(onSendClick: () -> Unit = {}) {
val AppColors = LocalAppTheme.current
var uploading by remember { mutableStateOf(false) }
// 上传进度
if (uploading) {
BasicAlertDialog(
onDismissRequest = { },
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(64.dp)).shadow(elevation = 4.dp)
.background(AppColors.background).padding(16.dp),
contentAlignment = Alignment.CenterStart
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = AppColors.main
)
Spacer(modifier = Modifier.width(8.dp))
Text("Uploading", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = AppColors.text)
}
}
}
val navController = LocalNavController.current
val context = LocalContext.current
val model = NewPostViewModel
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 10.dp)
) {
Row(
modifier = Modifier.align(Alignment.CenterStart),
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = "Back",
modifier = Modifier
.size(24.dp)
.noRippleClickable {
navController.popBackStack()
},
colorFilter = ColorFilter.tint(AppColors.text)
)
Spacer(modifier = Modifier.weight(1f))
Icon(
painter = painterResource(id = R.drawable.rider_pro_video_share),
tint = AppColors.text,
contentDescription = "Send",
modifier = Modifier
.size(32.dp)
.noRippleClickable {
// 检查输入
val errorMessage = model.validateMoment()
if (errorMessage != null) {
Toast.makeText(context, errorMessage, Toast.LENGTH_SHORT).show()
return@noRippleClickable
}
model.viewModelScope.launch {
try {
uploading = true
model.createMoment(context = context) { progress ->
// 更新进度条
}
navController.popBackStack()
}catch (e:Exception) {
e.printStackTrace()
}finally {
uploading = false
}
// 上传完成后隐藏进度条
}
}
)
}
}
}
@Composable
fun NewPostTextField(hint: String, value: String, onValueChange: (String) -> Unit) {
val AppColors = LocalAppTheme.current
Box(modifier = Modifier.fillMaxWidth()) {
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.heightIn(200.dp)
.padding(horizontal = 18.dp, vertical = 10.dp),
cursorBrush = SolidColor(AppColors.text),
textStyle = TextStyle(
color = AppColors.text,
)
)
if (value.isEmpty()) {
Text(
text = hint,
color = AppColors.inputHint,
modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp)
)
}
}
}
@Composable
fun AddImageGrid() {
val navController = LocalNavController.current
val context = LocalContext.current
val model = NewPostViewModel
val scope = model.viewModelScope
val pickImagesLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris ->
if (uris.isNotEmpty()) {
scope.launch {
for (uri in uris) {
ImageItem.fromUri(context, uri.toString())?.let {
model.imageList += it
}
}
}
}
}
val takePictureLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.TakePicture()
) { success ->
if (success) {
scope.launch {
ImageItem.fromUri(context, model.currentPhotoUri.toString())?.let {
model.imageList += it
}
}
}
}
val stroke = Stroke(
width = 2f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
)
DraggableGrid(
items = NewPostViewModel.imageList,
onMove = { from, to ->
NewPostViewModel.imageList = NewPostViewModel.imageList.toMutableList().apply {
add(to, removeAt(from))
}
},
lockedIndices = listOf(
),
onDragModeEnd = {},
onDragModeStart = {},
additionalItems = listOf(
),
getItemId = { it.id }
) { item, isDrag ->
Box(
modifier = Modifier
) {
CustomAsyncImage(
LocalContext.current,
item.bitmap,
contentDescription = "Image",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.noRippleClickable {
navController.navigate(NavigationRoute.NewPostImageGrid.route)
},
contentScale = ContentScale.Crop
)
if (isDrag) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0x66000000))
)
}
}
}
LazyVerticalGrid(
columns = GridCells.Fixed(3),
contentPadding = PaddingValues(16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(18.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.drawBehind {
val strokeWidth = 1.dp.toPx()
val dashLength = 10f
val dashGap = 10f
val pathEffect =
PathEffect.dashPathEffect(floatArrayOf(dashLength, dashGap))
drawRoundRect(
color = Color(0xFFD6D6D6),
style = Stroke(strokeWidth, pathEffect = pathEffect),
cornerRadius = CornerRadius(8.dp.toPx())
)
}
.noRippleClickable {
pickImagesLauncher.launch("image/*")
},
) {
Icon(
painter = painterResource(id = R.drawable.rider_pro_new_post_add_pic),
contentDescription = "Add Image",
modifier = Modifier
.size(48.dp)
.align(Alignment.Center),
tint = Color(0xFFD6D6D6)
)
}
}
item {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.drawBehind {
val strokeWidth = 1.dp.toPx()
val dashLength = 10f
val dashGap = 10f
val pathEffect =
PathEffect.dashPathEffect(floatArrayOf(dashLength, dashGap))
drawRoundRect(
color = Color(0xFFD6D6D6),
style = Stroke(strokeWidth, pathEffect = pathEffect),
cornerRadius = CornerRadius(8.dp.toPx())
)
}
.noRippleClickable {
val photoFile = File(context.cacheDir, "photo.jpg")
val photoUri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
photoFile
)
model.currentPhotoUri = photoUri
takePictureLauncher.launch(photoUri)
},
) {
Icon(
painter = painterResource(id = R.drawable.rider_pro_camera),
contentDescription = "Take Photo",
modifier = Modifier
.size(48.dp)
.align(Alignment.Center),
tint = Color(0xFFD6D6D6)
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AdditionalPostItem() {
val model = NewPostViewModel
val navController = LocalNavController.current
var isShowLocationModal by remember { mutableStateOf(false) }
fun onSelectLocationClick() {
isShowLocationModal = true
}
if (isShowLocationModal) {
ModalBottomSheet(
onDismissRequest = {
isShowLocationModal = false
},
containerColor = Color.White
) {
// Sheet content
SelectLocationModal(
onClose = {
isShowLocationModal = false
}
) {
isShowLocationModal = false
NewPostViewModel.searchPlaceAddressResult = it
}
}
}
Column(
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 24.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onSelectLocationClick()
}
) {
NewPostViewModel.searchPlaceAddressResult?.let {
SelectedLocation(it) {
NewPostViewModel.searchPlaceAddressResult = null
}
} ?: Row(
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_add_location),
contentDescription = "Location",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text("Add Location", color = Color(0xFF333333))
Spacer(modifier = Modifier.weight(1f))
Image(
painter = painterResource(id = R.drawable.rider_pro_nav_next),
contentDescription = "Add Location",
modifier = Modifier.size(24.dp)
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 24.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
navController.navigate("EditModification")
}
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_modification),
contentDescription = "Modification List",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text("Modification List", color = Color(0xFF333333))
Spacer(modifier = Modifier.weight(1f))
Image(
painter = painterResource(id = R.drawable.rider_pro_nav_next),
contentDescription = "Modification List",
modifier = Modifier.size(24.dp)
)
}
}
}
}
@Composable
fun SelectedLocation(
searchPlaceAddressResult: SearchPlaceAddressResult,
onRemoveLocation: () -> Unit
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp)
) {
Text(searchPlaceAddressResult.name, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(4.dp))
Text(searchPlaceAddressResult.address, color = Color(0xFF9a9a9a))
}
Image(
painter = painterResource(id = R.drawable.rider_pro_close),
contentDescription = "Next",
modifier = Modifier
.size(24.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
onRemoveLocation()
}
)
}
}
}

View File

@@ -0,0 +1,117 @@
package com.aiosman.ravenow.ui.post
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
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.layout.systemBars
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.rememberAsyncImagePainter
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.google.accompanist.systemuicontroller.rememberSystemUiController
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun NewPostImageGridScreen() {
val model = NewPostViewModel
val imageList = model.imageList
val pagerState = rememberPagerState(pageCount = { imageList.size })
val systemUiController = rememberSystemUiController()
val paddingValues = WindowInsets.systemBars.asPaddingValues()
val navController = LocalNavController.current
val title = "${pagerState.currentPage + 1}/${imageList.size}"
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = false)
}
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFF2e2e2e))
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Column {
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.AutoMirrored.Default.ArrowBack,
contentDescription = "back",
modifier = Modifier
.size(24.dp)
.noRippleClickable {
navController.popBackStack()
},
tint = Color.White
)
Text(
title,
color = Color.White,
modifier = Modifier.weight(1f),
textAlign = TextAlign.Center,
fontSize = 18.sp,
)
Icon(
Icons.Default.Delete,
contentDescription = "delete",
modifier = Modifier
.size(24.dp)
.noRippleClickable {
model.deleteImage(pagerState.currentPage)
},
tint = Color.White
)
}
}
}
HorizontalPager(
state = pagerState,
) { page ->
val imageUrl = imageList[page]
Image(
painter = rememberAsyncImagePainter(model = imageUrl.bitmap),
contentDescription = "Image $page",
modifier = Modifier.fillMaxSize()
)
}
}
}
}

View File

@@ -0,0 +1,162 @@
package com.aiosman.ravenow.ui.post
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.media.ExifInterface
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import com.aiosman.ravenow.data.MomentService
import com.aiosman.ravenow.entity.MomentServiceImpl
import com.aiosman.ravenow.data.UploadImage
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.exp.rotate
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentViewModel
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import com.aiosman.ravenow.ui.modification.Modification
import com.aiosman.ravenow.utils.FileUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
import java.io.InputStream
import java.util.UUID
data class ImageItem(
val uri: String,
val id: String,
val bitmap: Bitmap,
val file: File
) {
companion object {
suspend fun fromUri(context: Context,uri: String): ImageItem? {
// 保存图片文件到临时文件夹
context.contentResolver.openInputStream(Uri.parse(uri))?.use { inputStream ->
val tempFileName = UUID.randomUUID().toString()
val tempFile = File.createTempFile(tempFileName, null, context.cacheDir)
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
// 读取图片文件为 Bitmap
var bitmap = BitmapFactory.decodeFile(tempFile.absolutePath)
// 读取文件exif修正旋转
val exif = ExifInterface(tempFile.absolutePath)
bitmap = when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
ExifInterface.ORIENTATION_ROTATE_90 -> bitmap.rotate(90)
ExifInterface.ORIENTATION_ROTATE_180 -> bitmap.rotate(180)
ExifInterface.ORIENTATION_ROTATE_270 -> bitmap.rotate(270)
else -> bitmap
}
// 保存 bitmap 到临时文件夹
try {
val savedBitmapFilename = UUID.randomUUID().toString()
val bitmapFile = File.createTempFile(savedBitmapFilename, ".jpg", context.cacheDir)
FileOutputStream(bitmapFile).use { os ->
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, os)
}
// 清理临时文件
tempFile.delete()
return ImageItem(
Uri.fromFile(bitmapFile).toString(),
savedBitmapFilename,
bitmap,
bitmapFile
)
} catch (e: IOException) {
Log.e("NewPost", "Failed to save bitmap to file", e)
null
}
}
return null
}
}
}
object NewPostViewModel : ViewModel() {
var momentService: MomentService = MomentServiceImpl()
var textContent by mutableStateOf("")
var searchPlaceAddressResult by mutableStateOf<SearchPlaceAddressResult?>(null)
var modificationList by mutableStateOf<List<Modification>>(listOf())
var imageList by mutableStateOf(listOf<ImageItem>())
var relPostId by mutableStateOf<Int?>(null)
var relMoment by mutableStateOf<MomentEntity?>(null)
var currentPhotoUri: Uri? = null
fun asNewPost() {
textContent = ""
searchPlaceAddressResult = null
modificationList = listOf()
imageList = listOf()
relPostId = null
}
fun asNewPostWithImageUris(imageUris: List<String>) {
textContent = ""
searchPlaceAddressResult = null
modificationList = listOf()
// imageList = imageUris.map {
// ImageItem(it, UUID.randomUUID().toString())
// }
relPostId = null
}
suspend fun uriToFile(context: Context, uri: Uri): File {
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
val tempFile = withContext(Dispatchers.IO) {
File.createTempFile("temp", null, context.cacheDir)
}
inputStream?.use { input ->
FileOutputStream(tempFile).use { output ->
input.copyTo(output)
}
}
return tempFile
}
fun validateMoment(): String? {
if (imageList.isEmpty()) {
return "Please select at least one image"
}
return null
}
suspend fun createMoment(context: Context, onUploadProgress: (Float) -> Unit) {
val uploadImageList = emptyList<UploadImage>().toMutableList()
var index = 0
for (item in imageList) {
// 保存图片到本地
FileUtil.bitmapToJPG(context, item.bitmap, UUID.randomUUID().toString())
?.let { savedImageUri ->
// 读取保存的图片文件
uriToFile(context, savedImageUri).let { file ->
uploadImageList += UploadImage(file, file.name, item.uri, "jpg")
}
}
// 在上传过程中调用 onUploadProgress 更新进度
onUploadProgress(((index / imageList.size).toFloat())) // progressValue 是当前上传进度,例如 0.5 表示 50%
index += 1
}
momentService.createMoment(textContent, 1, uploadImageList, relPostId)
// 刷新个人动态
MyProfileViewModel.loadProfile(pullRefresh = true)
TimelineMomentViewModel.refreshPager()
}
suspend fun init() {
relPostId?.let {
val moment = momentService.getMomentById(it)
relMoment = moment
}
}
fun deleteImage(index: Int) {
imageList = imageList.toMutableList().apply {
removeAt(index)
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
package com.aiosman.ravenow.ui.post
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.MomentService
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentServiceImpl
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentViewModel
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import kotlinx.coroutines.launch
class PostViewModel(
val postId: String
) : ViewModel() {
var service: MomentService = MomentServiceImpl()
var userService: UserService = UserServiceImpl()
var accountProfileEntity by mutableStateOf<AccountProfileEntity?>(null)
var moment by mutableStateOf<MomentEntity?>(null)
var accountService: AccountService = AccountServiceImpl()
var commentsViewModel: CommentsViewModel = CommentsViewModel(postId)
var isError by mutableStateOf(false)
var isFirstLoad by mutableStateOf(true)
fun reloadComment() {
commentsViewModel.reloadComment()
}
suspend fun initData(highlightCommentId: Int? = null) {
if (!isFirstLoad) {
return
}
isFirstLoad = false
try {
moment = service.getMomentById(postId.toInt())
} catch (e: Exception) {
isError = true
return
}
highlightCommentId?.let {
commentsViewModel.highlightComment(it)
}
commentsViewModel.reloadComment()
}
suspend fun likeComment(commentId: Int) {
commentsViewModel.likeComment(commentId)
}
suspend fun unlikeComment(commentId: Int) {
commentsViewModel.unlikeComment(commentId)
}
suspend fun createComment(
content: String,
parentCommentId: Int? = null,
replyUserId: Int? = null,
replyCommentId: Int? = null
) {
commentsViewModel.createComment(
content = content,
parentCommentId = parentCommentId,
replyUserId = replyUserId,
replyCommentId = replyCommentId
)
moment = moment?.copy(commentCount = moment?.commentCount?.plus(1) ?: 0)
}
suspend fun likeMoment() {
moment?.let {
service.likeMoment(it.id)
moment = moment?.copy(likeCount = moment?.likeCount?.plus(1) ?: 0, liked = true)
TimelineMomentViewModel.updateLikeCount(it.id)
}
}
suspend fun dislikeMoment() {
moment?.let {
service.dislikeMoment(it.id)
moment = moment?.copy(likeCount = moment?.likeCount?.minus(1) ?: 0, liked = false)
// update home list
TimelineMomentViewModel.updateDislikeMomentById(it.id)
}
}
suspend fun favoriteMoment() {
moment?.let {
service.favoriteMoment(it.id)
moment =
moment?.copy(favoriteCount = moment?.favoriteCount?.plus(1) ?: 0, isFavorite = true)
}
}
suspend fun unfavoriteMoment() {
moment?.let {
service.unfavoriteMoment(it.id)
moment = moment?.copy(
favoriteCount = moment?.favoriteCount?.minus(1) ?: 0, isFavorite = false
)
}
}
suspend fun followUser() {
moment?.let {
userService.followUser(it.authorId.toString())
moment = moment?.copy(followStatus = true)
// 更新我的关注页面的关注数
}
}
suspend fun unfollowUser() {
moment?.let {
userService.unFollowUser(it.authorId.toString())
moment = moment?.copy(followStatus = false)
// 更新我的关注页面的关注数
}
}
fun deleteComment(commentId: Int) {
commentsViewModel.deleteComment(commentId)
moment = moment?.copy(commentCount = moment?.commentCount?.minus(1) ?: 0)
moment?.let {
TimelineMomentViewModel.updateMomentCommentCount(it.id, -1)
}
}
var avatar: String? = null
get() {
accountProfileEntity?.avatar?.let {
return it
}
moment?.avatar?.let {
return it
}
return field
}
var nickname: String? = null
get() {
accountProfileEntity?.nickName?.let {
return it
}
moment?.nickname?.let {
return it
}
return field
}
fun deleteMoment(callback: () -> Unit) {
viewModelScope.launch {
moment?.let {
service.deleteMoment(it.id)
TimelineMomentViewModel.deleteMoment(it.id)
MyProfileViewModel.deleteMoment(it.id)
}
callback()
}
}
fun loadMoreSubComments(commentId: Int) {
commentsViewModel.loadMoreSubComments(commentId)
}
}

View File

@@ -0,0 +1,217 @@
package com.aiosman.ravenow.ui.post
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.R
data class SearchPlaceAddressResult(
val name: String,
val address: String
)
@Composable
fun SelectLocationModal(
onClose: () -> Unit,
onSelectedLocation: (SearchPlaceAddressResult) -> Unit
) {
val context = LocalContext.current
var queryString by remember { mutableStateOf("") }
var searchPlaceAddressResults by remember {
mutableStateOf<List<SearchPlaceAddressResult>>(
emptyList()
)
}
// fun searchAddrWithGoogleMap(query: String) {
// val placesClient: PlacesClient = Places.createClient(context)
// val placeFields: List<Place.Field> =
// listOf(Place.Field.ID, Place.Field.NAME, Place.Field.ADDRESS)
// val request = SearchByTextRequest.newInstance(query, placeFields)
// placesClient.searchByText(request)
// .addOnSuccessListener { response ->
// val place = response.places
// searchPlaceAddressResults = place.map {
// SearchPlaceAddressResult(it.name ?: "", it.address ?: "")
// }
//
// }.addOnFailureListener { exception ->
// if (exception is ApiException) {
// Log.e("SelectLocationModal", "Place not found: ${exception.statusCode}")
// }
// }
// }
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 10.dp)
) {
Text(
"Check In",
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Center),
fontSize = 16.sp
)
Text(
"Cancel",
modifier = Modifier
.align(Alignment.CenterEnd)
.clickable {
onClose()
},
fontSize = 16.sp
)
}
LocationSearchTextInput(queryString, onQueryClick = {
// searchAddrWithGoogleMap(queryString)
}) {
queryString = it
}
LazyColumn(
modifier = Modifier
.weight(1f)
.padding(top = 28.dp)
) {
item {
for (searchPlaceAddressResult in searchPlaceAddressResults) {
LocationItem(searchPlaceAddressResult) {
onSelectedLocation(searchPlaceAddressResult)
}
}
}
}
}
}
@Composable
fun LocationSearchTextInput(
value: String,
onQueryClick: () -> Unit,
onValueChange: (String) -> Unit
) {
val keyboardController = LocalSoftwareKeyboardController.current
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp)
.clip(shape = RoundedCornerShape(16.dp))
.background(Color(0xffF5F5F5))
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_search_location),
contentDescription = "Search",
modifier = Modifier
.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
if (value.isEmpty()) {
Text(
"search",
modifier = Modifier.padding(vertical = 16.dp),
color = Color(0xffA0A0A0)
)
}
BasicTextField(
value = value,
onValueChange = onValueChange,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
keyboardOptions = KeyboardOptions(
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = {
onQueryClick()
// hide keyboard
keyboardController?.hide()
}
)
)
}
}
}
@Composable
fun LocationItem(
searchPlaceAddressResult: SearchPlaceAddressResult,
onLocationItemClick: () -> Unit = {}
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
.clickable {
onLocationItemClick()
}
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 16.dp)
) {
Text(searchPlaceAddressResult.name, fontWeight = FontWeight.Bold)
Text(searchPlaceAddressResult.address, color = Color(0xFF9a9a9a))
}
Image(
painter = painterResource(id = R.drawable.rider_pro_nav_next),
contentDescription = "Next",
modifier = Modifier.size(24.dp)
)
}
}
}