改包名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,231 @@
package com.aiosman.ravenow.ui.index
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemColors
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.index.tabs.add.AddPage
import com.aiosman.ravenow.ui.index.tabs.message.NotificationsScreen
import com.aiosman.ravenow.ui.index.tabs.moment.MomentsList
import com.aiosman.ravenow.ui.index.tabs.profile.ProfileWrap
import com.aiosman.ravenow.ui.index.tabs.search.DiscoverScreen
import com.aiosman.ravenow.ui.index.tabs.shorts.ShortVideo
import com.aiosman.ravenow.ui.index.tabs.street.StreetPage
import com.aiosman.ravenow.ui.post.NewPostViewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun IndexScreen() {
val AppColors = LocalAppTheme.current
val model = IndexViewModel
val navigationBarHeight = with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
val navController = LocalNavController.current
val item = listOf(
NavigationItem.Home,
NavigationItem.Search,
NavigationItem.Add,
NavigationItem.Notification,
NavigationItem.Profile
)
val systemUiController = rememberSystemUiController()
val pagerState = rememberPagerState(pageCount = { item.size })
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
systemUiController.setNavigationBarColor(Color.Transparent)
}
Scaffold(
bottomBar = {
NavigationBar(
modifier = Modifier.height(56.dp + navigationBarHeight),
containerColor = Color.Black
) {
item.forEachIndexed { idx, it ->
val isSelected = model.tabIndex == idx
val iconTint by animateColorAsState(
targetValue = if (isSelected) Color.White else Color.White,
animationSpec = tween(durationMillis = 250), label = ""
)
NavigationBarItem(
selected = isSelected,
onClick = {
if (it.route === NavigationItem.Add.route) {
NewPostViewModel.asNewPost()
navController.navigate(NavigationRoute.NewPost.route)
return@NavigationBarItem
}
coroutineScope.launch {
pagerState.scrollToPage(idx)
}
model.tabIndex = idx
},
colors = NavigationBarItemColors(
selectedTextColor = Color.Red,
selectedIndicatorColor = Color.Black,
unselectedTextColor = Color.Red,
disabledIconColor = Color.Red,
disabledTextColor = Color.Red,
selectedIconColor = iconTint,
unselectedIconColor = iconTint,
),
icon = {
Icon(
modifier = Modifier.size(24.dp),
imageVector = if (isSelected) it.selectedIcon() else it.icon(),
contentDescription = null,
tint = iconTint
)
}
)
}
}
}
) { innerPadding ->
innerPadding
HorizontalPager(
state = pagerState,
modifier = Modifier.background(AppColors.background).padding(0.dp),
beyondBoundsPageCount = 5,
userScrollEnabled = false
) { page ->
when (page) {
0 -> Home()
1 -> DiscoverScreen()
2 -> Add()
3 -> Notifications()
4 -> Profile()
}
}
}
}
@Composable
fun Home() {
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
}
Column(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
MomentsList()
}
}
@Composable
fun Street() {
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
}
Column(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
StreetPage()
}
}
@Composable
fun Add() {
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Black, darkIcons = !AppState.darkMode)
}
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
AddPage()
}
}
@Composable
fun Video() {
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Black, darkIcons = false)
}
Column(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally,
) {
ShortVideo()
}
}
@Composable
fun Profile() {
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, !AppState.darkMode)
}
Column(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
ProfileWrap()
}
}
@Composable
fun Notifications() {
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, !AppState.darkMode)
}
Column(
modifier = Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
NotificationsScreen()
}
}

View File

@@ -0,0 +1,15 @@
package com.aiosman.ravenow.ui.index
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
object IndexViewModel:ViewModel() {
var tabIndex by mutableStateOf(0)
fun ResetModel(){
tabIndex = 0
}
}

View File

@@ -0,0 +1,47 @@
package com.aiosman.ravenow.ui.index
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.vectorResource
import com.aiosman.ravenow.R
sealed class NavigationItem(
val route: String,
val icon: @Composable () -> ImageVector,
val selectedIcon: @Composable () -> ImageVector = icon
) {
data object Home : NavigationItem("Home",
icon = { ImageVector.vectorResource(R.drawable.rider_pro_nav_home) },
selectedIcon = { ImageVector.vectorResource(R.drawable.rider_pro_nav_home_hl) }
)
data object Street : NavigationItem("Street",
icon = { ImageVector.vectorResource(R.drawable.rider_pro_location) },
selectedIcon = { ImageVector.vectorResource(R.drawable.rider_pro_location_filed) }
)
data object Add : NavigationItem("Add",
icon = { ImageVector.vectorResource(R.drawable.rider_pro_nav_post_hl) },
selectedIcon = { ImageVector.vectorResource(R.drawable.rider_pro_nav_post_hl) }
)
data object Message : NavigationItem("Message",
icon = { ImageVector.vectorResource(R.drawable.rider_pro_video_outline) },
selectedIcon = { ImageVector.vectorResource(R.drawable.rider_pro_video) }
)
data object Notification : NavigationItem("Notification",
icon = { ImageVector.vectorResource(R.drawable.rider_pro_nav_notification)},
selectedIcon = { ImageVector.vectorResource(R.drawable.rider_pro_nav_notification_hl) }
)
data object Profile : NavigationItem("Profile",
icon = { ImageVector.vectorResource(R.drawable.rider_pro_nav_profile) },
selectedIcon = { ImageVector.vectorResource(R.drawable.rider_pro_nav_profile_hl) }
)
data object Search : NavigationItem("Search",
icon = { ImageVector.vectorResource(R.drawable.rider_pro_nav_search) },
selectedIcon = { ImageVector.vectorResource(R.drawable.rider_pro_nav_search_hl) }
)
}

View File

@@ -0,0 +1,53 @@
package com.aiosman.ravenow.ui.index.tabs.add
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.ui.post.NewPostViewModel
import com.aiosman.ravenow.R
@Composable
fun AddPage(){
val navController = LocalNavController.current
Column(modifier = Modifier
.fillMaxSize()
.background(Color.Black)) {
AddBtn(icon = R.drawable.rider_pro_icon_rider_share, text = "Rave NowShare") {
NewPostViewModel.asNewPost()
navController.navigate("NewPost")
}
// AddBtn(icon = R.drawable.rider_pro_location_create, text = "Location Create")
}
}
@Composable
fun AddBtn(@DrawableRes icon: Int, text: String,onClick: (() -> Unit)? = {}){
Row (modifier = Modifier
.fillMaxWidth().padding(24.dp).clickable {
onClick?.invoke()
},
verticalAlignment = Alignment.CenterVertically){
Image(
modifier = Modifier.size(40.dp),
painter = painterResource(id = icon), contentDescription = null)
Text(modifier = Modifier.padding(start = 24.dp),text = text, color = Color.White,fontSize = 22.sp, style = TextStyle(fontWeight = FontWeight.Bold))
}
}

View File

@@ -0,0 +1,345 @@
package com.aiosman.ravenow.ui.index.tabs.message
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.HorizontalDivider
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.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.AppStore
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.StatusBarSpacer
import com.aiosman.ravenow.ui.follower.FollowerNoticeViewModel
import com.aiosman.ravenow.ui.like.LikeNoticeViewModel
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.launch
/**
* 消息列表界面
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun NotificationsScreen() {
val AppColors = LocalAppTheme.current
val navController = LocalNavController.current
val systemUiController = rememberSystemUiController()
val context = LocalContext.current
val state = rememberPullRefreshState(MessageListViewModel.isLoading, onRefresh = {
MessageListViewModel.viewModelScope.launch {
MessageListViewModel.initData(context, force = true)
}
})
LaunchedEffect(Unit) {
systemUiController.setNavigationBarColor(Color.Transparent)
MessageListViewModel.initData(context)
}
Column(
modifier = Modifier.fillMaxSize()
) {
StatusBarSpacer()
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.pullRefresh(state)
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
NotificationIndicator(
MessageListViewModel.likeNoticeCount,
R.drawable.rider_pro_moment_like,
stringResource(R.string.like_upper)
) {
if (MessageListViewModel.likeNoticeCount > 0) {
// 刷新点赞消息列表
LikeNoticeViewModel.isFirstLoad = true
// 清除点赞消息数量
MessageListViewModel.clearLikeNoticeCount()
}
navController.navigate(NavigationRoute.Likes.route)
}
NotificationIndicator(
MessageListViewModel.followNoticeCount,
R.drawable.rider_pro_followers,
stringResource(R.string.followers_upper)
) {
if (MessageListViewModel.followNoticeCount > 0) {
// 刷新关注消息列表
FollowerNoticeViewModel.isFirstLoad = true
MessageListViewModel.clearFollowNoticeCount()
}
navController.navigate(NavigationRoute.Followers.route)
}
NotificationIndicator(
MessageListViewModel.commentNoticeCount,
R.drawable.rider_pro_comment,
stringResource(R.string.comment).uppercase()
) {
navController.navigate(NavigationRoute.CommentNoticeScreen.route)
}
}
HorizontalDivider(color = AppColors.divider, modifier = Modifier.padding(16.dp))
// NotificationCounterItem(MessageListViewModel.unReadConversationCount.toInt())
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
ChatMessageList(
MessageListViewModel.chatList,
onUserAvatarClick = { conv ->
MessageListViewModel.goToUserDetail(conv, navController)
},
) { conv ->
MessageListViewModel.goToChat(conv, navController)
}
}
}
PullRefreshIndicator(
MessageListViewModel.isLoading,
state,
Modifier.align(Alignment.TopCenter)
)
}
}
}
@Composable
fun NotificationIndicator(
notificationCount: Int,
iconRes: Int,
label: String,
onClick: () -> Unit
) {
val AppColors = LocalAppTheme.current
Box(
modifier = Modifier
) {
Box(
modifier = Modifier
.padding(16.dp)
.align(Alignment.TopCenter)
.noRippleClickable {
onClick()
}
) {
if (notificationCount > 0) {
Box(
modifier = Modifier
.background(AppColors.main, RoundedCornerShape(16.dp))
.padding(4.dp)
.align(Alignment.TopEnd)
) {
Text(
text = notificationCount.toString(),
color = AppColors.mainText,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Center)
)
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Box(
modifier = Modifier.padding(16.dp)
) {
Image(
painter = painterResource(id = iconRes),
contentDescription = label,
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(AppColors.text)
)
}
Box(
modifier = Modifier
) {
Text(label, modifier = Modifier.align(Alignment.Center), color = AppColors.text)
}
}
}
}
}
@Composable
fun NotificationCounterItem(count: Int) {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
var clickCount by remember { mutableStateOf(0) }
Row(
modifier = Modifier.padding(vertical = 16.dp, horizontal = 32.dp),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_notification),
contentDescription = "",
modifier = Modifier
.size(24.dp).noRippleClickable {
clickCount++
if (clickCount > 5) {
clickCount = 0
AppStore.saveDarkMode(!AppState.darkMode)
Toast.makeText(context, "Dark mode: ${AppState.darkMode},please restart app", Toast.LENGTH_SHORT).show()
}
},
colorFilter = ColorFilter.tint(AppColors.text)
)
Spacer(modifier = Modifier.width(24.dp))
Text(stringResource(R.string.notifications_upper), fontSize = 18.sp, color = AppColors.text)
Spacer(modifier = Modifier.weight(1f))
if (count > 0) {
Box(
modifier = Modifier
.background(AppColors.main, RoundedCornerShape(16.dp))
.padding(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = count.toString(),
color = AppColors.mainText,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
}
}
}
}
@Composable
fun ChatMessageList(
items: List<Conversation>,
onUserAvatarClick: (Conversation) -> Unit = {},
onChatClick: (Conversation) -> Unit = {}
) {
val AppColors = LocalAppTheme.current
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(items.size) { index ->
val item = items[index]
Row(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
) {
Box {
CustomAsyncImage(
context = LocalContext.current,
imageUrl = item.avatar,
contentDescription = item.nickname,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(48.dp))
.noRippleClickable {
onUserAvatarClick(item)
}
)
}
Column(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp)
.noRippleClickable {
onChatClick(item)
}
) {
Row {
Text(
text = item.nickname,
fontSize = 16.sp,
modifier = Modifier,
fontWeight = FontWeight.Bold,
color = AppColors.text
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = item.lastMessageTime,
fontSize = 14.sp,
color = AppColors.secondaryText,
)
}
Spacer(modifier = Modifier.height(6.dp))
Row {
Text(
text = "${if (item.isSelf) "Me: " else ""}${item.displayText}",
fontSize = 14.sp,
maxLines = 1,
color = AppColors.secondaryText,
modifier = Modifier.weight(1f),
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(4.dp))
if (item.unreadCount > 0) {
Box(
modifier = Modifier
.background(AppColors.main, CircleShape)
.padding(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = item.unreadCount.toString(),
color = AppColors.mainText,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Center)
)
}
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
}
}
}

View File

@@ -0,0 +1,212 @@
package com.aiosman.ravenow.ui.index.tabs.message
import android.content.Context
import android.icu.util.Calendar
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.navigation.NavController
import androidx.navigation.NavHostController
import androidx.paging.PagingData
import androidx.paging.map
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.data.AccountNotice
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.exp.formatChatTime
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.navigateToChat
import com.aiosman.ravenow.utils.TrtcHelper
import com.tencent.imsdk.v2.V2TIMConversation
import com.tencent.imsdk.v2.V2TIMConversationResult
import com.tencent.imsdk.v2.V2TIMManager
import com.tencent.imsdk.v2.V2TIMMessage
import com.tencent.imsdk.v2.V2TIMValueCallback
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlin.coroutines.suspendCoroutine
data class Conversation(
val id: String,
val trtcUserId: String,
val nickname: String,
val lastMessage: String,
val lastMessageTime: String,
val avatar: String = "",
val unreadCount: Int = 0,
val displayText: String,
val isSelf: Boolean
) {
companion object {
fun convertToConversation(msg: V2TIMConversation, context: Context): Conversation {
val lastMessage = Calendar.getInstance().apply {
timeInMillis = msg.lastMessage?.timestamp ?: 0
timeInMillis *= 1000
}
var displayText = ""
when (msg.lastMessage?.elemType) {
V2TIMMessage.V2TIM_ELEM_TYPE_TEXT -> {
displayText = msg.lastMessage?.textElem?.text ?: ""
}
V2TIMMessage.V2TIM_ELEM_TYPE_IMAGE -> {
displayText = "[图片]"
}
}
return Conversation(
id = msg.conversationID,
nickname = msg.showName,
lastMessage = msg.lastMessage?.textElem?.text ?: "",
lastMessageTime = lastMessage.time.formatChatTime(context),
avatar = msg.faceUrl,
unreadCount = msg.unreadCount,
trtcUserId = msg.userID,
displayText = displayText,
isSelf = msg.lastMessage.sender == AppState.profile?.trtcUserId
)
}
}
}
object MessageListViewModel : ViewModel() {
val accountService: AccountService = AccountServiceImpl()
val userService: UserService = UserServiceImpl()
var noticeInfo by mutableStateOf<AccountNotice?>(null)
var chatList by mutableStateOf<List<Conversation>>(emptyList())
private val _commentItemsFlow = MutableStateFlow<PagingData<CommentEntity>>(PagingData.empty())
var isLoading by mutableStateOf(false)
var unReadConversationCount by mutableStateOf(0L)
var isFirstLoad = true
suspend fun initData(context: Context, force: Boolean = false) {
loadChatList(context)
loadUnreadCount()
if (!isFirstLoad && !force) {
return
}
if (force) {
isLoading = true
}
isFirstLoad = false
val info = accountService.getMyNoticeInfo()
noticeInfo = info
isLoading = false
}
val likeNoticeCount
get() = noticeInfo?.likeCount ?: 0
val followNoticeCount
get() = noticeInfo?.followCount ?: 0
val favouriteNoticeCount
get() = noticeInfo?.favoriteCount ?: 0
val commentNoticeCount
get() = noticeInfo?.commentCount ?: 0
private fun updateIsRead(id: Int) {
val currentPagingData = _commentItemsFlow.value
val updatedPagingData = currentPagingData.map { commentEntity ->
if (commentEntity.id == id) {
commentEntity.copy(unread = false)
} else {
commentEntity
}
}
_commentItemsFlow.value = updatedPagingData
}
fun clearLikeNoticeCount() {
noticeInfo = noticeInfo?.copy(likeCount = 0)
}
fun clearFollowNoticeCount() {
noticeInfo = noticeInfo?.copy(followCount = 0)
}
fun clearFavouriteNoticeCount() {
noticeInfo = noticeInfo?.copy(favoriteCount = 0)
}
fun updateUnReadCount(delta: Int) {
noticeInfo?.let {
noticeInfo = it.copy(commentCount = it.commentCount + delta)
}
}
fun ResetModel() {
_commentItemsFlow.value = PagingData.empty()
noticeInfo = null
isLoading = false
isFirstLoad = true
}
suspend fun loadChatList(context: Context) {
val result = suspendCoroutine { continuation ->
V2TIMManager.getConversationManager().getConversationList(
0,
Int.MAX_VALUE,
object : V2TIMValueCallback<V2TIMConversationResult> {
override fun onSuccess(t: V2TIMConversationResult?) {
continuation.resumeWith(Result.success(t))
}
override fun onError(code: Int, desc: String?) {
continuation.resumeWith(Result.failure(Exception("Error $code: $desc")))
}
}
)
}
chatList = result?.conversationList?.map { msg: V2TIMConversation ->
Conversation.convertToConversation(msg, context)
} ?: emptyList()
}
suspend fun loadUnreadCount() {
try {
this.unReadConversationCount = TrtcHelper.loadUnreadCount()
} catch (e: Exception) {
e.printStackTrace()
this.unReadConversationCount = 0
}
}
fun goToChat(
conversation: Conversation,
navController: NavHostController
) {
viewModelScope.launch {
val profile = userService.getUserProfileByTrtcUserId(conversation.trtcUserId)
navController.navigateToChat(profile.id.toString())
}
}
fun goToUserDetail(
conversation: Conversation,
navController: NavController
) {
viewModelScope.launch {
val profile = userService.getUserProfileByTrtcUserId(conversation.trtcUserId)
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
profile.id.toString()
)
)
}
}
fun refreshConversation(context: Context, userId: String) {
viewModelScope.launch {
loadChatList(context)
}
}
}

View File

@@ -0,0 +1,133 @@
package com.aiosman.ravenow.ui.index.tabs.moment
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre.ExploreMomentsList
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentsList
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.launch
/**
* 动态列表
*/
@OptIn(ExperimentalMaterialApi::class, ExperimentalFoundationApi::class)
@Composable
fun MomentsList() {
val AppColors = LocalAppTheme.current
val model = MomentViewModel
val navigationBarPaddings =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
var pagerState = rememberPagerState { 2 }
var scope = rememberCoroutineScope()
Column(
modifier = Modifier
.fillMaxSize()
.padding(
top = statusBarPaddingValues.calculateTopPadding(),
bottom = navigationBarPaddings
),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
modifier = Modifier.fillMaxWidth().height(44.dp),
// center the tabs
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Column(
modifier = Modifier
.noRippleClickable {
scope.launch {
pagerState.animateScrollToPage(0)
}
},
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Worldwide", fontSize = 16.sp, color = AppColors.text,fontWeight = FontWeight.W600)
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.width(48.dp)
.height(4.dp)
.clip(RoundedCornerShape(16.dp))
.background(if (pagerState.currentPage == 0) AppColors.text else AppColors.background)
)
}
Spacer(modifier = Modifier.width(32.dp))
Column(
modifier = Modifier
.noRippleClickable {
scope.launch {
pagerState.animateScrollToPage(1)
}
},
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "Following", fontSize = 16.sp, color = AppColors.text, fontWeight = FontWeight.W600)
Spacer(modifier = Modifier.height(4.dp))
Box(
modifier = Modifier
.width(48.dp)
.height(4.dp)
.clip(RoundedCornerShape(16.dp))
.background(if (pagerState.currentPage == 1) AppColors.text else AppColors.background)
)
}
}
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
when (it) {
0 -> {
ExploreMomentsList()
}
1 -> {
TimelineMomentsList()
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
package com.aiosman.ravenow.ui.index.tabs.moment
import androidx.lifecycle.ViewModel
object MomentViewModel : ViewModel() {
}

View File

@@ -0,0 +1,85 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.ravenow.ui.composables.MomentCard
import kotlinx.coroutines.launch
/**
* 动态列表
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ExploreMomentsList() {
val model = MomentExploreViewModel
var dataFlow = model.momentsFlow
var moments = dataFlow.collectAsLazyPagingItems()
val scope = rememberCoroutineScope()
val state = rememberPullRefreshState(model.refreshing, onRefresh = {
model.refreshPager(
pullRefresh = true
)
})
LaunchedEffect(Unit) {
model.refreshPager()
}
Column(
modifier = Modifier
.fillMaxSize()
) {
Box(Modifier.pullRefresh(state)) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(
moments.itemCount,
key = { idx -> idx }
) { idx ->
val momentItem = moments[idx] ?: return@items
MomentCard(momentEntity = momentItem,
onAddComment = {
scope.launch {
model.onAddComment(momentItem.id)
}
},
onLikeClick = {
scope.launch {
if (momentItem.liked) {
model.dislikeMoment(momentItem.id)
} else {
model.likeMoment(momentItem.id)
}
}
},
onFavoriteClick = {
scope.launch {
if (momentItem.isFavorite) {
model.unfavoriteMoment(momentItem.id)
} else {
model.favoriteMoment(momentItem.id)
}
}
},
onFollowClick = {
model.followAction(momentItem)
},
)
}
}
PullRefreshIndicator(model.refreshing, state, Modifier.align(Alignment.TopCenter))
}
}
}

View File

@@ -0,0 +1,182 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.expolre
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.map
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.data.MomentService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentPagingSource
import com.aiosman.ravenow.entity.MomentRemoteDataSource
import com.aiosman.ravenow.entity.MomentServiceImpl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object MomentExploreViewModel : ViewModel() {
private val momentService: MomentService = MomentServiceImpl()
private val userService = UserServiceImpl()
private val _momentsFlow = MutableStateFlow<PagingData<MomentEntity>>(PagingData.empty())
val momentsFlow = _momentsFlow.asStateFlow()
var existsMoment = mutableStateOf(false)
var refreshing by mutableStateOf(false)
var isFirstLoad = true
fun refreshPager(pullRefresh: Boolean = false) {
if (!isFirstLoad && !pullRefresh) {
return
}
isFirstLoad = false
viewModelScope.launch {
if (pullRefresh) {
refreshing = true
}
// 检查是否有动态
val existMoments =
momentService.getMoments(timelineId = AppState.UserId, pageNumber = 1)
if (existMoments.list.isEmpty()) {
existsMoment.value = true
}
if (pullRefresh) {
refreshing = false
}
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
MomentPagingSource(
MomentRemoteDataSource(momentService),
// 如果没有动态,则显示热门动态
explore = true
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_momentsFlow.value = it
}
}
}
fun updateLikeCount(id: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(likeCount = momentItem.likeCount + 1, liked = true)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
suspend fun likeMoment(id: Int) {
momentService.likeMoment(id)
updateLikeCount(id)
}
fun updateCommentCount(id: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(commentCount = momentItem.commentCount + 1)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
suspend fun onAddComment(id: Int) {
val currentPagingData = _momentsFlow.value
updateCommentCount(id)
}
fun updateDislikeMomentById(id: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(likeCount = momentItem.likeCount - 1, liked = false)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
suspend fun dislikeMoment(id: Int) {
momentService.dislikeMoment(id)
updateDislikeMomentById(id)
}
fun updateFavoriteCount(id: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(favoriteCount = momentItem.favoriteCount + 1, isFavorite = true)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
suspend fun favoriteMoment(id: Int) {
momentService.favoriteMoment(id)
updateFavoriteCount(id)
}
fun updateUnfavoriteCount(id: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(favoriteCount = momentItem.favoriteCount - 1, isFavorite = false)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
suspend fun unfavoriteMoment(id: Int) {
momentService.unfavoriteMoment(id)
updateUnfavoriteCount(id)
}
fun updateFollowStatus(authorId:Int,isFollow:Boolean) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.authorId == authorId) {
momentItem.copy(followStatus = isFollow)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
fun followAction(moment: MomentEntity) {
viewModelScope.launch {
try {
if (moment.followStatus) {
userService.unFollowUser(moment.authorId.toString())
} else {
userService.followUser(moment.authorId.toString())
}
updateFollowStatus(moment.authorId, !moment.followStatus)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun ResetModel() {
_momentsFlow.value = PagingData.empty()
isFirstLoad = true
}
}

View File

@@ -0,0 +1,91 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.ravenow.ui.composables.MomentCard
import kotlinx.coroutines.launch
/**
* 动态列表
*/
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun TimelineMomentsList() {
val model = TimelineMomentViewModel
var dataFlow = model.momentsFlow
var moments = dataFlow.collectAsLazyPagingItems()
val scope = rememberCoroutineScope()
val state = rememberPullRefreshState(model.refreshing, onRefresh = {
model.refreshPager(
pullRefresh = true
)
})
LaunchedEffect(Unit) {
model.refreshPager()
}
Column(
modifier = Modifier
.fillMaxSize()
) {
Box(Modifier.pullRefresh(state)) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(
moments.itemCount,
key = { idx -> moments[idx]?.id ?: idx }
) { idx ->
val momentItem = moments[idx] ?: return@items
MomentCard(momentEntity = momentItem,
onAddComment = {
scope.launch {
model.onAddComment(momentItem.id)
}
},
onLikeClick = {
scope.launch {
if (momentItem.liked) {
model.dislikeMoment(momentItem.id)
} else {
model.likeMoment(momentItem.id)
}
}
},
onFavoriteClick = {
scope.launch {
if (momentItem.isFavorite) {
model.unfavoriteMoment(momentItem.id)
} else {
model.favoriteMoment(momentItem.id)
}
}
},
onFollowClick = {
model.followAction(momentItem)
},
showFollowButton = false
)
// Box(
// modifier = Modifier
// .height(4.dp)
// .fillMaxWidth()
// .background(Color(0xFFF0F2F5))
// )
}
}
PullRefreshIndicator(model.refreshing, state, Modifier.align(Alignment.TopCenter))
}
}
}

View File

@@ -0,0 +1,209 @@
package com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline
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.AppState
import com.aiosman.ravenow.data.MomentService
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentPagingSource
import com.aiosman.ravenow.entity.MomentRemoteDataSource
import com.aiosman.ravenow.entity.MomentServiceImpl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object TimelineMomentViewModel : ViewModel() {
private val momentService: MomentService = MomentServiceImpl()
private val _momentsFlow = MutableStateFlow<PagingData<MomentEntity>>(PagingData.empty())
private val userService :UserService = UserServiceImpl()
val momentsFlow = _momentsFlow.asStateFlow()
var existsMoment = mutableStateOf(false)
var refreshing by mutableStateOf(false)
var isFirstLoad = true
fun refreshPager(pullRefresh: Boolean = false) {
if (!isFirstLoad && !pullRefresh) {
return
}
isFirstLoad = false
viewModelScope.launch {
if (pullRefresh) {
refreshing = true
}
// 检查是否有动态
val existMoments =
momentService.getMoments(timelineId = AppState.UserId, pageNumber = 1)
if (existMoments.list.isEmpty()) {
existsMoment.value = true
}
if (pullRefresh) {
refreshing = false
}
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
MomentPagingSource(
MomentRemoteDataSource(momentService),
// 如果没有动态,则显示热门动态
timelineId = if (existMoments.list.isEmpty()) null else AppState.UserId,
trend = if (existMoments.list.isEmpty()) true else null
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_momentsFlow.value = it
}
}
}
fun updateLikeCount(id: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(likeCount = momentItem.likeCount + 1, liked = true)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
suspend fun likeMoment(id: Int) {
momentService.likeMoment(id)
updateLikeCount(id)
}
fun updateCommentCount(id: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(commentCount = momentItem.commentCount + 1)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
suspend fun onAddComment(id: Int) {
val currentPagingData = _momentsFlow.value
updateCommentCount(id)
}
fun updateDislikeMomentById(id: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(likeCount = momentItem.likeCount - 1, liked = false)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
suspend fun dislikeMoment(id: Int) {
momentService.dislikeMoment(id)
updateDislikeMomentById(id)
}
fun updateFavoriteCount(id: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(favoriteCount = momentItem.favoriteCount + 1, isFavorite = true)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
suspend fun favoriteMoment(id: Int) {
momentService.favoriteMoment(id)
updateFavoriteCount(id)
}
fun updateUnfavoriteCount(id: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(favoriteCount = momentItem.favoriteCount - 1, isFavorite = false)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
suspend fun unfavoriteMoment(id: Int) {
momentService.unfavoriteMoment(id)
updateUnfavoriteCount(id)
}
fun deleteMoment(id: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.filter { momentItem ->
momentItem.id != id
}
_momentsFlow.value = updatedPagingData
}
/**
* 更新动态评论数
*/
fun updateMomentCommentCount(id: Int, delta: Int) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.id == id) {
momentItem.copy(commentCount = momentItem.commentCount + delta)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
fun updateFollowStatus(authorId:Int,isFollow:Boolean) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.authorId == authorId) {
momentItem.copy(followStatus = isFollow)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
fun followAction(moment: MomentEntity) {
viewModelScope.launch {
try {
if (moment.followStatus) {
userService.unFollowUser(moment.authorId.toString())
} else {
userService.followUser(moment.authorId.toString())
}
updateFollowStatus(moment.authorId, !moment.followStatus)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun ResetModel() {
_momentsFlow.value = PagingData.empty()
isFirstLoad = true
}
}

View File

@@ -0,0 +1,137 @@
package com.aiosman.ravenow.ui.index.tabs.profile
import android.content.Context
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 androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import androidx.paging.filter
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.Messaging
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.MomentService
import com.aiosman.ravenow.data.UploadImage
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentPagingSource
import com.aiosman.ravenow.entity.MomentRemoteDataSource
import com.aiosman.ravenow.entity.MomentServiceImpl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import java.io.File
object MyProfileViewModel : ViewModel() {
val accountService: AccountService = AccountServiceImpl()
val momentService: MomentService = MomentServiceImpl()
var profile by mutableStateOf<AccountProfileEntity?>(null)
private var _sharedFlow = MutableStateFlow<PagingData<MomentEntity>>(PagingData.empty())
var sharedFlow = _sharedFlow.asStateFlow()
var refreshing by mutableStateOf(false)
var firstLoad = true
suspend fun loadUserProfile() {
val profile = accountService.getMyAccountProfile()
MyProfileViewModel.profile = profile
}
fun loadProfile(pullRefresh: Boolean = false) {
if (!firstLoad && !pullRefresh) return
viewModelScope.launch {
if (pullRefresh) {
refreshing = true
}
firstLoad = false
loadUserProfile()
refreshing = false
profile?.let {
try {
// Collect shared flow
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
MomentPagingSource(
MomentRemoteDataSource(momentService),
author = AppState.UserId
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_sharedFlow.value = it
}
} catch (e: Exception) {
Log.e("MyProfileViewModel", "loadProfile: ", e)
}
}
}
}
fun logout(context: Context) {
viewModelScope.launch {
Messaging.unregisterDevice(context)
AppStore.apply {
token = null
rememberMe = false
saveData()
}
// 删除推送渠道
AppState.ReloadAppState(context)
}
}
fun updateUserProfileBanner(bannerImageUrl: Uri?,file:File, context: Context) {
viewModelScope.launch {
val newBanner = bannerImageUrl?.let {
val cursor = context.contentResolver.query(it, null, null, null, null)
var newBanner: UploadImage? = null
cursor?.use { cur ->
val columnIndex = cur.getColumnIndex("_display_name")
if (cur.moveToFirst() && columnIndex != -1) {
val displayName = cur.getString(columnIndex)
val extension = displayName.substringAfterLast(".")
Log.d("Change banner", "File name: $displayName, extension: $extension")
// read as file
Log.d("Change banner", "File size: ${file.length()}")
newBanner = UploadImage(file, displayName, it.toString(), extension)
}
}
newBanner
}
accountService.updateProfile(
banner = newBanner,
avatar = null,
nickName = null,
bio = null
)
profile = accountService.getMyAccountProfile()
}
}
fun deleteMoment(id: Int) {
val currentPagingData = _sharedFlow.value
val updatedPagingData = currentPagingData.filter { momentItem ->
momentItem.id != id
}
_sharedFlow.value = updatedPagingData
}
val bio get() = profile?.bio ?: ""
val nickName get() = profile?.nickName ?: ""
val avatar get() = profile?.avatar
fun ResetModel() {
profile = null
_sharedFlow.value = PagingData.empty()
firstLoad = true
}
}

View File

@@ -0,0 +1,582 @@
package com.aiosman.ravenow.ui.index.tabs.profile
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
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
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.paging.PagingData
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.MainActivity
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.DropdownMenu
import com.aiosman.ravenow.ui.composables.MenuItem
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.pickupAndCompressLauncher
import com.aiosman.ravenow.ui.composables.toolbar.CollapsingToolbarScaffold
import com.aiosman.ravenow.ui.composables.toolbar.ScrollStrategy
import com.aiosman.ravenow.ui.composables.toolbar.rememberCollapsingToolbarScaffoldState
import com.aiosman.ravenow.ui.index.tabs.profile.composable.EmptyMomentPostUnit
import com.aiosman.ravenow.ui.index.tabs.profile.composable.GalleryItem
import com.aiosman.ravenow.ui.index.tabs.profile.composable.MomentPostUnit
import com.aiosman.ravenow.ui.index.tabs.profile.composable.OtherProfileAction
import com.aiosman.ravenow.ui.index.tabs.profile.composable.SelfProfileAction
import com.aiosman.ravenow.ui.index.tabs.profile.composable.UserContentPageIndicator
import com.aiosman.ravenow.ui.index.tabs.profile.composable.UserItem
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.post.NewPostViewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class)
@Composable
fun ProfileV3(
onUpdateBanner: ((Uri, File, Context) -> Unit)? = null,
profile: AccountProfileEntity? = null,
onLogout: () -> Unit = {},
onFollowClick: () -> Unit = {},
onChatClick: () -> Unit = {},
sharedFlow: SharedFlow<PagingData<MomentEntity>> = MutableStateFlow<PagingData<MomentEntity>>(
PagingData.empty()
).asStateFlow(),
isSelf: Boolean = true
) {
val model = MyProfileViewModel
val state = rememberCollapsingToolbarScaffoldState()
val pagerState = rememberPagerState(pageCount = { 2 })
var enabled by remember { mutableStateOf(true) }
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
var expanded by remember { mutableStateOf(false) }
var minibarExpanded by remember { mutableStateOf(false) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
var bannerHeight = 400
val pickBannerImageLauncher = pickupAndCompressLauncher(
context,
scope,
maxSize = ConstVars.BANNER_IMAGE_MAX_SIZE,
quality = 100
) { uri, file ->
onUpdateBanner?.invoke(uri, file, context)
}
val moments = sharedFlow.collectAsLazyPagingItems()
val refreshState = rememberPullRefreshState(model.refreshing, onRefresh = {
model.loadProfile(pullRefresh = true)
})
var miniToolbarHeight by remember { mutableStateOf(0) }
val density = LocalDensity.current
var appTheme = LocalAppTheme.current
var AppColors = appTheme
var systemUiController = rememberSystemUiController()
fun switchTheme(){
// delay
scope.launch {
delay(200)
AppState.switchTheme()
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
if (AppState.darkMode) {
(context as MainActivity).window.decorView.setBackgroundColor(android.graphics.Color.BLACK)
}else{
(context as MainActivity).window.decorView.setBackgroundColor(android.graphics.Color.WHITE)
}
}
}
Box(
modifier = Modifier.pullRefresh(refreshState)
) {
CollapsingToolbarScaffold(
modifier = Modifier
.fillMaxSize()
.background(AppColors.decentBackground),
state = state,
scrollStrategy = ScrollStrategy.ExitUntilCollapsed,
toolbarScrollable = true,
enabled = enabled,
toolbar = { toolbarScrollState ->
Column(
modifier = Modifier
.fillMaxWidth()
.height(miniToolbarHeight.dp)
// 保持在最低高度和当前高度之间
.background(AppColors.decentBackground)
) {
}
// header
Box(
modifier = Modifier
.parallax(0.5f)
.fillMaxWidth()
.height(600.dp)
.verticalScroll(toolbarScrollState)
) {
Box(
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
alpha = state.toolbarState.progress
}
) {
// banner
Box(
modifier = Modifier
.fillMaxWidth()
.height(bannerHeight.dp)
.background(AppColors.decentBackground)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(bannerHeight.dp - 24.dp)
.let {
if (isSelf) {
it.noRippleClickable {
Intent(Intent.ACTION_PICK).apply {
type = "image/*"
pickBannerImageLauncher.launch(this)
}
}
} else {
it
}
}
.shadow(
elevation = 6.dp,
shape = RoundedCornerShape(
bottomStart = 32.dp,
bottomEnd = 32.dp
),
)
) {
val banner = profile?.banner
if (banner != null) {
CustomAsyncImage(
LocalContext.current,
banner,
modifier = Modifier
.fillMaxSize(),
contentDescription = "",
contentScale = ContentScale.Crop
)
} else {
Image(
painter = painterResource(id = R.drawable.rider_pro_moment_demo_2),
modifier = Modifier
.fillMaxSize(),
contentDescription = "",
contentScale = ContentScale.Crop
)
}
}
if (isSelf) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(
top = statusBarPaddingValues.calculateTopPadding(),
start = 8.dp,
end = 8.dp
)
) {
Box(
modifier = Modifier
.padding(16.dp)
.clip(RoundedCornerShape(8.dp))
.background(AppColors.background.copy(alpha = 0.7f))
) {
Icon(
painter = painterResource(id = R.drawable.rider_pro_more_horizon),
contentDescription = "",
modifier = Modifier.noRippleClickable {
expanded = true
},
tint = AppColors.text
)
}
val themeModeString =
if (AppState.darkMode) R.string.light_mode else R.string.dark_mode
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
width = 250,
menuItems = listOf(
MenuItem(
stringResource(R.string.logout),
R.mipmap.rider_pro_logout
) {
expanded = false
scope.launch {
onLogout()
navController.navigate(NavigationRoute.Login.route) {
popUpTo(NavigationRoute.Index.route) {
inclusive = true
}
}
}
},
MenuItem(
stringResource(R.string.change_password),
R.mipmap.rider_pro_change_password
) {
expanded = false
scope.launch {
navController.navigate(NavigationRoute.ChangePasswordScreen.route)
}
},
MenuItem(
stringResource(R.string.favourites),
R.drawable.rider_pro_favourite
) {
expanded = false
scope.launch {
navController.navigate(NavigationRoute.FavouriteList.route)
}
},
MenuItem(
stringResource(themeModeString),
R.drawable.rider_pro_theme_mode_light
) {
expanded = false
switchTheme()
}
)
)
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.background(AppColors.decentBackground)
) {
// user info
Column(
modifier = Modifier
.fillMaxWidth()
) {
// Spacer(modifier = Modifier.height(16.dp))
// 个人信息
Box(
modifier = Modifier.padding(horizontal = 16.dp)
) {
profile?.let {
UserItem(it)
}
}
Spacer(modifier = Modifier.height(20.dp))
profile?.let {
Box(
modifier = Modifier.padding(horizontal = 16.dp)
) {
if (isSelf) {
SelfProfileAction {
navController.navigate(NavigationRoute.AccountEdit.route)
}
} else {
if (it.id != AppState.UserId) {
OtherProfileAction(
it,
onFollow = {
onFollowClick()
},
onChat = {
onChatClick()
}
)
}
}
}
}
}
}
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer {
alpha = 1 - state.toolbarState.progress
}
.background(AppColors.decentBackground)
.onGloballyPositioned {
miniToolbarHeight = with(density) {
it.size.height.toDp().value.toInt()
}
}
) {
StatusBarSpacer()
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
CustomAsyncImage(
LocalContext.current,
profile?.avatar,
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
contentDescription = "",
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(16.dp))
androidx.compose.material3.Text(
text = profile?.nickName ?: "",
fontSize = 16.sp,
fontWeight = FontWeight.W600,
color = AppColors.text
)
Spacer(modifier = Modifier.weight(1f))
if (isSelf) {
Box(
modifier = Modifier
) {
Box(
modifier = Modifier
.padding(16.dp)
) {
Icon(
painter = painterResource(id = R.drawable.rider_pro_more_horizon),
contentDescription = "",
modifier = Modifier.noRippleClickable {
minibarExpanded = true
},
tint = AppColors.text
)
}
val themeModeString =
if (AppState.darkMode) R.string.light_mode else R.string.dark_mode
DropdownMenu(
expanded = minibarExpanded,
onDismissRequest = { minibarExpanded = false },
width = 250,
menuItems = listOf(
MenuItem(
stringResource(R.string.logout),
R.mipmap.rider_pro_logout
) {
minibarExpanded = false
scope.launch {
onLogout()
navController.navigate(NavigationRoute.Login.route) {
popUpTo(NavigationRoute.Index.route) {
inclusive = true
}
}
}
},
MenuItem(
stringResource(R.string.change_password),
R.mipmap.rider_pro_change_password
) {
minibarExpanded = false
scope.launch {
navController.navigate(NavigationRoute.ChangePasswordScreen.route)
}
},
MenuItem(
stringResource(R.string.favourites),
R.drawable.rider_pro_favourite
) {
minibarExpanded = false
scope.launch {
navController.navigate(NavigationRoute.FavouriteList.route)
}
},
MenuItem(
stringResource(themeModeString),
R.drawable.rider_pro_theme_mode_light
) {
minibarExpanded = false
switchTheme()
}
)
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
}
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(AppColors.decentBackground)
) {
UserContentPageIndicator(
pagerState = pagerState,
)
Spacer(modifier = Modifier.height(8.dp))
HorizontalPager(
state = pagerState,
) { idx ->
when (idx) {
0 ->
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.spacedBy(
8.dp
),
verticalItemSpacing = 8.dp,
contentPadding = PaddingValues(8.dp)
) {
if (isSelf) {
items(1) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.75f)
.clip(
RoundedCornerShape(8.dp)
)
.background(
AppColors.background
)
.padding(8.dp)
.noRippleClickable {
NewPostViewModel.asNewPost()
navController.navigate(NavigationRoute.NewPost.route)
}
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(
RoundedCornerShape(8.dp)
)
.background(
AppColors.decentBackground
)
) {
Icon(
Icons.Default.Add,
contentDescription = "",
modifier = Modifier
.size(32.dp)
.align(Alignment.Center),
tint = AppColors.text
)
}
}
}
}
items(moments.itemCount) { idx ->
val moment = moments[idx] ?: return@items
GalleryItem(moment, idx)
}
items(2) {
Spacer(modifier = Modifier.height(120.dp))
}
}
1 ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
) {
if (moments.itemCount == 0 && isSelf) {
item {
EmptyMomentPostUnit()
}
}
item {
for (idx in 0 until moments.itemCount) {
val moment = moments[idx]
moment?.let {
MomentPostUnit(it)
}
}
}
item {
Spacer(modifier = Modifier.height(120.dp))
}
}
}
}
}
}
PullRefreshIndicator(model.refreshing, refreshState, Modifier.align(Alignment.TopCenter))
}
}

View File

@@ -0,0 +1,25 @@
package com.aiosman.ravenow.ui.index.tabs.profile
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.LocalContext
@Composable
fun ProfileWrap(
) {
val context = LocalContext.current
LaunchedEffect(Unit) {
MyProfileViewModel.loadProfile()
}
ProfileV3(
onUpdateBanner = { uri, file, context ->
MyProfileViewModel.updateUserProfileBanner(uri, file, context)
},
onLogout = {
MyProfileViewModel.logout(context)
},
profile = MyProfileViewModel.profile,
sharedFlow = MyProfileViewModel.sharedFlow,
)
}

View File

@@ -0,0 +1,61 @@
package com.aiosman.ravenow.ui.index.tabs.profile.composable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
@Composable
fun GalleryItem(
moment: MomentEntity,
idx: Int = 0
) {
val navController = LocalNavController.current
Box(
modifier = Modifier
.fillMaxWidth()
.let {
val firstImage = moment.images.firstOrNull()
if (firstImage?.width != null &&
firstImage.height != null &&
firstImage.width!! > 0 &&
firstImage.height!! > 0
) {
val ratio = firstImage.width!!.toFloat() / firstImage.height!!.toFloat()
return@let it.aspectRatio(ratio.coerceIn(0.7f, 1.5f))
} else {
return@let it.aspectRatio(if (idx % 3 == 0) 1.5f else 1f)
}
}
.clip(RoundedCornerShape(8.dp))
.noRippleClickable {
navController.navigateToPost(
moment.id
)
}
) {
CustomAsyncImage(
LocalContext.current,
moment.images[0].thumbnail,
modifier = Modifier
.fillMaxSize(),
contentDescription = "",
contentScale = ContentScale.Crop,
)
}
}

View File

@@ -0,0 +1,300 @@
package com.aiosman.ravenow.ui.index.tabs.profile.composable
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
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.aspectRatio
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.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Icon
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.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.exp.formatPostTime2
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
import com.aiosman.ravenow.ui.post.NewPostViewModel
@Composable
fun EmptyMomentPostUnit() {
TimeGroup(stringResource(R.string.empty_my_post_title))
ProfileEmptyMomentCard()
}
@Composable
fun ProfileEmptyMomentCard(
) {
val AppColors = LocalAppTheme.current
var columnHeight by remember { mutableStateOf(0) }
val navController = LocalNavController.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, top = 18.dp, end = 24.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
) {
Canvas(
modifier = Modifier
.height(with(LocalDensity.current) { columnHeight.toDp() })
.width(14.dp)
) {
drawLine(
color = Color(0xff899DA9),
start = Offset(0f, 0f),
end = Offset(0f, size.height),
strokeWidth = 4f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 20f), 0f)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column(
modifier = Modifier
.weight(1f)
.onGloballyPositioned { coordinates ->
columnHeight = coordinates.size.height
}
) {
Text(stringResource(R.string.empty_my_post_content), fontSize = 16.sp)
Spacer(modifier = Modifier.height(24.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(3f / 2f)
.background(Color.White)
.padding(16.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFF5F5F5))
.noRippleClickable {
NewPostViewModel.asNewPost()
navController.navigate(NavigationRoute.NewPost.route)
}
) {
Icon(
Icons.Default.Add,
tint = Color(0xFFD8D8D8),
contentDescription = "New post",
modifier = Modifier
.size(32.dp)
.align(Alignment.Center)
)
}
}
}
}
}
}
@Composable
fun MomentPostUnit(momentEntity: MomentEntity) {
TimeGroup(momentEntity.time.formatPostTime2())
ProfileMomentCard(
momentEntity.momentTextContent,
momentEntity.images[0].thumbnail,
momentEntity.likeCount.toString(),
momentEntity.commentCount.toString(),
momentEntity = momentEntity
)
}
@Composable
fun TimeGroup(time: String = "2024.06.08 12:23") {
val AppColors = LocalAppTheme.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, top = 40.dp, end = 24.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Image(
modifier = Modifier
.height(16.dp)
.width(14.dp),
painter = painterResource(id = R.drawable.rider_pro_moment_time_flag),
contentDescription = ""
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = time,
fontSize = 16.sp,
color = AppColors.text,
style = TextStyle(fontWeight = FontWeight.W600)
)
}
}
@Composable
fun ProfileMomentCard(
content: String,
imageUrl: String,
like: String,
comment: String,
momentEntity: MomentEntity
) {
var columnHeight by remember { mutableStateOf(0) }
val AppColors = LocalAppTheme.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, top = 18.dp, end = 24.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
) {
Canvas(
modifier = Modifier
.height(with(LocalDensity.current) { columnHeight.toDp() })
.width(14.dp)
) {
drawLine(
color = AppColors.divider,
start = Offset(0f, 0f),
end = Offset(0f, size.height),
strokeWidth = 4f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 20f), 0f)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column(
modifier = Modifier
.background(
AppColors.background)
.weight(1f)
.onGloballyPositioned { coordinates ->
columnHeight = coordinates.size.height
}
) {
if (content.isNotEmpty()) {
MomentCardTopContent(content)
}
MomentCardPicture(imageUrl, momentEntity = momentEntity)
MomentCardOperation(like, comment)
}
}
}
}
@Composable
fun MomentCardTopContent(content: String) {
val AppColors = LocalAppTheme.current
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Text(
modifier = Modifier.padding(top = 16.dp, bottom = 0.dp, start = 16.dp, end = 16.dp),
text = content, fontSize = 16.sp, color = AppColors.text
)
}
}
@Composable
fun MomentCardPicture(imageUrl: String, momentEntity: MomentEntity) {
val navController = LocalNavController.current
val context = LocalContext.current
CustomAsyncImage(
context,
imageUrl,
modifier = Modifier
.fillMaxSize()
.aspectRatio(3f / 2f)
.padding(top = 16.dp)
.noRippleClickable {
navController.navigateToPost(
id = momentEntity.id,
highlightCommentId = 0,
initImagePagerIndex = 0
)
},
contentDescription = "",
contentScale = ContentScale.Crop
)
}
@Composable
fun MomentCardOperation(like: String, comment: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
MomentCardOperationItem(
drawable = R.drawable.rider_pro_like,
number = like,
modifier = Modifier.padding(end = 32.dp)
)
MomentCardOperationItem(
drawable = R.drawable.rider_pro_moment_comment,
number = comment,
modifier = Modifier.padding(end = 32.dp)
)
}
}
@Composable
fun MomentCardOperationItem(@DrawableRes drawable: Int, number: String, modifier: Modifier) {
val AppColors = LocalAppTheme.current
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically
) {
Image(
modifier = Modifier.padding(start = 16.dp, end = 8.dp),
painter = painterResource(id = drawable), contentDescription = "",
colorFilter = ColorFilter.tint(AppColors.text)
)
Text(text = number, color = AppColors.text)
}
}

View File

@@ -0,0 +1,110 @@
package com.aiosman.ravenow.ui.index.tabs.profile.composable
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.Clear
import androidx.compose.material3.Icon
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.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun OtherProfileAction(
profile: AccountProfileEntity,
onFollow: (() -> Unit)? = null,
onChat: (() -> Unit)? = null
) {
val AppColors = LocalAppTheme.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.clip(RoundedCornerShape(32.dp))
.background(if (profile.isFollowing) AppColors.main else AppColors.basicMain)
.padding(horizontal = 16.dp, vertical = 4.dp)
.noRippleClickable {
onFollow?.invoke()
}
) {
if (profile.isFollowing) {
Icon(
Icons.Default.Clear,
contentDescription = "",
modifier = Modifier.size(24.dp),
tint = AppColors.mainText
)
} else {
Icon(
Icons.Default.Add,
contentDescription = "",
modifier = Modifier.size(24.dp),
tint = AppColors.mainText
)
}
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(if (profile.isFollowing) R.string.unfollow_upper else R.string.follow_upper),
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = if (profile.isFollowing) AppColors.mainText else AppColors.text,
modifier = Modifier.padding(8.dp),
)
}
Spacer(modifier = Modifier.width(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.clip(RoundedCornerShape(32.dp))
.background(AppColors.basicMain)
.padding(horizontal = 16.dp, vertical = 4.dp)
.noRippleClickable {
onChat?.invoke()
}
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_comment),
contentDescription = "",
modifier = Modifier.size(24.dp),
colorFilter = ColorFilter.tint(AppColors.text)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.chat_upper),
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = AppColors.text,
modifier = Modifier.padding(8.dp),
)
}
}
// 按钮
}

View File

@@ -0,0 +1,62 @@
package com.aiosman.ravenow.ui.index.tabs.profile.composable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material3.Icon
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.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun SelfProfileAction(
onEditProfile: () -> Unit
) {
val AppColors = LocalAppTheme.current
// 按钮
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.clip(RoundedCornerShape(32.dp))
.background(
AppColors.basicMain
)
.padding(horizontal = 16.dp, vertical = 4.dp)
.noRippleClickable {
onEditProfile()
}
) {
Icon(
Icons.Default.Edit,
contentDescription = "",
modifier = Modifier.size(24.dp),
tint = AppColors.text
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.edit_profile),
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = AppColors.text,
modifier = Modifier.padding(8.dp)
)
}
}

View File

@@ -0,0 +1,90 @@
package com.aiosman.ravenow.ui.index.tabs.profile.composable
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun UserContentPageIndicator(
pagerState: PagerState
){
val scope = rememberCoroutineScope()
val AppColors = LocalAppTheme.current
Row(
modifier = Modifier
.verticalScroll(rememberScrollState())
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.clip(RoundedCornerShape(32.dp))
.background(if (pagerState.currentPage == 0) AppColors.background else Color.Transparent)
.padding(horizontal = 16.dp, vertical = 4.dp)
.noRippleClickable {
// switch to gallery
scope.launch {
pagerState.scrollToPage(0)
}
}
) {
Text(
text = stringResource(R.string.gallery),
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = AppColors.text,
modifier = Modifier.padding(8.dp),
)
}
Spacer(modifier = Modifier.width(4.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.clip(RoundedCornerShape(32.dp))
.background(if (pagerState.currentPage == 1) AppColors.background else Color.Transparent)
.padding(horizontal = 16.dp, vertical = 4.dp)
.noRippleClickable {
// switch to moments
scope.launch {
pagerState.scrollToPage(1)
}
}
) {
Text(
text = stringResource(R.string.moment),
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = AppColors.text,
modifier = Modifier.padding(8.dp)
)
}
}
}

View File

@@ -0,0 +1,145 @@
package com.aiosman.ravenow.ui.index.tabs.profile.composable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun UserItem(accountProfileEntity: AccountProfileEntity) {
val navController = LocalNavController.current
val AppColors = LocalAppTheme.current
Column(
modifier = Modifier
.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
// 头像
CustomAsyncImage(
LocalContext.current,
accountProfileEntity.avatar,
modifier = Modifier
.clip(CircleShape)
.size(48.dp),
contentDescription = "",
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(32.dp))
//个人统计
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.weight(1f)
.noRippleClickable {
navController.navigate(
NavigationRoute.FollowerList.route.replace(
"{id}",
accountProfileEntity.id.toString()
)
)
}
) {
Text(
text = accountProfileEntity.followerCount.toString(),
fontWeight = FontWeight.W600,
fontSize = 16.sp,
color = AppColors.text
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.followers_upper),
color = AppColors.text
)
}
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
.noRippleClickable {
navController.navigate(
NavigationRoute.FollowingList.route.replace(
"{id}",
accountProfileEntity.id.toString()
)
)
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = accountProfileEntity.followingCount.toString(),
fontWeight = FontWeight.W600,
fontSize = 16.sp,
color = AppColors.text
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.following_upper),
color = AppColors.text
)
}
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier.weight(1f),
horizontalAlignment = Alignment.CenterHorizontally,
) {
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// 昵称
Text(
text = accountProfileEntity.nickName,
fontWeight = FontWeight.W600,
fontSize = 16.sp,
color = AppColors.text
)
Spacer(modifier = Modifier.height(4.dp))
// 个人简介
if (accountProfileEntity.bio.isNotEmpty()){
Text(
text = accountProfileEntity.bio,
fontSize = 14.sp,
color = AppColors.secondaryText
)
}else{
Text(
text = "No bio here.",
fontSize = 14.sp,
color = AppColors.secondaryText
)
}
}
}

View File

@@ -0,0 +1,192 @@
package com.aiosman.ravenow.ui.index.tabs.search
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.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
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.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.paging.compose.collectAsLazyPagingItems
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.StatusBarSpacer
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
@OptIn( ExperimentalMaterialApi::class)
@Preview
@Composable
fun DiscoverScreen() {
val model = DiscoverViewModel
val AppColors = LocalAppTheme.current
val navController = LocalNavController.current
val navigationBarPaddings =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 48.dp
LaunchedEffect(Unit) {
DiscoverViewModel.refreshPager()
}
var refreshing by remember { mutableStateOf(false) }
val state = rememberPullRefreshState(refreshing, onRefresh = {
model.refreshPager()
})
Column(
modifier = Modifier
.fillMaxSize()
.pullRefresh(state)
.padding(bottom = navigationBarPaddings)
) {
Column(
modifier = Modifier.fillMaxWidth().background(
AppColors.background).padding(bottom = 10.dp)
) {
StatusBarSpacer()
SearchButton(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, start = 16.dp, end = 16.dp),
) {
SearchViewModel.requestFocus = true
navController.navigate(NavigationRoute.Search.route)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
DiscoverView()
PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter))
}
}
}
@Composable
fun SearchButton(
modifier: Modifier = Modifier,
clickAction: () -> Unit = {}
) {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
Box(
modifier = modifier
.clip(shape = RoundedCornerShape(8.dp))
.background(
AppColors.inputBackground)
.padding(horizontal = 16.dp, vertical = 12.dp)
.noRippleClickable {
clickAction()
}
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Search,
contentDescription = null,
tint = AppColors.inputHint
)
Box {
Text(
text = context.getString(R.string.search),
modifier = Modifier.padding(start = 8.dp),
color = AppColors.inputHint,
fontSize = 18.sp
)
}
}
}
}
@Composable
fun DiscoverView() {
val model = DiscoverViewModel
var dataFlow = model.discoverMomentsFlow
var moments = dataFlow.collectAsLazyPagingItems()
val context = LocalContext.current
val navController = LocalNavController.current
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxSize().padding(bottom = 8.dp),
// contentPadding = PaddingValues(8.dp)
) {
items(moments.itemCount) { idx ->
val momentItem = moments[idx] ?: return@items
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.padding(2.dp)
.clip(RoundedCornerShape(8.dp))
.noRippleClickable {
navController.navigateToPost(
id = momentItem.id,
highlightCommentId = 0,
initImagePagerIndex = 0
)
}
) {
CustomAsyncImage(
imageUrl = momentItem.images[0].thumbnail,
contentDescription = "",
modifier = Modifier
.fillMaxSize(),
context = context
)
if (momentItem.images.size > 1) {
Box(
modifier = Modifier
.padding(top = 8.dp, end = 8.dp)
.align(Alignment.TopEnd)
) {
Image(
modifier = Modifier.size(24.dp),
painter = painterResource(R.drawable.rider_pro_picture_more),
contentDescription = "",
)
}
}
}
}
}
}

View File

@@ -0,0 +1,47 @@
package com.aiosman.ravenow.ui.index.tabs.search
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 com.aiosman.ravenow.data.MomentService
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentPagingSource
import com.aiosman.ravenow.entity.MomentRemoteDataSource
import com.aiosman.ravenow.entity.MomentServiceImpl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object DiscoverViewModel:ViewModel() {
private val momentService: MomentService = MomentServiceImpl()
private val _discoverMomentsFlow =
MutableStateFlow<PagingData<MomentEntity>>(PagingData.empty())
val discoverMomentsFlow = _discoverMomentsFlow.asStateFlow()
var firstLoad = true
fun refreshPager() {
if (!firstLoad) {
return
}
firstLoad = false
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
MomentPagingSource(
MomentRemoteDataSource(momentService),
trend = true
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_discoverMomentsFlow.value = it
}
}
}
fun ResetModel(){
firstLoad = true
}
}

View File

@@ -0,0 +1,387 @@
package com.aiosman.ravenow.ui.index.tabs.search
import androidx.compose.foundation.ExperimentalFoundationApi
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.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
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.material.Icon
import androidx.compose.material.Tab
import androidx.compose.material.TabRow
import androidx.compose.material.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.MomentCard
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Preview
@Composable
fun SearchScreen() {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val model = SearchViewModel
val categories = listOf(context.getString(R.string.moment), context.getString(R.string.users))
val coroutineScope = rememberCoroutineScope()
val pagerState = rememberPagerState(pageCount = { categories.size })
val selectedTabIndex = remember { derivedStateOf { pagerState.currentPage } }
val keyboardController = LocalSoftwareKeyboardController.current
val systemUiController = rememberSystemUiController()
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
val navigationBarPaddings =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val focusRequester = remember { FocusRequester() }
val navController = LocalNavController.current
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
}
LaunchedEffect(Unit) {
if (model.requestFocus) {
focusRequester.requestFocus()
model.requestFocus = false
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
.padding(bottom = navigationBarPaddings)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(
AppColors.background
)
.padding(bottom = 10.dp)
) {
Spacer(modifier = Modifier.height(statusBarPaddingValues.calculateTopPadding()))
Row(
modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
SearchInput(
modifier = Modifier
.weight(1f),
text = model.searchText,
onTextChange = {
model.searchText = it
},
onSearch = {
model.search()
// hide ime
keyboardController?.hide() // Hide the keyboard
},
focusRequester = focusRequester
)
Spacer(modifier = Modifier.size(16.dp))
Text(
stringResource(R.string.cancel),
fontSize = 16.sp,
modifier = Modifier.noRippleClickable {
navController.navigateUp()
},
color = AppColors.text
)
}
}
if (model.showResult) {
TabRow(
selectedTabIndex = selectedTabIndex.value,
backgroundColor = AppColors.background,
contentColor = AppColors.text,
) {
categories.forEachIndexed { index, category ->
Tab(
selected = selectedTabIndex.value == index,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(index)
}
},
text = { Text(category, color = AppColors.text) }
)
}
}
SearchPager(
pagerState = pagerState
)
}
}
}
@Composable
fun SearchInput(
modifier: Modifier = Modifier,
text: String = "",
onTextChange: (String) -> Unit = {},
onSearch: () -> Unit = {},
focusRequester: FocusRequester = remember { FocusRequester() }
) {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
Box(
modifier = modifier
.clip(shape = RoundedCornerShape(8.dp))
.background(
AppColors.inputBackground
)
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Search,
contentDescription = null,
tint = AppColors.inputHint
)
Box {
if (text.isEmpty()) {
Text(
text = context.getString(R.string.search),
modifier = Modifier.padding(start = 8.dp),
color = AppColors.inputHint,
fontSize = 18.sp
)
}
BasicTextField(
value = text,
onValueChange = {
onTextChange(it)
},
modifier = Modifier
.padding(start = 8.dp)
.fillMaxWidth()
.focusRequester(focusRequester),
singleLine = true,
textStyle = TextStyle(
fontSize = 18.sp,
color = AppColors.text
),
keyboardOptions = KeyboardOptions.Default.copy(
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = {
onSearch()
}
),
cursorBrush = SolidColor(AppColors.text)
)
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SearchPager(
pagerState: PagerState,
) {
val AppColors = LocalAppTheme.current
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxSize().background(AppColors.background),
) { page ->
when (page) {
0 -> MomentResultTab()
1 -> UserResultTab()
}
}
}
@Composable
fun MomentResultTab() {
val model = SearchViewModel
var dataFlow = model.momentsFlow
var moments = dataFlow.collectAsLazyPagingItems()
val AppColors = LocalAppTheme.current
Box(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(moments.itemCount) { idx ->
val momentItem = moments[idx] ?: return@items
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
) {
MomentCard(
momentEntity = momentItem,
hideAction = true,
onFollowClick = {
model.momentFollowAction(momentItem)
}
)
}
// Spacer(modifier = Modifier.padding(16.dp))
}
}
}
}
@Composable
fun UserResultTab() {
val model = SearchViewModel
val users = model.usersFlow.collectAsLazyPagingItems()
val scope = rememberCoroutineScope()
Box(
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(users.itemCount) { idx ->
val userItem = users[idx] ?: return@items
UserItem(userItem) {
scope.launch {
if (userItem.isFollowing) {
model.unfollowUser(userItem.id)
} else {
model.followUser(userItem.id)
}
}
}
}
}
}
}
@Composable
fun UserItem(
accountProfile: AccountProfileEntity,
onFollow: (AccountProfileEntity) -> Unit = {},
) {
val context = LocalContext.current
val navController = LocalNavController.current
val AppColors = LocalAppTheme.current
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.noRippleClickable {
navController.navigate("AccountProfile/${accountProfile.id}")
},
verticalAlignment = Alignment.CenterVertically
) {
CustomAsyncImage(
context,
imageUrl = accountProfile.avatar,
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
contentDescription = null
)
Spacer(modifier = Modifier.padding(8.dp))
Row(
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = accountProfile.nickName,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = AppColors.text
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = stringResource(
R.string.search_user_item_follower_count,
accountProfile.followerCount
), fontSize = 14.sp, color = AppColors.secondaryText
)
}
Spacer(modifier = Modifier.width(16.dp))
// Box {
// if (accountProfile.id != AppState.UserId) {
// if (accountProfile.isFollowing) {
// ActionButton(
// text = stringResource(R.string.following_upper),
// backgroundColor = Color(0xFF9E9E9E),
// contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp),
// color = Color.White,
// fullWidth = false
// ) {
// onFollow(accountProfile)
// }
// } else {
// ActionButton(
// text = stringResource(R.string.follow_upper),
// backgroundColor = Color(0xffda3832),
// contentPadding = PaddingValues(vertical = 4.dp, horizontal = 8.dp),
// color = Color.White,
// fullWidth = false
// ) {
// onFollow(accountProfile)
// }
// }
// }
// }
}
}
}

View File

@@ -0,0 +1,127 @@
package com.aiosman.ravenow.ui.index.tabs.search
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.map
import com.aiosman.ravenow.data.MomentService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.entity.AccountPagingSource
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentPagingSource
import com.aiosman.ravenow.entity.MomentRemoteDataSource
import com.aiosman.ravenow.entity.MomentServiceImpl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object SearchViewModel : ViewModel() {
var searchText by mutableStateOf("")
private val momentService: MomentService = MomentServiceImpl()
private val _momentsFlow = MutableStateFlow<PagingData<MomentEntity>>(PagingData.empty())
val momentsFlow = _momentsFlow.asStateFlow()
private val userService = UserServiceImpl()
private val _usersFlow = MutableStateFlow<PagingData<AccountProfileEntity>>(PagingData.empty())
val usersFlow = _usersFlow.asStateFlow()
var showResult by mutableStateOf(false)
var requestFocus by mutableStateOf(false)
fun search() {
if (searchText.isEmpty()) {
return
}
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
MomentPagingSource(
MomentRemoteDataSource(momentService),
contentSearch = searchText
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_momentsFlow.value = it
}
}
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
AccountPagingSource(
userService,
nickname = searchText
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_usersFlow.value = it
}
}
showResult = true
}
suspend fun followUser(id:Int){
userService.followUser(id.toString())
val currentPagingData = _usersFlow.value
val updatedPagingData = currentPagingData.map { userItem ->
if (userItem.id == id) {
userItem.copy(isFollowing = true, followerCount = userItem.followerCount + 1)
} else {
userItem
}
}
_usersFlow.value = updatedPagingData
}
suspend fun unfollowUser(id:Int){
userService.unFollowUser(id.toString())
val currentPagingData = _usersFlow.value
val updatedPagingData = currentPagingData.map { userItem ->
if (userItem.id == id) {
userItem.copy(isFollowing = false, followerCount = userItem.followerCount - 1)
} else {
userItem
}
}
_usersFlow.value = updatedPagingData
}
fun ResetModel(){
_momentsFlow.value = PagingData.empty()
_usersFlow.value = PagingData.empty()
showResult = false
}
fun updateMomentFollowStatus(authorId:Int,isFollow:Boolean) {
val currentPagingData = _momentsFlow.value
val updatedPagingData = currentPagingData.map { momentItem ->
if (momentItem.authorId == authorId) {
momentItem.copy(followStatus = isFollow)
} else {
momentItem
}
}
_momentsFlow.value = updatedPagingData
}
fun momentFollowAction(moment: MomentEntity) {
viewModelScope.launch {
try {
if (moment.followStatus) {
userService.unFollowUser(moment.authorId.toString())
} else {
userService.followUser(moment.authorId.toString())
}
updateMomentFollowStatus(moment.authorId, !moment.followStatus)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}

View File

@@ -0,0 +1,231 @@
package com.aiosman.ravenow.ui.index.tabs.shorts
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Density
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.roundToInt
class PagerState(
currentPage: Int = 0,
minPage: Int = 0,
maxPage: Int = 0
) {
private var _minPage by mutableStateOf(minPage)
var minPage: Int
get() = _minPage
set(value) {
_minPage = value.coerceAtMost(_maxPage)
_currentPage = _currentPage.coerceIn(_minPage, _maxPage)
}
private var _maxPage by mutableStateOf(maxPage, structuralEqualityPolicy())
var maxPage: Int
get() = _maxPage
set(value) {
_maxPage = value.coerceAtLeast(_minPage)
_currentPage = _currentPage.coerceIn(_minPage, maxPage)
}
private var _currentPage by mutableStateOf(currentPage.coerceIn(minPage, maxPage))
var currentPage: Int
get() = _currentPage
set(value) {
_currentPage = value.coerceIn(minPage, maxPage)
}
enum class SelectionState { Selected, Undecided }
var selectionState by mutableStateOf(SelectionState.Selected)
suspend inline fun <R> selectPage(block: PagerState.() -> R): R = try {
selectionState = SelectionState.Undecided
block()
} finally {
selectPage()
}
suspend fun selectPage() {
currentPage -= currentPageOffset.roundToInt()
snapToOffset(0f)
selectionState = SelectionState.Selected
}
private var _currentPageOffset = Animatable(0f).apply {
updateBounds(-1f, 1f)
}
val currentPageOffset: Float
get() = _currentPageOffset.value
suspend fun snapToOffset(offset: Float) {
val max = if (currentPage == minPage) 0f else 1f
val min = if (currentPage == maxPage) 0f else -1f
_currentPageOffset.snapTo(offset.coerceIn(min, max))
}
suspend fun fling(velocity: Float) {
if (velocity < 0 && currentPage == maxPage) return
if (velocity > 0 && currentPage == minPage) return
// 根据 fling 的方向滑动到下一页或上一页
_currentPageOffset.animateTo(velocity)
selectPage()
}
override fun toString(): String = "PagerState{minPage=$minPage, maxPage=$maxPage, " +
"currentPage=$currentPage, currentPageOffset=$currentPageOffset}"
}
@Immutable
private data class PageData(val page: Int) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any? = this@PageData
}
private val Measurable.page: Int
get() = (parentData as? PageData)?.page ?: error("no PageData for measurable $this")
@Composable
fun Pager(
modifier: Modifier = Modifier,
state: PagerState,
orientation: Orientation = Orientation.Horizontal,
offscreenLimit: Int = 2,
horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, // 新增水平对齐参数
verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, // 新增垂直对齐参数
content: @Composable PagerScope.() -> Unit
) {
var pageSize by remember { mutableStateOf(0) }
val coroutineScope = rememberCoroutineScope()
Layout(
content = {
// 根据 offscreenLimit 计算页面范围
val minPage = maxOf(state.currentPage - offscreenLimit, state.minPage)
val maxPage = minOf(state.currentPage + offscreenLimit, state.maxPage)
for (page in minPage..maxPage) {
val pageData = PageData(page)
val scope = PagerScope(state, page)
key(pageData) {
Column(modifier = pageData) {
scope.content()
}
}
}
},
modifier = modifier.draggable(
orientation = orientation,
onDragStarted = {
state.selectionState = PagerState.SelectionState.Undecided
},
onDragStopped = { velocity ->
coroutineScope.launch {
// 根据速度判断是否滑动到下一页
val threshold = 1000f // 速度阈值,可调整
if (velocity > threshold) {
state.fling(1f) // 向右滑动
} else if (velocity < -threshold) {
state.fling(-1f) // 向左滑动
} else {
state.fling(0f) // 保持当前页
}
}
},
state = rememberDraggableState { dy ->
coroutineScope.launch {
with(state) {
val pos = pageSize * currentPageOffset
val max = if (currentPage == minPage) 0 else pageSize
val min = if (currentPage == maxPage) 0 else -pageSize
// 直接将手指的位移应用到 currentPageOffset
val newPos = (pos + dy).coerceIn(min.toFloat(), max.toFloat())
snapToOffset(newPos / pageSize)
}
}
},
)
) { measurables, constraints ->
layout(constraints.maxWidth, constraints.maxHeight) {
val currentPage = state.currentPage
val offset = state.currentPageOffset
val childConstraints = constraints.copy(minWidth = 0, minHeight = 0)
measurables.forEach { measurable ->
val placeable = measurable.measure(childConstraints)
val page = measurable.page
// 根据对齐参数计算 x 和 y 位置
val xPosition = when (horizontalAlignment) {
Alignment.Start -> 0
Alignment.CenterHorizontally -> (constraints.maxWidth - placeable.width) / 2
Alignment.End -> constraints.maxWidth - placeable.width
else -> 0
}
val yPosition = when (verticalAlignment) {
Alignment.Top -> 0
Alignment.CenterVertically -> (constraints.maxHeight - placeable.height) / 2
Alignment.Bottom -> constraints.maxHeight - placeable.height
else -> 0
}
if (currentPage == page) { // 只在当前页面设置 pageSize避免不必要的设置
pageSize = if (orientation == Orientation.Horizontal) {
placeable.width
} else {
placeable.height
}
}
val isVisible = abs(page - (currentPage - offset)) <= 1
if (isVisible) {
// 修正 x 的计算
val xOffset = if (orientation == Orientation.Horizontal) {
((page - currentPage) * pageSize + offset * pageSize).roundToInt()
} else {
0
}
// 使用 placeRelative 进行放置
placeable.placeRelative(
x = xPosition + xOffset,
y = yPosition + if (orientation == Orientation.Vertical) ((page - (currentPage - offset)) * placeable.height).roundToInt() else 0
)
}
}
}
}
}
class PagerScope(
private val state: PagerState,
val page: Int
) {
val currentPage: Int
get() = state.currentPage
val currentPageOffset: Float
get() = state.currentPageOffset
val selectionState: PagerState.SelectionState
get() = state.selectionState
}

View File

@@ -0,0 +1,69 @@
package com.aiosman.ravenow.ui.index.tabs.shorts
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import com.aiosman.ravenow.ui.theme.RiderProTheme
val videoUrls = listOf(
"https://api.rider-pro.com/test/shorts/1.mp4",
"https://api.rider-pro.com/test/shorts/2.mp4",
"https://api.rider-pro.com/test/shorts/3.mp4",
"https://api.rider-pro.com/test/shorts/4.mp4",
"https://api.rider-pro.com/test/shorts/5.webm",
"https://api.rider-pro.com/test/shorts/6.webm",
"https://api.rider-pro.com/test/shorts/7.webm",
"https://api.rider-pro.com/test/shorts/8.webm",
"https://api.rider-pro.com/test/shorts/9.webm",
"https://api.rider-pro.com/test/shorts/10.webm",
"https://api.rider-pro.com/test/shorts/11.webm",
"https://api.rider-pro.com/test/shorts/12.webm",
"https://api.rider-pro.com/test/shorts/13.webm",
"https://api.rider-pro.com/test/shorts/14.webm",
"https://api.rider-pro.com/test/shorts/15.webm",
"https://api.rider-pro.com/test/shorts/16.webm",
"https://api.rider-pro.com/test/shorts/17.webm",
"https://api.rider-pro.com/test/shorts/18.webm",
"https://api.rider-pro.com/test/shorts/19.webm",
"https://api.rider-pro.com/test/shorts/20.webm",
"https://api.rider-pro.com/test/shorts/21.webm",
"https://api.rider-pro.com/test/shorts/22.webm",
"https://api.rider-pro.com/test/shorts/23.webm",
"https://api.rider-pro.com/test/shorts/24.webm",
"https://api.rider-pro.com/test/shorts/25.webm",
"https://api.rider-pro.com/test/shorts/26.webm",
"https://api.rider-pro.com/test/shorts/27.webm",
"https://api.rider-pro.com/test/shorts/28.webm",
"https://api.rider-pro.com/test/shorts/29.webm",
"https://api.rider-pro.com/test/shorts/30.webm",
"https://api.rider-pro.com/test/shorts/31.webm",
"https://api.rider-pro.com/test/shorts/32.webm",
"https://api.rider-pro.com/test/shorts/33.webm",
"https://api.rider-pro.com/test/shorts/34.webm",
"https://api.rider-pro.com/test/shorts/35.webm",
"https://api.rider-pro.com/test/shorts/36.webm",
"https://api.rider-pro.com/test/shorts/37.webm",
"https://api.rider-pro.com/test/shorts/38.webm",
"https://api.rider-pro.com/test/shorts/39.webm",
"https://api.rider-pro.com/test/shorts/40.webm",
"https://api.rider-pro.com/test/shorts/41.webm",
)
@Composable
fun ShortVideo() {
RiderProTheme {
Surface(color = MaterialTheme.colorScheme.background) {
ShortViewCompose(
videoItemsUrl = videoUrls,
clickItemPosition = 0
)
}
}
}

View File

@@ -0,0 +1,392 @@
@file:kotlin.OptIn(ExperimentalMaterial3Api::class)
package com.aiosman.ravenow.ui.index.tabs.shorts
import android.net.Uri
import android.view.Gravity
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.DrawableRes
import androidx.annotation.OptIn
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
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
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
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.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.util.Util
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSourceFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.PlayerView
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.comment.CommentModalContent
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun ShortViewCompose(
videoItemsUrl: List<String>,
clickItemPosition: Int = 0,
videoHeader: @Composable () -> Unit = {},
videoBottom: @Composable () -> Unit = {}
) {
val pagerState: PagerState = run {
remember {
PagerState(clickItemPosition, 0, videoItemsUrl.size - 1)
}
}
val initialLayout = remember {
mutableStateOf(true)
}
val pauseIconVisibleState = remember {
mutableStateOf(false)
}
Pager(
state = pagerState,
orientation = Orientation.Vertical,
offscreenLimit = 1
) {
pauseIconVisibleState.value = false
SingleVideoItemContent(
videoItemsUrl[page],
pagerState,
page,
initialLayout,
pauseIconVisibleState,
videoHeader,
videoBottom
)
}
LaunchedEffect(clickItemPosition) {
delay(300)
initialLayout.value = false
}
}
@Composable
private fun SingleVideoItemContent(
videoUrl: String,
pagerState: PagerState,
pager: Int,
initialLayout: MutableState<Boolean>,
pauseIconVisibleState: MutableState<Boolean>,
VideoHeader: @Composable() () -> Unit,
VideoBottom: @Composable() () -> Unit,
) {
Box(modifier = Modifier.fillMaxSize()) {
VideoPlayer(videoUrl, pagerState, pager, pauseIconVisibleState)
VideoHeader.invoke()
Box(modifier = Modifier.align(Alignment.BottomStart)) {
VideoBottom.invoke()
}
if (initialLayout.value) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Black)
)
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun VideoPlayer(
videoUrl: String,
pagerState: PagerState,
pager: Int,
pauseIconVisibleState: MutableState<Boolean>,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val lifecycleOwner = LocalLifecycleOwner.current
var showCommentModal by remember { mutableStateOf(false) }
var sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
)
val exoPlayer = remember {
ExoPlayer.Builder(context)
.build()
.apply {
val dataSourceFactory: DataSource.Factory = DefaultDataSourceFactory(
context,
Util.getUserAgent(context, context.packageName)
)
val source = ProgressiveMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(Uri.parse(videoUrl)))
this.prepare(source)
}
}
if (pager == pagerState.currentPage) {
exoPlayer.playWhenReady = true
exoPlayer.play()
} else {
exoPlayer.pause()
}
exoPlayer.videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT
exoPlayer.repeatMode = Player.REPEAT_MODE_ONE
// player box
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.TopCenter
) {
var playerView by remember { mutableStateOf<PlayerView?>(null) } // Store reference to PlayerView
AndroidView(
factory = { context ->
// 创建一个 FrameLayout 作为容器
FrameLayout(context).apply {
// 设置背景颜色为黑色,用于显示黑边
setBackgroundColor(Color.Black.toArgb())
// 创建 PlayerView 并添加到 FrameLayout 中
val view = PlayerView(context).apply {
hideController()
useController = false
player = exoPlayer
resizeMode =
AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT // 或 RESIZE_MODE_ZOOM
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT,
Gravity.CENTER
)
}
addView(view)
}
},
modifier = Modifier.noRippleClickable {
pauseIconVisibleState.value = true
exoPlayer.pause()
scope.launch {
delay(100)
if (exoPlayer.isPlaying) {
exoPlayer.pause()
} else {
pauseIconVisibleState.value = false
exoPlayer.play()
}
}
}
)
if (pauseIconVisibleState.value) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = null,
modifier = Modifier
.align(Alignment.Center)
.size(80.dp)
)
}
// Release ExoPlayer when the videoUrl changes or the composable leaves composition
DisposableEffect(videoUrl) {
onDispose {
exoPlayer.release()
playerView?.player = null
playerView = null // Release the reference to the PlayerView
}
}
DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
exoPlayer.pause() // 应用进入后台时暂停
}
Lifecycle.Event.ON_RESUME -> {
if (pager == pagerState.currentPage) {
exoPlayer.play() // 返回前台且为当前页面时恢复播放
}
}
else -> {}
}
}
lifecycleOwner.lifecycle.addObserver(observer)
onDispose {
lifecycleOwner.lifecycle.removeObserver(observer)
}
}
}
// action buttons
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomEnd
) {
Column(
modifier = Modifier.padding(bottom = 72.dp, end = 12.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
UserAvatar()
VideoBtn(icon = R.drawable.rider_pro_video_like, text = "975.9k")
VideoBtn(icon = R.drawable.rider_pro_video_comment, text = "1896") {
showCommentModal = true
}
VideoBtn(icon = R.drawable.rider_pro_video_favor, text = "234")
VideoBtn(icon = R.drawable.rider_pro_video_share, text = "677k")
}
}
// info
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomStart
) {
Column(modifier = Modifier.padding(start = 16.dp, bottom = 16.dp)) {
Row(
modifier = Modifier
.padding(bottom = 8.dp)
.background(color = Color.Gray),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
Image(
modifier = Modifier
.size(20.dp)
.padding(start = 4.dp, end = 6.dp),
painter = painterResource(id = R.drawable.rider_pro_video_location),
contentDescription = ""
)
Text(
modifier = Modifier.padding(end = 4.dp),
text = "USA",
fontSize = 12.sp,
color = Color.White,
style = TextStyle(fontWeight = FontWeight.Bold)
)
}
Text(
text = "@Kevinlinpr",
fontSize = 16.sp,
color = Color.White,
style = TextStyle(fontWeight = FontWeight.Bold)
)
Text(
modifier = Modifier
.fillMaxWidth()
.padding(top = 4.dp), // 确保Text占用可用宽度
text = "Pedro Acosta to join KTM in 2025 on a multi-year deal! \uD83D\uDFE0",
fontSize = 16.sp,
color = Color.White,
style = TextStyle(fontWeight = FontWeight.Bold),
overflow = TextOverflow.Ellipsis, // 超出范围时显示省略号
maxLines = 2 // 最多显示两行
)
}
}
if (showCommentModal) {
ModalBottomSheet(
onDismissRequest = { showCommentModal = false },
containerColor = Color.White,
sheetState = sheetState
) {
CommentModalContent() {
}
}
}
}
@Composable
fun UserAvatar() {
Image(
modifier = Modifier
.padding(bottom = 16.dp)
.size(40.dp)
.border(width = 3.dp, color = Color.White, shape = RoundedCornerShape(40.dp))
.clip(
RoundedCornerShape(40.dp)
), painter = painterResource(id = R.drawable.default_avatar), contentDescription = ""
)
}
@Composable
fun VideoBtn(@DrawableRes icon: Int, text: String, onClick: (() -> Unit)? = null) {
Column(
modifier = Modifier
.padding(bottom = 16.dp)
.clickable {
onClick?.invoke()
},
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
modifier = Modifier.size(36.dp),
painter = painterResource(id = icon),
contentDescription = ""
)
Text(
text = text,
fontSize = 11.sp,
color = Color.White,
style = TextStyle(fontWeight = FontWeight.Bold)
)
}
}

View File

@@ -0,0 +1,229 @@
package com.aiosman.ravenow.ui.index.tabs.street
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.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Icon
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.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavOptions
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.test.countries
import com.google.android.gms.maps.model.CameraPosition
import com.google.android.gms.maps.model.LatLng
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.MapProperties
import com.google.maps.android.compose.MapUiSettings
import com.google.maps.android.compose.MarkerComposable
import com.google.maps.android.compose.MarkerState
import com.google.maps.android.compose.rememberCameraPositionState
@Composable
fun StreetPage() {
val navController = LocalNavController.current
var currentLocation by remember { mutableStateOf<LatLng?>(null) }
val navigationBarHeight = with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
val cameraPositionState = rememberCameraPositionState {
position = CameraPosition.fromLatLngZoom(currentLocation ?: LatLng(0.0, 0.0), 10f)
}
var hasLocationPermission by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
}
LaunchedEffect(currentLocation) {
cameraPositionState.position =
CameraPosition.fromLatLngZoom(
currentLocation ?: LatLng(
countries[0].lat,
countries[0].lng
), 5f
)
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = 56.dp + navigationBarHeight)
) {
GoogleMap(
modifier = Modifier.fillMaxSize(),
cameraPositionState = cameraPositionState,
properties = MapProperties(
isMyLocationEnabled = hasLocationPermission,
),
uiSettings = MapUiSettings(
compassEnabled = true,
myLocationButtonEnabled = false,
zoomControlsEnabled = false
)
) {
// pins
countries.forEach { position ->
MarkerComposable(
state = MarkerState(position = LatLng(position.lat, position.lng)),
onClick = {
val screenLocation =
cameraPositionState.projection?.toScreenLocation(it.position)
val x = screenLocation?.x ?: 0
val y = screenLocation?.y ?: 0
navController.navigate("LocationDetail/${x}/${y}",NavOptions.Builder()
.setEnterAnim(0)
.setExitAnim(0)
.setPopEnterAnim(0)
.setPopExitAnim(0)
.build())
true
},
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_map_mark),
contentDescription = "",
)
}
}
}
Image(
painter = painterResource(id = R.drawable.rider_pro_my_location),
contentDescription = "",
modifier = Modifier
.align(Alignment.BottomStart)
.padding(start = 16.dp, bottom = 16.dp + navigationBarHeight)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
currentLocation?.let {
cameraPositionState.position =
CameraPosition.fromLatLngZoom(it, cameraPositionState.position.zoom)
}
}
)
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 16.dp + navigationBarHeight)
) {
Box(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(color = Color(0xffda3832))
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_new_post_add_pic),
contentDescription = "",
modifier = Modifier
.align(Alignment.Center)
.size(36.dp),
colorFilter = ColorFilter.tint(Color.White)
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.padding(top = 64.dp, start = 16.dp, end = 16.dp)
) {
Box(
modifier = Modifier
.background(Color.White)
.padding(16.dp),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(Color(0xfff7f7f7))
.padding(vertical = 8.dp, horizontal = 16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.rider_pro_search_location),
contentDescription = "",
tint = Color(0xffc6c6c6),
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
) {
if (searchText.isEmpty()) {
Text(
text = "Please enter a search location",
color = Color(0xffc6c6c6),
fontSize = 16.sp,
modifier = Modifier.padding(start = 8.dp)
)
}
BasicTextField(
value = searchText,
onValueChange = {
searchText = it
},
modifier = Modifier
.fillMaxWidth(),
textStyle = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal
)
)
}
}
}
}
}
}
}