改包名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,427 @@
package com.aiosman.ravenow.ui
import ChangePasswordScreen
import ImageViewer
import ModificationListScreen
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.aiosman.ravenow.LocalAnimatedContentScope
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.LocalSharedTransitionScope
import com.aiosman.ravenow.ui.account.AccountEditScreen2
import com.aiosman.ravenow.ui.account.ResetPasswordScreen
import com.aiosman.ravenow.ui.chat.ChatScreen
import com.aiosman.ravenow.ui.comment.CommentsScreen
import com.aiosman.ravenow.ui.comment.notice.CommentNoticeScreen
import com.aiosman.ravenow.ui.crop.ImageCropScreen
import com.aiosman.ravenow.ui.favourite.FavouriteListPage
import com.aiosman.ravenow.ui.favourite.FavouriteNoticeScreen
import com.aiosman.ravenow.ui.follower.FollowerListScreen
import com.aiosman.ravenow.ui.follower.FollowerNoticeScreen
import com.aiosman.ravenow.ui.follower.FollowingListScreen
import com.aiosman.ravenow.ui.gallery.OfficialGalleryScreen
import com.aiosman.ravenow.ui.gallery.OfficialPhotographerScreen
import com.aiosman.ravenow.ui.gallery.ProfileTimelineScreen
import com.aiosman.ravenow.ui.index.IndexScreen
import com.aiosman.ravenow.ui.index.tabs.message.NotificationsScreen
import com.aiosman.ravenow.ui.index.tabs.search.SearchScreen
import com.aiosman.ravenow.ui.like.LikeNoticeScreen
import com.aiosman.ravenow.ui.location.LocationDetailScreen
import com.aiosman.ravenow.ui.login.EmailSignupScreen
import com.aiosman.ravenow.ui.login.LoginPage
import com.aiosman.ravenow.ui.login.SignupScreen
import com.aiosman.ravenow.ui.login.UserAuthScreen
import com.aiosman.ravenow.ui.modification.EditModificationScreen
import com.aiosman.ravenow.ui.post.NewPostImageGridScreen
import com.aiosman.ravenow.ui.post.NewPostScreen
import com.aiosman.ravenow.ui.post.PostScreen
import com.aiosman.ravenow.ui.profile.AccountProfileV2
sealed class NavigationRoute(
val route: String,
) {
data object Index : NavigationRoute("Index")
data object ProfileTimeline : NavigationRoute("ProfileTimeline")
data object LocationDetail : NavigationRoute("LocationDetail/{x}/{y}")
data object OfficialPhoto : NavigationRoute("OfficialPhoto")
data object OfficialPhotographer : NavigationRoute("OfficialPhotographer")
data object Post : NavigationRoute("Post/{id}/{highlightCommentId}/{initImagePagerIndex}")
data object ModificationList : NavigationRoute("ModificationList")
data object MyMessage : NavigationRoute("MyMessage")
data object Comments : NavigationRoute("Comments")
data object Likes : NavigationRoute("Likes")
data object Followers : NavigationRoute("Followers")
data object NewPost : NavigationRoute("NewPost")
data object EditModification : NavigationRoute("EditModification")
data object Login : NavigationRoute("Login")
data object AccountProfile : NavigationRoute("AccountProfile/{id}")
data object SignUp : NavigationRoute("SignUp")
data object UserAuth : NavigationRoute("UserAuth")
data object EmailSignUp : NavigationRoute("EmailSignUp")
data object AccountEdit : NavigationRoute("AccountEditScreen")
data object ImageViewer : NavigationRoute("ImageViewer")
data object ChangePasswordScreen : NavigationRoute("ChangePasswordScreen")
data object FavouritesScreen : NavigationRoute("FavouritesScreen")
data object NewPostImageGrid : NavigationRoute("NewPostImageGrid")
data object Search : NavigationRoute("Search")
data object FollowerList : NavigationRoute("FollowerList/{id}")
data object FollowingList : NavigationRoute("FollowingList/{id}")
data object ResetPassword : NavigationRoute("ResetPassword")
data object FavouriteList : NavigationRoute("FavouriteList")
data object Chat : NavigationRoute("Chat/{id}")
data object CommentNoticeScreen : NavigationRoute("CommentNoticeScreen")
data object ImageCrop : NavigationRoute("ImageCrop")
}
@Composable
fun NavigationController(
navController: NavHostController,
startDestination: String = NavigationRoute.Login.route
) {
val navigationBarHeight = with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
NavHost(
navController = navController,
startDestination = startDestination,
) {
composable(route = NavigationRoute.Index.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
IndexScreen()
}
}
composable(route = NavigationRoute.ProfileTimeline.route) {
ProfileTimelineScreen()
}
composable(
route = NavigationRoute.LocationDetail.route,
arguments = listOf(
navArgument("x") { type = NavType.FloatType },
navArgument("y") { type = NavType.FloatType }
)
) {
Box(
modifier = Modifier.padding(bottom = navigationBarHeight)
) {
val x = it.arguments?.getFloat("x") ?: 0f
val y = it.arguments?.getFloat("y") ?: 0f
LocationDetailScreen(
x, y
)
}
}
composable(route = NavigationRoute.OfficialPhoto.route) {
OfficialGalleryScreen()
}
composable(route = NavigationRoute.OfficialPhotographer.route) {
OfficialPhotographerScreen()
}
composable(
route = NavigationRoute.Post.route,
arguments = listOf(
navArgument("id") { type = NavType.StringType },
navArgument("highlightCommentId") { type = NavType.IntType },
navArgument("initImagePagerIndex") { type = NavType.IntType }
),
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 200))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 200))
},
popEnterTransition = {
fadeIn(animationSpec = tween(durationMillis = 200))
},
popExitTransition = {
fadeOut(animationSpec = tween(durationMillis = 200))
}
) { backStackEntry ->
val id = backStackEntry.arguments?.getString("id")
val highlightCommentId =
backStackEntry.arguments?.getInt("highlightCommentId")?.let {
if (it == 0) null else it
}
val initIndex = backStackEntry.arguments?.getInt("initImagePagerIndex")
PostScreen(
id!!,
highlightCommentId,
initImagePagerIndex = initIndex
)
}
composable(route = NavigationRoute.ModificationList.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
ModificationListScreen()
}
composable(route = NavigationRoute.MyMessage.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
NotificationsScreen()
}
composable(route = NavigationRoute.Comments.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
CommentsScreen()
}
composable(route = NavigationRoute.Likes.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
LikeNoticeScreen()
}
composable(route = NavigationRoute.Followers.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
FollowerNoticeScreen()
}
composable(
route = NavigationRoute.NewPost.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
NewPostScreen()
}
composable(route = NavigationRoute.EditModification.route) {
Box(
modifier = Modifier.padding(top = 64.dp)
) {
EditModificationScreen()
}
}
composable(route = NavigationRoute.Login.route) {
LoginPage()
}
composable(
route = NavigationRoute.AccountProfile.route,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
AccountProfileV2(it.arguments?.getString("id")!!)
}
}
composable(
route = NavigationRoute.SignUp.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
SignupScreen()
}
composable(
route = NavigationRoute.UserAuth.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
UserAuthScreen()
}
composable(
route = NavigationRoute.EmailSignUp.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
EmailSignupScreen()
}
composable(
route = NavigationRoute.AccountEdit.route,
enterTransition = {
fadeIn(animationSpec = tween(durationMillis = 0))
},
exitTransition = {
fadeOut(animationSpec = tween(durationMillis = 0))
}
) {
AccountEditScreen2()
}
composable(route = NavigationRoute.ImageViewer.route) {
ImageViewer()
}
composable(route = NavigationRoute.ChangePasswordScreen.route) {
ChangePasswordScreen()
}
composable(route = NavigationRoute.FavouritesScreen.route) {
FavouriteNoticeScreen()
}
composable(route = NavigationRoute.NewPostImageGrid.route) {
NewPostImageGridScreen()
}
composable(route = NavigationRoute.Search.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
SearchScreen()
}
}
composable(
route = NavigationRoute.FollowerList.route,
arguments = listOf(navArgument("id") { type = NavType.IntType })
) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
FollowerListScreen(it.arguments?.getInt("id")!!)
}
}
composable(
route = NavigationRoute.FollowingList.route,
arguments = listOf(navArgument("id") { type = NavType.IntType })
) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
FollowingListScreen(it.arguments?.getInt("id")!!)
}
}
composable(route = NavigationRoute.ResetPassword.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
ResetPasswordScreen()
}
}
composable(route = NavigationRoute.FavouriteList.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
FavouriteListPage()
}
}
composable(
route = NavigationRoute.Chat.route,
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
ChatScreen(it.arguments?.getString("id")!!)
}
}
composable(route = NavigationRoute.CommentNoticeScreen.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
CommentNoticeScreen()
}
}
composable(route = NavigationRoute.ImageCrop.route) {
CompositionLocalProvider(
LocalAnimatedContentScope provides this,
) {
ImageCropScreen()
}
}
}
}
@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
fun Navigation(
startDestination: String = NavigationRoute.Login.route,
onLaunch: (navController: NavHostController) -> Unit
) {
val navController = rememberNavController()
LaunchedEffect(Unit) {
onLaunch(navController)
}
SharedTransitionLayout {
CompositionLocalProvider(
LocalNavController provides navController,
LocalSharedTransitionScope provides this@SharedTransitionLayout,
) {
Box {
NavigationController(
navController = navController,
startDestination = startDestination
)
}
}
}
}
fun NavHostController.navigateToPost(
id: Int,
highlightCommentId: Int? = 0,
initImagePagerIndex: Int? = 0
) {
navigate(
route = NavigationRoute.Post.route
.replace("{id}", id.toString())
.replace("{highlightCommentId}", highlightCommentId.toString())
.replace("{initImagePagerIndex}", initImagePagerIndex.toString())
)
}
fun NavHostController.navigateToChat(id: String) {
navigate(
route = NavigationRoute.Chat.route
.replace("{id}", id)
)
}

View File

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

View File

@@ -0,0 +1,204 @@
package com.aiosman.ravenow.ui.account
import android.widget.Toast
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.ConstVars
import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.DictService
import com.aiosman.ravenow.data.DictServiceImpl
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.TextInputField
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun ResetPasswordScreen() {
var username by remember { mutableStateOf("") }
val accountService: AccountService = AccountServiceImpl()
val dictService: DictService = DictServiceImpl()
val scope = rememberCoroutineScope()
val context = LocalContext.current
var isSendSuccess by remember { mutableStateOf<Boolean?>(null) }
var isLoading by remember { mutableStateOf(false) }
val navController = LocalNavController.current
var usernameError by remember { mutableStateOf<String?>(null) }
var countDown by remember { mutableStateOf<Int?>(null) }
var countDownMax by remember { mutableStateOf(60) }
val appColors = LocalAppTheme.current
fun validate(): Boolean {
if (username.isEmpty()) {
usernameError = context.getString(R.string.text_error_email_required)
return false
}
usernameError = null
return true
}
LaunchedEffect(Unit) {
try {
dictService.getDictByKey(ConstVars.DIC_KEY_RESET_EMAIL_INTERVAL).let {
countDownMax = it.value.toInt()
}
} catch (e: Exception) {
countDownMax = 60
}
}
fun startCountDown() {
scope.launch {
countDown = countDownMax
while (countDown!! > 0) {
delay(1000)
countDown = countDown!! - 1
}
countDown = null
}
}
fun resetPassword() {
if (!validate()) return
scope.launch {
isLoading = true
try {
accountService.resetPassword(username)
isSendSuccess = true
startCountDown()
} catch (e: ServiceException) {
if (e.code == ErrorCode.USER_NOT_EXIST.code){
usernameError = context.getString(R.string.error_40002_user_not_exist)
} else {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
} catch (e: Exception) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
isSendSuccess = false
} finally {
isLoading = false
}
}
}
Column(
modifier = Modifier.fillMaxSize()
) {
StatusBarSpacer()
Box(
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
top = 16.dp,
bottom = 0.dp
)
) {
NoticeScreenHeader(
stringResource(R.string.recover_account_upper),
moreIcon = false
)
}
Spacer(modifier = Modifier.height(72.dp))
Column(
modifier = Modifier.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
TextInputField(
text = username,
onValueChange = { username = it },
hint = stringResource(R.string.text_hint_email),
enabled = !isLoading && countDown == null,
error = usernameError,
)
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier.height(72.dp)
) {
isSendSuccess?.let {
if (it) {
Text(
text = stringResource(R.string.reset_mail_send_success),
style = TextStyle(
color = appColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
),
modifier = Modifier.fillMaxSize()
)
} else {
Text(
text = stringResource(R.string.reset_mail_send_failed),
style = TextStyle(
color = appColors.text,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
),
modifier = Modifier.fillMaxSize()
)
}
}
}
ActionButton(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
text = if (countDown != null) {
stringResource(R.string.resend, "(${countDown})")
} else {
stringResource(R.string.recover)
},
backgroundColor = appColors.main,
color = appColors.mainText,
isLoading = isLoading,
contentPadding = PaddingValues(0.dp),
enabled = countDown == null,
) {
resetPassword()
}
isSendSuccess?.let {
Spacer(modifier = Modifier.height(16.dp))
ActionButton(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
text = stringResource(R.string.back_upper),
contentPadding = PaddingValues(0.dp),
) {
navController.navigateUp()
}
}
}
}
}

View File

@@ -0,0 +1,160 @@
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.width
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.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.ravenow.data.api.showToast
import com.aiosman.ravenow.data.api.toErrorMessage
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.TextInputField
import kotlinx.coroutines.launch
/**
* 修改密码页面的 ViewModel
*/
class ChangePasswordViewModel {
val accountService: AccountService = AccountServiceImpl()
/**
* 修改密码
* @param currentPassword 当前密码
* @param newPassword 新密码
*/
suspend fun changePassword(currentPassword: String, newPassword: String) {
accountService.changeAccountPassword(currentPassword, newPassword)
}
}
/**
* 修改密码页面
*/
@Composable
fun ChangePasswordScreen() {
val context = LocalContext.current
val viewModel = remember { ChangePasswordViewModel() }
var currentPassword by remember { mutableStateOf("") }
var newPassword by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var errorMessage by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
var oldPasswordError by remember { mutableStateOf<String?>(null) }
var confirmPasswordError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(null) }
val AppColors = LocalAppTheme.current
fun validate(): Boolean {
oldPasswordError =
if (currentPassword.isEmpty()) "Please enter your current password" else null
passwordError = when {
newPassword.length < 8 -> "Password must be at least 8 characters long"
!newPassword.any { it.isDigit() } -> "Password must contain at least one digit"
!newPassword.any { it.isUpperCase() } -> "Password must contain at least one uppercase letter"
!newPassword.any { it.isLowerCase() } -> "Password must contain at least one lowercase letter"
else -> null
}
confirmPasswordError =
if (newPassword != confirmPassword) "Passwords do not match" else null
return passwordError == null && confirmPasswordError == null && oldPasswordError == null
}
Column(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background),
horizontalAlignment = Alignment.CenterHorizontally
) {
StatusBarSpacer()
Box(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
) {
NoticeScreenHeader(
title = "Change password",
moreIcon = false
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
) {
Spacer(modifier = Modifier.height(80.dp))
TextInputField(
text = currentPassword,
onValueChange = { currentPassword = it },
password = true,
label = "Current password",
hint = "Enter your current password",
error = oldPasswordError
)
Spacer(modifier = Modifier.height(4.dp))
TextInputField(
text = newPassword,
onValueChange = { newPassword = it },
password = true,
label = "New password",
hint = "Enter your new password",
error = passwordError
)
Spacer(modifier = Modifier.height(4.dp))
TextInputField(
text = confirmPassword,
onValueChange = { confirmPassword = it },
password = true,
label = "Confirm new password",
hint = "Enter your new password again",
error = confirmPasswordError
)
Spacer(modifier = Modifier.height(50.dp))
ActionButton(
modifier = Modifier
.width(345.dp),
text = "Let's Ride",
) {
if (validate()) {
scope.launch {
try {
viewModel.changePassword(currentPassword, newPassword)
navController.navigateUp()
} catch (e: ServiceException) {
when (e.errorType) {
ErrorCode.IncorrectOldPassword ->
oldPasswordError = e.errorType.toErrorMessage(context)
else ->
e.errorType.showToast(context)
}
} catch (e: Exception) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
}
}
}
}
}
}

View File

@@ -0,0 +1,240 @@
package com.aiosman.ravenow.ui.account
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
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.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.UploadImage
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.post.NewPostViewModel.uriToFile
import kotlinx.coroutines.launch
/**
* 编辑用户资料界面
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AccountEditScreen() {
val accountService: AccountService = AccountServiceImpl()
var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") }
var imageUrl by remember { mutableStateOf<Uri?>(null) }
var bannerImageUrl by remember { mutableStateOf<Uri?>(null) }
var profile by remember {
mutableStateOf<AccountProfileEntity?>(
null
)
}
val navController = LocalNavController.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
/**
* 加载用户资料
*/
suspend fun reloadProfile() {
accountService.getMyAccountProfile().let {
profile = it
name = it.nickName
bio = it.bio
}
}
fun updateUserProfile() {
scope.launch {
val newAvatar = imageUrl?.let {
val cursor = context.contentResolver.query(it, null, null, null, null)
var newAvatar: UploadImage? = null
cursor?.use { cur ->
if (cur.moveToFirst()) {
val displayName = cur.getString(cur.getColumnIndex("_display_name"))
val extension = displayName.substringAfterLast(".")
Log.d("NewPost", "File name: $displayName, extension: $extension")
// read as file
val file = uriToFile(context, it)
Log.d("NewPost", "File size: ${file.length()}")
newAvatar = UploadImage(file, displayName, it.toString(), extension)
}
}
newAvatar
}
var newBanner = bannerImageUrl?.let {
val cursor = context.contentResolver.query(it, null, null, null, null)
var newBanner: UploadImage? = null
cursor?.use { cur ->
if (cur.moveToFirst()) {
val displayName = cur.getString(cur.getColumnIndex("_display_name"))
val extension = displayName.substringAfterLast(".")
Log.d("NewPost", "File name: $displayName, extension: $extension")
// read as file
val file = uriToFile(context, it)
Log.d("NewPost", "File size: ${file.length()}")
newBanner = UploadImage(file, displayName, it.toString(), extension)
}
}
newBanner
}
val newName = if (name == profile?.nickName) null else name
accountService.updateProfile(
avatar = newAvatar,
banner = newBanner,
nickName = newName,
bio = bio
)
reloadProfile()
navController.popBackStack()
}
}
val pickImageLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri = result.data?.data
uri?.let {
imageUrl = it
}
}
}
val pickBannerImageLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri = result.data?.data
uri?.let {
bannerImageUrl = uri
}
}
}
LaunchedEffect(Unit) {
reloadProfile()
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Edit") },
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
updateUserProfile()
}
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Save"
)
}
}
) { padding ->
profile?.let {
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
CustomAsyncImage(
context,
if (imageUrl != null) {
imageUrl.toString()
} else {
it.avatar
},
contentDescription = null,
modifier = Modifier
.size(100.dp)
.noRippleClickable {
Intent(Intent.ACTION_PICK).apply {
type = "image/*"
pickImageLauncher.launch(this)
}
},
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.size(16.dp))
CustomAsyncImage(
context,
if (bannerImageUrl != null) {
bannerImageUrl.toString()
} else {
it.banner!!
},
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(200.dp)
.noRippleClickable {
Intent(Intent.ACTION_PICK).apply {
type = "image/*"
pickBannerImageLauncher.launch(this)
}
},
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.size(16.dp))
TextField(
value = name,
onValueChange = {
name = it
},
label = {
Text("Name")
},
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.size(16.dp))
TextField(
value = bio,
onValueChange = {
bio = it
},
label = {
Text("Bio")
},
modifier = Modifier.fillMaxWidth()
)
}
}
}
}

View File

@@ -0,0 +1,190 @@
package com.aiosman.ravenow.ui.account
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.form.FormTextInput
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
/**
* 编辑用户资料界面
*/
@Composable
fun AccountEditScreen2() {
val model = AccountEditViewModel
val navController = LocalNavController.current
val context = LocalContext.current
var usernameError by remember { mutableStateOf<String?>(null) }
var bioError by remember { mutableStateOf<String?>(null) }
fun onNicknameChange(value: String) {
model.name = value
usernameError = when {
value.isEmpty() -> "昵称不能为空"
value.length < 3 -> "昵称长度不能小于3"
value.length > 20 -> "昵称长度不能大于20"
else -> null
}
}
val appColors = LocalAppTheme.current
fun onBioChange(value: String) {
model.bio = value
bioError = when {
value.length > 100 -> "个人简介长度不能大于24"
else -> null
}
}
fun validate(): Boolean {
return usernameError == null && bioError == null
}
LaunchedEffect(Unit) {
if (model.profile == null) {
model.reloadProfile()
}
}
Column(
modifier = Modifier
.fillMaxSize()
.background(color = appColors.background),
horizontalAlignment = Alignment.CenterHorizontally
) {
StatusBarSpacer()
Box(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
) {
NoticeScreenHeader(
title = stringResource(R.string.edit_profile),
moreIcon = false
) {
Icon(
modifier = Modifier
.size(24.dp)
.noRippleClickable {
if (validate() && !model.isUpdating) {
model.viewModelScope.launch {
model.isUpdating = true
model.updateUserProfile(context)
model.viewModelScope.launch(Dispatchers.Main) {
navController.navigateUp()
model.isUpdating = false
}
}
}
},
imageVector = Icons.Default.Check,
contentDescription = "保存",
tint = if (validate() && !model.isUpdating) Color.Black else Color.Gray
)
}
}
Spacer(modifier = Modifier.height(44.dp))
model.profile?.let {
Box(
modifier = Modifier.size(88.dp),
contentAlignment = Alignment.Center
) {
CustomAsyncImage(
context,
model.croppedBitmap ?: it.avatar,
modifier = Modifier
.size(88.dp)
.clip(
RoundedCornerShape(88.dp)
),
contentDescription = "",
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.size(32.dp)
.clip(CircleShape)
.background(appColors.main)
.align(Alignment.BottomEnd)
.noRippleClickable {
navController.navigate(NavigationRoute.ImageCrop.route)
},
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Add,
contentDescription = "Add",
tint = Color.White,
)
}
}
Spacer(modifier = Modifier.height(58.dp))
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
) {
FormTextInput(
value = model.name,
label = stringResource(R.string.nickname),
hint = "Input nickname",
modifier = Modifier.fillMaxWidth(),
error = usernameError
) { value ->
onNicknameChange(value)
}
// Spacer(modifier = Modifier.height(16.dp))
FormTextInput(
value = model.bio,
label = stringResource(R.string.bio),
hint = "Input bio",
modifier = Modifier.fillMaxWidth(),
error = bioError
) { value ->
onBioChange(value)
}
}
}
}
}

View File

@@ -0,0 +1,644 @@
package com.aiosman.ravenow.ui.chat
import android.app.Activity
import android.content.Intent
import android.net.Uri
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitFirstDown
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
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.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
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.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.SoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.ChatItem
import com.aiosman.ravenow.exp.formatChatTime
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.modifiers.noRippleClickable
import com.tencent.imsdk.v2.V2TIMMessage
import kotlinx.coroutines.launch
@Composable
fun ChatScreen(userId: String) {
var isMenuExpanded by remember { mutableStateOf(false) }
val navController = LocalNavController.current
val context = LocalNavController.current.context
val AppColors = LocalAppTheme.current
var goToNewCount by remember { mutableStateOf(0) }
val viewModel = viewModel<ChatViewModel>(
key = "ChatViewModel_$userId",
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return ChatViewModel(userId) as T
}
}
)
var isLoadingMore by remember { mutableStateOf(false) } // Add a state for loading
LaunchedEffect(Unit) {
viewModel.init(context = context)
}
DisposableEffect(Unit) {
onDispose {
viewModel.UnRegistListener()
viewModel.clearUnRead()
}
}
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
val navigationBarHeight = with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
var inBottom by remember { mutableStateOf(true) }
// 监听滚动状态,触发加载更多
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
.collect { index ->
Log.d("ChatScreen", "lastVisibleItemIndex: ${index}")
if (index == listState.layoutInfo.totalItemsCount - 1) {
coroutineScope.launch {
viewModel.onLoadMore(context)
}
}
}
}
// 监听滚动状态,触发滚动到底部
LaunchedEffect(listState) {
snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index }
.collect { index ->
inBottom = index == 0
if (index == 0) {
goToNewCount = 0
}
}
}
// 监听是否需要滚动到最新消息
LaunchedEffect(viewModel.goToNew) {
if (viewModel.goToNew) {
if (inBottom) {
listState.scrollToItem(0)
} else {
goToNewCount++
}
viewModel.goToNew = false
}
}
Scaffold(
modifier = Modifier
.fillMaxSize(),
topBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.background(AppColors.background)
) {
StatusBarSpacer()
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(R.drawable.rider_pro_back_icon),
modifier = Modifier
.size(28.dp)
.noRippleClickable {
navController.navigateUp()
},
contentDescription = null,
colorFilter = ColorFilter.tint(
AppColors.text)
)
Spacer(modifier = Modifier.width(16.dp))
Text(
text = viewModel.userProfile?.nickName ?: "",
modifier = Modifier.weight(1f),
style = TextStyle(
color = AppColors.text,
fontSize = 18.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
)
)
Spacer(modifier = Modifier.weight(1f))
Box {
Image(
painter = painterResource(R.drawable.rider_pro_more_horizon),
modifier = Modifier
.size(28.dp)
.noRippleClickable {
isMenuExpanded = true
},
contentDescription = null,
colorFilter = ColorFilter.tint(
AppColors.text)
)
DropdownMenu(
expanded = isMenuExpanded,
onDismissRequest = {
isMenuExpanded = false
},
menuItems = listOf(
MenuItem(
title = if (viewModel.notificationStrategy == "mute") "Unmute" else "Mute",
icon = if (viewModel.notificationStrategy == "mute") R.drawable.rider_pro_notice_mute else R.drawable.rider_pro_notice_active,
) {
isMenuExpanded = false
viewModel.viewModelScope.launch {
if (viewModel.notificationStrategy == "mute") {
viewModel.updateNotificationStrategy("active")
} else {
viewModel.updateNotificationStrategy("mute")
}
}
}
),
)
}
}
}
},
bottomBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.imePadding()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(
AppColors.decentBackground)
)
Spacer(modifier = Modifier.height(8.dp))
ChatInput(
onSendImage = {
it?.let {
viewModel.sendImageMessage(it, context)
}
},
) {
viewModel.sendMessage(it, context)
}
}
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.background(AppColors.decentBackground)
.padding(paddingValues)
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize(),
reverseLayout = true,
verticalArrangement = Arrangement.Top
) {
val chatList = groupMessagesByTime(viewModel.getDisplayChatList(), viewModel)
items(chatList.size, key = { index -> chatList[index].msgId }) { index ->
val item = chatList[index]
if (item.showTimeDivider) {
val calendar = java.util.Calendar.getInstance()
calendar.timeInMillis = item.timestamp
Text(
text = calendar.time.formatChatTime(context), // Format the timestamp
style = TextStyle(
color = AppColors.secondaryText,
fontSize = 14.sp,
textAlign = TextAlign.Center
),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
)
}
ChatItem(item = item, viewModel.myProfile?.trtcUserId!!)
}
// item {
// Spacer(modifier = Modifier.height(72.dp))
// }
}
if (goToNewCount > 0) {
Box(
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = 16.dp, end = 16.dp)
.shadow(4.dp, shape = RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(AppColors.background)
.padding(8.dp)
.noRippleClickable {
coroutineScope.launch {
listState.scrollToItem(0)
}
},
) {
Text(
text = "${goToNewCount} New Message",
style = TextStyle(
color = AppColors.text,
fontSize = 16.sp,
),
)
}
}
}
}
}
@Composable
fun ChatSelfItem(item: ChatItem) {
val context = LocalContext.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()
) {
Column(
horizontalAlignment = androidx.compose.ui.Alignment.End,
) {
Box(
modifier = Modifier
.widthIn(
min = 20.dp,
max = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 250.dp else 150.dp)
)
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF000000))
.padding(
vertical = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 8.dp else 0.dp),
horizontal = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 16.dp else 0.dp)
)
.padding(bottom = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 3.dp else 0.dp))
) {
when (item.messageType) {
V2TIMMessage.V2TIM_ELEM_TYPE_TEXT -> {
Text(
text = item.message,
style = TextStyle(
color = Color.White,
fontSize = 16.sp,
),
textAlign = TextAlign.Start
)
}
V2TIMMessage.V2TIM_ELEM_TYPE_IMAGE -> {
CustomAsyncImage(
imageUrl = item.imageList[1].url,
modifier = Modifier.fillMaxSize(),
contentDescription = "image"
)
}
else -> {
Text(
text = "Unsupported message type",
style = TextStyle(
color = Color.White,
fontSize = 16.sp,
)
)
}
}
}
}
Spacer(modifier = Modifier.width(12.dp))
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(40.dp))
) {
CustomAsyncImage(
imageUrl = item.avatar,
modifier = Modifier.fillMaxSize(),
contentDescription = "avatar"
)
}
}
}
}
@Composable
fun ChatOtherItem(item: ChatItem) {
val AppColors = LocalAppTheme.current
Column(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Row(
horizontalArrangement = Arrangement.Start,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(40.dp))
) {
CustomAsyncImage(
imageUrl = item.avatar,
modifier = Modifier.fillMaxSize(),
contentDescription = "avatar"
)
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Box(
modifier = Modifier
.widthIn(
min = 20.dp,
max = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 250.dp else 150.dp)
)
.clip(RoundedCornerShape(8.dp))
.background(AppColors.background)
.padding(
vertical = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 8.dp else 0.dp),
horizontal = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 16.dp else 0.dp)
)
.padding(bottom = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 3.dp else 0.dp))
) {
when (item.messageType) {
V2TIMMessage.V2TIM_ELEM_TYPE_TEXT -> {
Text(
text = item.message,
style = TextStyle(
color = AppColors.text,
fontSize = 16.sp,
),
textAlign = TextAlign.Start
)
}
V2TIMMessage.V2TIM_ELEM_TYPE_IMAGE -> {
CustomAsyncImage(
imageUrl = item.imageList[1].url,
modifier = Modifier.fillMaxSize(),
contentDescription = "image"
)
}
else -> {
Text(
text = "Unsupported message type",
style = TextStyle(
color = AppColors.text,
fontSize = 16.sp,
)
)
}
}
}
}
}
}
}
@Composable
fun ChatItem(item: ChatItem, currentUserId: String) {
val isCurrentUser = item.userId == currentUserId
if (isCurrentUser) {
ChatSelfItem(item)
} else {
ChatOtherItem(item)
}
}
@Composable
fun ChatInput(
onSendImage: (Uri?) -> Unit = {},
onSend: (String) -> Unit = {},
) {
val navigationBarHeight = with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
var keyboardController by remember { mutableStateOf<SoftwareKeyboardController?>(null) }
var isKeyboardOpen by remember { mutableStateOf(false) }
var text by remember { mutableStateOf("") }
val appColors = LocalAppTheme.current
val inputBarHeight by animateDpAsState(
targetValue = if (isKeyboardOpen) 8.dp else (navigationBarHeight + 8.dp),
animationSpec = tween(
durationMillis = 300,
easing = androidx.compose.animation.core.LinearEasing
), label = ""
)
// 在 isKeyboardOpen 变化时立即更新 inputBarHeight 的动画目标值
LaunchedEffect(isKeyboardOpen) {
inputBarHeight // 触发 inputBarHeight 的重组
}
val focusManager = LocalFocusManager.current
val windowInsets = WindowInsets.ime
val density = LocalDensity.current
val softwareKeyboardController = LocalSoftwareKeyboardController.current
val currentDensity by rememberUpdatedState(density)
LaunchedEffect(windowInsets.getBottom(currentDensity)) {
if (windowInsets.getBottom(currentDensity) <= 0) {
focusManager.clearFocus()
}
}
val imagePickUpLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
val uri = it.data?.data
onSendImage(uri)
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = inputBarHeight)
) {
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(16.dp))
.background(appColors.background)
.padding(horizontal = 16.dp),
contentAlignment = Alignment.CenterStart,
) {
BasicTextField(
value = text,
onValueChange = {
text = it
},
textStyle = TextStyle(
color = appColors.text,
fontSize = 16.sp
),
cursorBrush = SolidColor(appColors.text),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.onFocusChanged { focusState ->
isKeyboardOpen = focusState.isFocused
}
.pointerInput(Unit) {
awaitPointerEventScope {
keyboardController = softwareKeyboardController
awaitFirstDown().also {
keyboardController?.show()
}
}
},
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
keyboardController?.hide()
}
)
)
}
Spacer(modifier = Modifier.width(16.dp))
Icon(
painter = painterResource(id = R.drawable.rider_pro_camera),
contentDescription = "Emoji",
modifier = Modifier
.size(30.dp)
.noRippleClickable {
imagePickUpLauncher.launch(
Intent.createChooser(
Intent(Intent.ACTION_GET_CONTENT).apply {
type = "image/*"
},
"Select Image"
)
)
},
tint = appColors.chatActionColor
)
Spacer(modifier = Modifier.width(8.dp))
Crossfade(
targetState = text.isNotEmpty(), animationSpec = tween(500),
label = ""
) { isNotEmpty ->
Icon(
painter = painterResource(id = R.drawable.rider_pro_video_share),
contentDescription = "Emoji",
modifier = Modifier
.size(32.dp)
.noRippleClickable {
if (text.isNotEmpty()) {
onSend(text)
text = ""
}
},
tint = if (isNotEmpty) appColors.main else appColors.chatActionColor
)
}
}
}
fun groupMessagesByTime(chatList: List<ChatItem>, viewModel: ChatViewModel): List<ChatItem> {
for (i in chatList.indices) { // Iterate in normal order
if (i == 0) {
viewModel.showTimestampMap[chatList[i].msgId] = false
chatList[i].showTimeDivider = false
continue
}
val currentMessage = chatList[i]
val timeDiff = currentMessage.timestamp - chatList[i - 1].timestamp
// 时间间隔大于 3 分钟,显示时间戳
if (-timeDiff > 30 * 60 * 1000) {
viewModel.showTimestampMap[currentMessage.msgId] = true
currentMessage.showTimeDivider = true
}
}
return chatList
}

View File

@@ -0,0 +1,263 @@
package com.aiosman.ravenow.ui.chat
import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.util.Log
import android.webkit.MimeTypeMap
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aiosman.ravenow.ChatState
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.ChatItem
import com.aiosman.ravenow.entity.ChatNotification
import com.tencent.imsdk.v2.V2TIMAdvancedMsgListener
import com.tencent.imsdk.v2.V2TIMCallback
import com.tencent.imsdk.v2.V2TIMManager
import com.tencent.imsdk.v2.V2TIMMessage
import com.tencent.imsdk.v2.V2TIMSendCallback
import com.tencent.imsdk.v2.V2TIMValueCallback
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
class ChatViewModel(
val userId: String,
) : ViewModel() {
var chatData by mutableStateOf<List<ChatItem>>(emptyList())
var userProfile by mutableStateOf<AccountProfileEntity?>(null)
var myProfile by mutableStateOf<AccountProfileEntity?>(null)
val userService: UserService = UserServiceImpl()
val accountService: AccountService = AccountServiceImpl()
var textMessageListener: V2TIMAdvancedMsgListener? = null
var hasMore by mutableStateOf(true)
var isLoading by mutableStateOf(false)
var lastMessage: V2TIMMessage? = null
val showTimestampMap = mutableMapOf<String, Boolean>() // Add this map
var chatNotification by mutableStateOf<ChatNotification?>(null)
var goToNew by mutableStateOf(false)
fun init(context: Context) {
// 获取用户信息
viewModelScope.launch {
val resp = userService.getUserProfile(userId)
userProfile = resp
myProfile = accountService.getMyAccountProfile()
RegistListener(context)
fetchHistoryMessage(context)
// 获取通知信息
val notiStrategy = ChatState.getStrategyByTargetTrtcId(resp.trtcUserId)
chatNotification = notiStrategy
}
}
fun RegistListener(context: Context) {
textMessageListener = object : V2TIMAdvancedMsgListener() {
override fun onRecvNewMessage(msg: V2TIMMessage?) {
super.onRecvNewMessage(msg)
msg?.let {
val chatItem = ChatItem.convertToChatItem(msg, context)
chatItem?.let {
chatData = listOf(it) + chatData
goToNew = true
}
}
}
}
V2TIMManager.getMessageManager().addAdvancedMsgListener(textMessageListener);
}
fun UnRegistListener() {
V2TIMManager.getMessageManager().removeAdvancedMsgListener(textMessageListener);
}
fun clearUnRead() {
val conversationID = "c2c_${userProfile?.trtcUserId}"
V2TIMManager.getConversationManager()
.cleanConversationUnreadMessageCount(conversationID, 0, 0, object : V2TIMCallback {
override fun onSuccess() {
Log.i("imsdk", "success")
}
override fun onError(code: Int, desc: String) {
Log.i("imsdk", "failure, code:$code, desc:$desc")
}
})
}
fun onLoadMore(context: Context) {
if (!hasMore || isLoading) {
return
}
isLoading = true
viewModelScope.launch {
V2TIMManager.getMessageManager().getC2CHistoryMessageList(
userProfile?.trtcUserId!!,
20,
lastMessage,
object : V2TIMValueCallback<List<V2TIMMessage>> {
override fun onSuccess(p0: List<V2TIMMessage>?) {
chatData = chatData + (p0 ?: emptyList()).map {
ChatItem.convertToChatItem(it, context)
}.filterNotNull()
if ((p0?.size ?: 0) < 20) {
hasMore = false
}
lastMessage = p0?.lastOrNull()
isLoading = false
Log.d("ChatViewModel", "fetch history message success")
}
override fun onError(p0: Int, p1: String?) {
Log.e("ChatViewModel", "fetch history message error: $p1")
isLoading = false
}
}
)
}
}
fun sendMessage(message: String, context: Context) {
V2TIMManager.getInstance().sendC2CTextMessage(
message,
userProfile?.trtcUserId!!,
object : V2TIMSendCallback<V2TIMMessage> {
override fun onProgress(p0: Int) {
}
override fun onError(p0: Int, p1: String?) {
Log.e("ChatViewModel", "send message error: $p1")
}
override fun onSuccess(p0: V2TIMMessage?) {
Log.d("ChatViewModel", "send message success")
val chatItem = ChatItem.convertToChatItem(p0!!, context)
chatItem?.let {
chatData = listOf(it) + chatData
goToNew = true
}
}
}
)
}
fun sendImageMessage(imageUri: Uri, context: Context) {
val tempFile = createTempFile(context, imageUri)
val imagePath = tempFile?.path
if (imagePath != null) {
val v2TIMMessage = V2TIMManager.getMessageManager().createImageMessage(imagePath)
V2TIMManager.getMessageManager().sendMessage(
v2TIMMessage,
userProfile?.trtcUserId!!,
null,
V2TIMMessage.V2TIM_PRIORITY_NORMAL,
false,
null,
object : V2TIMSendCallback<V2TIMMessage> {
override fun onProgress(p0: Int) {
Log.d("ChatViewModel", "send image message progress: $p0")
}
override fun onError(p0: Int, p1: String?) {
Log.e("ChatViewModel", "send image message error: $p1")
}
override fun onSuccess(p0: V2TIMMessage?) {
Log.d("ChatViewModel", "send image message success")
val chatItem = ChatItem.convertToChatItem(p0!!, context)
chatItem?.let {
chatData = listOf(it) + chatData
goToNew = true
}
}
}
)
}
}
fun createTempFile(context: Context, uri: Uri): File? {
return try {
val projection = arrayOf(MediaStore.Images.Media.DATA)
val cursor = context.contentResolver.query(uri, projection, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
val filePath = it.getString(columnIndex)
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
val mimeType = context.contentResolver.getType(uri)
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
val tempFile =
File.createTempFile("temp_image", ".$extension", context.cacheDir)
val outputStream = FileOutputStream(tempFile)
inputStream?.use { input ->
outputStream.use { output ->
input.copyTo(output)
}
}
tempFile
} else {
null
}
}
} catch (e: Exception) {
e.printStackTrace()
null
}
}
fun fetchHistoryMessage(context: Context) {
V2TIMManager.getMessageManager().getC2CHistoryMessageList(
userProfile?.trtcUserId!!,
20,
null,
object : V2TIMValueCallback<List<V2TIMMessage>> {
override fun onSuccess(p0: List<V2TIMMessage>?) {
chatData = (p0 ?: emptyList()).mapNotNull {
ChatItem.convertToChatItem(it, context)
}
if ((p0?.size ?: 0) < 20) {
hasMore = false
}
lastMessage = p0?.lastOrNull()
Log.d("ChatViewModel", "fetch history message success")
}
override fun onError(p0: Int, p1: String?) {
Log.e("ChatViewModel", "fetch history message error: $p1")
}
}
)
}
fun getDisplayChatList(): List<ChatItem> {
val list = chatData
// Update showTimestamp for each message
for (item in list) {
item.showTimestamp = showTimestampMap.getOrDefault(item.msgId, false)
}
return list
}
suspend fun updateNotificationStrategy(strategy: String) {
userProfile?.let {
val result = ChatState.updateChatNotification(it.id, strategy)
chatNotification = result
}
}
val notificationStrategy get() = chatNotification?.strategy ?: "default"
}

View File

@@ -0,0 +1,240 @@
package com.aiosman.ravenow.ui.comment
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
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.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.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
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.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.ui.composables.EditCommentBottomModal
import com.aiosman.ravenow.ui.post.CommentContent
import com.aiosman.ravenow.ui.post.CommentMenuModal
import com.aiosman.ravenow.ui.post.CommentsViewModel
import com.aiosman.ravenow.ui.post.OrderSelectionComponent
import kotlinx.coroutines.launch
/**
* 评论弹窗的 ViewModel
*/
class CommentModalViewModel(
val postId: Int?
) : ViewModel() {
var commentText by mutableStateOf("")
var commentsViewModel: CommentsViewModel = CommentsViewModel(postId.toString())
init {
commentsViewModel.preTransit()
}
}
/**
* 评论弹窗
* @param postId 帖子ID
* @param onCommentAdded 评论添加回调
* @param onDismiss 关闭回调
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CommentModalContent(
postId: Int? = null,
commentCount: Int = 0,
onCommentAdded: () -> Unit = {},
onDismiss: () -> Unit = {}
) {
val model = viewModel<CommentModalViewModel>(
key = "CommentModalViewModel_$postId",
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return CommentModalViewModel(postId) as T
}
}
)
val commentViewModel = model.commentsViewModel
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
LaunchedEffect(Unit) {
}
var showCommentMenu by remember { mutableStateOf(false) }
var contextComment by remember { mutableStateOf<CommentEntity?>(null) }
val insets = WindowInsets
val imePadding = insets.ime.getBottom(density = LocalDensity.current)
var bottomPadding by remember { mutableStateOf(0.dp) }
var softwareKeyboardController = LocalSoftwareKeyboardController.current
var replyComment by remember { mutableStateOf<CommentEntity?>(null) }
LaunchedEffect(imePadding) {
bottomPadding = imePadding.dp
}
DisposableEffect(Unit) {
onDispose {
onDismiss()
}
}
if (showCommentMenu) {
ModalBottomSheet(
onDismissRequest = {
showCommentMenu = false
},
containerColor = Color.White,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
dragHandle = {},
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
windowInsets = WindowInsets(0)
) {
CommentMenuModal(
onDeleteClick = {
showCommentMenu = false
contextComment?.let {
commentViewModel.deleteComment(it.id)
}
}
)
}
}
suspend fun sendComment() {
if (model.commentText.isNotEmpty()) {
softwareKeyboardController?.hide()
commentViewModel.createComment(
model.commentText,
)
}
onCommentAdded()
}
Column(
modifier = Modifier
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, bottom = 16.dp, end = 16.dp)
) {
Text(
stringResource(R.string.comment),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Center)
)
}
HorizontalDivider(
color = Color(0xFFF7F7F7)
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(id = R.string.comment_count, commentCount),
fontSize = 14.sp,
color = Color(0xff666666)
)
OrderSelectionComponent {
commentViewModel.order = it
commentViewModel.reloadComment()
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.weight(1f)
) {
LazyColumn(
modifier = Modifier
.fillMaxWidth()
) {
item {
CommentContent(
viewModel = commentViewModel,
onLongClick = { commentEntity: CommentEntity ->
},
onReply = { parentComment, _, _, _ ->
},
)
Spacer(modifier = Modifier.height(72.dp))
}
}
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xfff7f7f7))
) {
EditCommentBottomModal(replyComment) {
commentViewModel.viewModelScope.launch {
if (replyComment != null) {
if (replyComment?.parentCommentId != null) {
// 第三级评论
commentViewModel.createComment(
it,
parentCommentId = replyComment?.parentCommentId,
replyUserId = replyComment?.author?.toInt()
)
} else {
// 子级评论
commentViewModel.createComment(it, replyComment?.id)
}
} else {
// 顶级评论
commentViewModel.createComment(it)
}
}
}
Spacer(modifier = Modifier.height(navBarHeight))
}
}
}

View File

@@ -0,0 +1,166 @@
package com.aiosman.ravenow.ui.comment
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.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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
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.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder
@Preview
@Composable
fun CommentsScreen() {
StatusBarMaskLayout(
darkIcons = true,
maskBoxBackgroundColor = Color(0xFFFFFFFF)
) {
Column(
modifier = Modifier
.weight(1f)
.background(color = Color(0xFFFFFFFF))
.padding(horizontal = 16.dp)
) {
NoticeScreenHeader("COMMENTS")
Spacer(modifier = Modifier.height(28.dp))
LazyColumn(
modifier = Modifier.weight(1f)
) {
item {
repeat(20) {
CommentsItem()
}
BottomNavigationPlaceholder()
}
}
}
}
}
@Composable
fun NoticeScreenHeader(
title:String,
moreIcon: Boolean = true,
rightIcon: @Composable (() -> Unit)? = null
) {
val nav = LocalNavController.current
val AppColors = LocalAppTheme.current
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon,),
contentDescription = title,
modifier = Modifier.size(16.dp).clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
nav.navigateUp()
},
colorFilter = ColorFilter.tint(AppColors.text)
)
Spacer(modifier = Modifier.size(12.dp))
Text(title, fontWeight = FontWeight.W800, fontSize = 17.sp, color = AppColors.text)
if (moreIcon) {
Spacer(modifier = Modifier.weight(1f))
Image(
painter = painterResource(id = R.drawable.rider_pro_more_horizon),
contentDescription = "More",
modifier = Modifier.size(24.dp)
)
}
if (rightIcon != null) {
Spacer(modifier = Modifier.weight(1f))
rightIcon()
}
}
}
@Composable
fun CommentsItem() {
Box(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.drawable.default_avatar),
contentDescription = "Avatar",
modifier = Modifier
.size(40.dp)
)
Spacer(modifier = Modifier.size(12.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text("Username", fontWeight = FontWeight.Bold, fontSize = 16.sp)
Spacer(modifier = Modifier.size(4.dp))
Text("Content", color = Color(0x99000000), fontSize = 12.sp)
Spacer(modifier = Modifier.size(4.dp))
Text("Date", color = Color(0x99000000), fontSize = 12.sp)
Spacer(modifier = Modifier.height(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_like),
contentDescription = "Like",
modifier = Modifier.size(16.dp),
)
Text(
"270",
color = Color(0x99000000),
fontSize = 12.sp,
modifier = Modifier.padding(start = 4.dp)
)
Spacer(modifier = Modifier.width(45.dp))
Image(
painter = painterResource(id = R.drawable.rider_pro_comments),
contentDescription = "Comments",
modifier = Modifier.size(16.dp)
)
Text(
"270",
color = Color(0x99000000),
fontSize = 12.sp,
modifier = Modifier.padding(start = 4.dp)
)
}
}
Box {
Image(
painter = painterResource(id = R.drawable.default_moment_img),
contentDescription = "More",
modifier = Modifier.size(64.dp)
)
}
}
}
}

View File

@@ -0,0 +1,250 @@
package com.aiosman.ravenow.ui.comment.notice
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.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.material3.LinearProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.paging.LoadState
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.exp.timeAgo
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
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
import kotlinx.coroutines.launch
@Composable
fun CommentNoticeScreen() {
val viewModel = viewModel<CommentNoticeListViewModel>(
key = "CommentNotice",
factory = object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return CommentNoticeListViewModel() as T
}
}
)
val context = LocalContext.current
LaunchedEffect(Unit) {
viewModel.initData(context)
}
var dataFlow = viewModel.commentItemsFlow
var comments = dataFlow.collectAsLazyPagingItems()
val navController = LocalNavController.current
val AppColors = LocalAppTheme.current
Column(
modifier = Modifier.fillMaxSize().background(color = AppColors.background)
) {
StatusBarSpacer()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
NoticeScreenHeader(stringResource(R.string.comment), moreIcon = false)
}
LazyColumn(
modifier = Modifier
.fillMaxSize().padding(horizontal = 16.dp)
) {
items(comments.itemCount) { index ->
comments[index]?.let { comment ->
CommentNoticeItem(comment) {
viewModel.updateReadStatus(comment.id)
viewModel.viewModelScope.launch {
var highlightCommentId = comment.id
comment.parentCommentId?.let {
highlightCommentId = it
}
navController.navigateToPost(
id = comment.post!!.id,
highlightCommentId = highlightCommentId,
initImagePagerIndex = 0
)
}
}
}
}
// handle load error
when {
comments.loadState.append is LoadState.Loading -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.padding(16.dp),
contentAlignment = Alignment.Center
) {
LinearProgressIndicator(
modifier = Modifier.width(160.dp),
color = AppColors.main
)
}
}
}
comments.loadState.append is LoadState.Error -> {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.noRippleClickable {
comments.retry()
},
contentAlignment = Alignment.Center
) {
Text(
text = "Load comment error, click to retry",
color = AppColors.text
)
}
}
}
}
item {
Spacer(modifier = Modifier.height(72.dp))
}
}
}
}
@Composable
fun CommentNoticeItem(
commentItem: CommentEntity,
onPostClick: () -> Unit = {},
) {
val navController = LocalNavController.current
val context = LocalContext.current
val AppColors = LocalAppTheme.current
Row(
modifier = Modifier.padding(vertical = 20.dp, horizontal = 16.dp)
) {
Box {
CustomAsyncImage(
context = context,
imageUrl = commentItem.avatar,
contentDescription = commentItem.name,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
commentItem.author.toString()
)
)
}
)
}
Row(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp)
.noRippleClickable {
onPostClick()
}
) {
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = commentItem.name,
fontSize = 18.sp,
modifier = Modifier,
color = AppColors.text
)
Spacer(modifier = Modifier.height(4.dp))
Row {
var text = commentItem.comment
if (commentItem.parentCommentId != null) {
text = "Reply you: $text"
}
Text(
text = text,
fontSize = 14.sp,
maxLines = 1,
color = AppColors.secondaryText,
modifier = Modifier.weight(1f),
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = commentItem.date.timeAgo(context),
fontSize = 14.sp,
color = AppColors.secondaryText,
)
}
}
Spacer(modifier = Modifier.width(24.dp))
commentItem.post?.let {
Box {
Box(
modifier = Modifier.padding(4.dp)
) {
CustomAsyncImage(
context = context,
imageUrl = it.images[0].thumbnail,
contentDescription = "Post Image",
modifier = Modifier
.size(48.dp).clip(RoundedCornerShape(8.dp))
)
// unread indicator
}
if (commentItem.unread) {
Box(
modifier = Modifier
.background(AppColors.main, CircleShape)
.size(12.dp)
.align(Alignment.TopEnd)
)
}
}
}
}
}
}

View File

@@ -0,0 +1,82 @@
package com.aiosman.ravenow.ui.comment.notice
import android.content.Context
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.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.CommentRemoteDataSource
import com.aiosman.ravenow.data.CommentService
import com.aiosman.ravenow.data.CommentServiceImpl
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.entity.CommentPagingSource
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class CommentNoticeListViewModel : ViewModel() {
val accountService: AccountService = AccountServiceImpl()
val userService: UserService = UserServiceImpl()
private val commentService: CommentService = CommentServiceImpl()
private val _commentItemsFlow = MutableStateFlow<PagingData<CommentEntity>>(PagingData.empty())
val commentItemsFlow = _commentItemsFlow.asStateFlow()
var isLoading by mutableStateOf(false)
var isFirstLoad = true
fun initData(context: Context, force: Boolean = false) {
if (!isFirstLoad && !force) {
return
}
if (force) {
isLoading = true
}
isFirstLoad = false
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
CommentPagingSource(
CommentRemoteDataSource(commentService),
selfNotice = true,
order = "latest"
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_commentItemsFlow.value = it
}
}
isLoading = false
}
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 updateReadStatus(id: Int) {
viewModelScope.launch {
commentService.updateReadStatus(id)
updateIsRead(id)
}
}
}

View File

@@ -0,0 +1,141 @@
package com.aiosman.ravenow.ui.composables
//import androidx.compose.foundation.layout.ColumnScopeInstance.weight
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
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.material3.CircularProgressIndicator
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun ActionButton(
modifier: Modifier = Modifier,
text: String,
color: Color? = null,
backgroundColor: Color? = null,
leading: @Composable (() -> Unit)? = null,
expandText: Boolean = false,
contentPadding: PaddingValues = PaddingValues(vertical = 16.dp),
isLoading: Boolean = false,
loadingTextColor: Color? = null,
loadingText: String = "Loading",
loadingBackgroundColor: Color? = null,
disabledBackgroundColor: Color? = null,
enabled: Boolean = true,
fullWidth: Boolean = false,
roundCorner: Float = 24f,
fontSize: TextUnit = 17.sp,
fontWeight: FontWeight = FontWeight.W900,
click: () -> Unit = {}
) {
val AppColors = LocalAppTheme.current
val animatedBackgroundColor by animateColorAsState(
targetValue = run {
if (enabled) {
if (isLoading) {
loadingBackgroundColor ?: AppColors.loadingMain
} else {
backgroundColor ?: AppColors.basicMain
}
} else {
disabledBackgroundColor ?: AppColors.disabledBackground
}
},
animationSpec = tween(300), label = ""
)
Box(
modifier = modifier
.clip(RoundedCornerShape(roundCorner.dp))
.background(animatedBackgroundColor)
.noRippleClickable {
if (enabled && !isLoading) {
click()
}
}
.padding(contentPadding),
contentAlignment = Alignment.CenterStart
) {
if (!isLoading) {
Box(
modifier = Modifier
.align(Alignment.Center)
.let {
if (fullWidth) {
it.fillMaxWidth()
} else {
it
}
},
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
Box(modifier = Modifier.align(Alignment.CenterStart)) {
leading?.invoke()
}
}
Text(
text,
fontSize = fontSize,
color = color ?: AppColors.text,
fontWeight = fontWeight,
textAlign = if (expandText) TextAlign.Center else TextAlign.Start
)
}
} else {
Box(
modifier = Modifier
.let {
if (fullWidth) {
it.fillMaxWidth()
} else {
it
}
}
.padding(horizontal = 16.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
color = AppColors.text
)
Text(
loadingText,
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = loadingTextColor ?: AppColors.loadingText,
)
}
}
}
}
}

View File

@@ -0,0 +1,41 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
@Composable
fun AnimatedCounter(count: Int, modifier: Modifier = Modifier, fontSize: Int = 24) {
val AppColors = LocalAppTheme.current
AnimatedContent(
targetState = count,
transitionSpec = {
// Compare the incoming number with the previous number.
if (targetState > initialState) {
// If the target number is larger, it slides up and fades in
// while the initial (smaller) number slides up and fades out.
(slideInVertically { height -> height } + fadeIn()).togetherWith(slideOutVertically { height -> -height } + fadeOut())
} else {
// If the target number is smaller, it slides down and fades in
// while the initial number slides down and fades out.
(slideInVertically { height -> -height } + fadeIn()).togetherWith(slideOutVertically { height -> height } + fadeOut())
}.using(
// Disable clipping since the faded slide-in/out should
// be displayed out of bounds.
SizeTransform(clip = false)
)
}
) { targetCount ->
Text(text = "$targetCount", modifier = modifier, fontSize = fontSize.sp, color = AppColors.text)
}
}

View File

@@ -0,0 +1,70 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.launch
@Composable
fun AnimatedFavouriteIcon(
modifier: Modifier = Modifier,
isFavourite: Boolean = false,
onClick: (() -> Unit)? = null
) {
val AppColors = LocalAppTheme.current
val animatableRotation = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
suspend fun shake() {
repeat(2) {
animatableRotation.animateTo(
targetValue = 10f,
animationSpec = tween(100)
) {
}
animatableRotation.animateTo(
targetValue = -10f,
animationSpec = tween(100)
) {
}
}
animatableRotation.animateTo(
targetValue = 0f,
animationSpec = tween(100)
)
}
Box(contentAlignment = Alignment.Center, modifier = Modifier.noRippleClickable {
onClick?.invoke()
// Trigger shake animation
scope.launch {
shake()
}
}) {
Image(
painter = if (isFavourite) {
painterResource(id = R.drawable.rider_pro_favourited)
} else {
painterResource(id = R.drawable.rider_pro_favourite)
},
contentDescription = "Favourite",
modifier = modifier.graphicsLayer {
rotationZ = animatableRotation.value
},
colorFilter = ColorFilter.tint(AppColors.text)
)
}
}

View File

@@ -0,0 +1,69 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.launch
@Composable
fun AnimatedLikeIcon(
modifier: Modifier = Modifier,
liked: Boolean = false,
onClick: (() -> Unit)? = null
) {
val AppColors = LocalAppTheme.current
val animatableRotation = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
suspend fun shake() {
repeat(2) {
animatableRotation.animateTo(
targetValue = 10f,
animationSpec = tween(100)
) {
}
animatableRotation.animateTo(
targetValue = -10f,
animationSpec = tween(100)
) {
}
}
animatableRotation.animateTo(
targetValue = 0f,
animationSpec = tween(100)
)
}
Box(contentAlignment = Alignment.Center, modifier = Modifier.noRippleClickable {
onClick?.invoke()
// Trigger shake animation
scope.launch {
shake()
}
}) {
Image(
painter = if (!liked) painterResource(id = R.drawable.rider_pro_moment_like) else painterResource(
id = R.drawable.rider_pro_moment_liked
),
contentDescription = "Like",
modifier = modifier.graphicsLayer {
rotationZ = animatableRotation.value
},
colorFilter = if (!liked) ColorFilter.tint(AppColors.text) else null
)
}
}

View File

@@ -0,0 +1,76 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toDrawable
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.aiosman.ravenow.utils.BlurHashDecoder
import com.aiosman.ravenow.utils.Utils.getImageLoader
const val DEFAULT_HASHED_BITMAP_WIDTH = 4
const val DEFAULT_HASHED_BITMAP_HEIGHT = 3
/**
* This function is used to load an image asynchronously and blur it using BlurHash.
* @param imageUrl The URL of the image to be loaded.
* @param modifier The modifier to be applied to the image.
* @param imageModifier The modifier to be applied to the image.
* @param contentDescription The content description to be applied to the image.
* @param contentScale The content scale to be applied to the image.
* @param isCrossFadeRequired Whether cross-fade is required or not.
* @param onImageLoadSuccess The callback to be called when the image is loaded successfully.
* @param onImageLoadFailure The callback to be called when the image is failed to load.
* @see AsyncImage
*/
@Suppress("LongParameterList")
@ExperimentalCoilApi
@Composable
fun AsyncBlurImage(
imageUrl: String,
blurHash: String,
modifier: Modifier = Modifier,
imageModifier: Modifier? = null,
contentDescription: String? = null,
contentScale: ContentScale = ContentScale.Fit,
isCrossFadeRequired: Boolean = false,
onImageLoadSuccess: () -> Unit = {},
onImageLoadFailure: () -> Unit = {}
) {
val context = LocalContext.current
val resources = context.resources
val imageLoader = getImageLoader(context)
val blurBitmap by remember(blurHash) {
mutableStateOf(
BlurHashDecoder.decode(
blurHash = blurHash,
width = DEFAULT_HASHED_BITMAP_WIDTH,
height = DEFAULT_HASHED_BITMAP_HEIGHT
)
)
}
AsyncImage(
modifier = imageModifier ?: modifier,
model = ImageRequest.Builder(context)
.data(imageUrl)
.crossfade(isCrossFadeRequired)
.placeholder(
blurBitmap?.toDrawable(resources)
)
.fallback(blurBitmap?.toDrawable(resources))
.build(),
contentDescription = contentDescription,
contentScale = contentScale,
onSuccess = { onImageLoadSuccess() },
onError = { onImageLoadFailure() },
imageLoader = imageLoader
)
}

View File

@@ -0,0 +1,24 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
@Composable
fun BottomNavigationPlaceholder(
color: Color? = null
) {
val navigationBarHeight = with(LocalDensity.current) {
WindowInsets.navigationBars.getBottom(this).toDp()
}
Box(
modifier = Modifier.height(navigationBarHeight).fillMaxWidth().background(color ?: Color.Transparent)
)
}

View File

@@ -0,0 +1,54 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.Icon
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun Checkbox(
size: Int = 24,
checked: Boolean = false,
onCheckedChange: (Boolean) -> Unit = {}
) {
val AppColors = LocalAppTheme.current
val backgroundColor by animateColorAsState(if (checked) AppColors.checkedBackground else Color.Transparent)
val borderColor by animateColorAsState(if (checked) Color.Transparent else AppColors.secondaryText)
val borderWidth by animateDpAsState(if (checked) 0.dp else 2.dp)
Box(
modifier = Modifier
.size(size.dp)
.noRippleClickable {
onCheckedChange(!checked)
}
.clip(CircleShape)
.background(color = backgroundColor)
.border(width = borderWidth, color = borderColor, shape = CircleShape)
.padding(2.dp)
) {
if (checked) {
Icon(
Icons.Default.Check,
contentDescription = "Checked",
tint = AppColors.checkedText
)
}
}
}

View File

@@ -0,0 +1,41 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
@Composable
fun CheckboxWithLabel(
checked: Boolean = false,
checkSize: Int = 16,
label: String = "",
fontSize: Int = 12,
error: Boolean = false,
onCheckedChange: (Boolean) -> Unit,
) {
val AppColors = LocalAppTheme.current
Row(
) {
Checkbox(
checked = checked,
onCheckedChange = {
onCheckedChange(it)
},
size = checkSize
)
Text(
text = label,
modifier = Modifier.padding(start = 8.dp),
fontSize = fontSize.sp,
style = TextStyle(
color = if (error) AppColors.error else AppColors.text
)
)
}
}

View File

@@ -0,0 +1,186 @@
package com.aiosman.ravenow.ui.composables
import android.graphics.BitmapFactory
import android.util.Base64
import androidx.compose.foundation.Canvas
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.material.AlertDialog
import androidx.compose.material.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.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInteropFilter
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.stringResource
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.api.CaptchaResponseBody
import java.io.ByteArrayInputStream
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun ClickCaptchaView(
captchaData: CaptchaResponseBody,
onPositionClicked: (Offset) -> Unit
) {
var clickPositions by remember { mutableStateOf(listOf<Offset>()) }
val context = LocalContext.current
val imageBitmap = remember(captchaData.masterBase64) {
val decodedString = Base64.decode(captchaData.masterBase64, Base64.DEFAULT)
val inputStream = ByteArrayInputStream(decodedString)
BitmapFactory.decodeStream(inputStream).asImageBitmap()
}
val thumbnailBitmap = remember(captchaData.thumbBase64) {
val decodedString = Base64.decode(captchaData.thumbBase64, Base64.DEFAULT)
val inputStream = ByteArrayInputStream(decodedString)
BitmapFactory.decodeStream(inputStream).asImageBitmap()
}
var boxWidth by remember { mutableStateOf(0) }
var boxHeightInDp by remember { mutableStateOf(0.dp) }
var scale by remember { mutableStateOf(1f) }
val density = LocalDensity.current
Column(
modifier = Modifier
.fillMaxWidth()
) {
Text(stringResource(R.string.captcha_hint))
Spacer(modifier = Modifier.height(16.dp))
Image(
bitmap = thumbnailBitmap,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned {
boxWidth = it.size.width
scale = imageBitmap.width.toFloat() / boxWidth
boxHeightInDp = with(density) { (imageBitmap.height.toFloat() / scale).toDp() }
}
.background(Color.Gray)
) {
if (boxWidth != 0 && boxHeightInDp != 0.dp) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(boxHeightInDp)
) {
Image(
bitmap = imageBitmap,
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier
.fillMaxSize()
.pointerInteropFilter { event ->
if (event.action == android.view.MotionEvent.ACTION_DOWN) {
val newPosition = Offset(event.x, event.y)
clickPositions = clickPositions + newPosition
// 计算出点击的位置在图片上的坐标
val imagePosition = Offset(
newPosition.x * scale,
newPosition.y * scale
)
onPositionClicked(imagePosition)
true
} else {
false
}
}
)
// Draw markers at click positions
clickPositions.forEachIndexed { index, position ->
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(
color = Color(0xaada3832).copy(),
radius = 40f,
center = position
)
drawContext.canvas.nativeCanvas.apply {
drawText(
(index + 1).toString(),
position.x,
position.y + 15f, // Adjusting the y position to center the text
android.graphics.Paint().apply {
color = android.graphics.Color.WHITE
textSize = 50f
textAlign = android.graphics.Paint.Align.CENTER
}
)
}
}
}
}
}
}
}
}
@Composable
fun ClickCaptchaDialog(
captchaData: CaptchaResponseBody,
onLoadCaptcha: () -> Unit,
onDismissRequest: () -> Unit,
onPositionClicked: (Offset) -> Unit
) {
AlertDialog(
onDismissRequest = {
onDismissRequest()
},
title = {
Text(stringResource(R.string.captcha))
},
text = {
Column {
Box(
modifier = Modifier
.fillMaxWidth()
) {
ClickCaptchaView(
captchaData = captchaData,
onPositionClicked = onPositionClicked
)
}
Spacer(modifier = Modifier.height(16.dp))
ActionButton(
text = stringResource(R.string.refresh),
modifier = Modifier
.fillMaxWidth(),
) {
onLoadCaptcha()
}
}
},
confirmButton = {
},
)
}

View File

@@ -0,0 +1,50 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
@Composable
fun CustomClickableText(
text: AnnotatedString,
modifier: Modifier = Modifier,
style: TextStyle = TextStyle.Default,
softWrap: Boolean = true,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
onLongPress: () -> Unit = {},
onClick: (Int) -> Unit
) {
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
val pressIndicator = Modifier.pointerInput(onClick) {
detectTapGestures(
onLongPress = { onLongPress() }
) { pos ->
layoutResult.value?.let { layoutResult ->
onClick(layoutResult.getOffsetForPosition(pos))
}
}
}
BasicText(
text = text,
modifier = modifier.then(pressIndicator),
style = style,
softWrap = softWrap,
overflow = overflow,
maxLines = maxLines,
onTextLayout = {
layoutResult.value = it
onTextLayout(it)
}
)
}

View File

@@ -0,0 +1,282 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VectorConverter
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.toOffset
import androidx.compose.ui.unit.toSize
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun <T : Any> DraggableGrid(
items: List<T>,
getItemId: (T) -> String,
onMove: (Int, Int) -> Unit,
onDragModeStart: () -> Unit, // New parameter for drag start
onDragModeEnd: () -> Unit, // New parameter for drag end,
additionalItems: List<@Composable () -> Unit> = emptyList(), // New parameter for additional items
lockedIndices: List<Int> = emptyList(), // New parameter for locked indices
content: @Composable (T, Boolean) -> Unit,
) {
val gridState = rememberLazyGridState()
val dragDropState =
rememberGridDragDropState(gridState, onMove, onDragModeStart, onDragModeEnd, lockedIndices)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.dragContainer(dragDropState),
state = gridState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
itemsIndexed(items, key = { _, item ->
getItemId(item)
}) { index, item ->
DraggableItem(dragDropState, index) { isDragging ->
content(item, isDragging)
}
}
additionalItems.forEach { additionalItem ->
item {
additionalItem()
}
}
}
}
fun Modifier.dragContainer(dragDropState: GridDragDropState): Modifier {
return pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDrag = { change, offset ->
change.consume()
dragDropState.onDrag(offset = offset)
},
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = { dragDropState.onDragInterrupted() },
onDragCancel = { dragDropState.onDragInterrupted() }
)
}
}
@ExperimentalFoundationApi
@Composable
fun LazyGridItemScope.DraggableItem(
dragDropState: GridDragDropState,
index: Int,
modifier: Modifier = Modifier,
content: @Composable (isDragging: Boolean) -> Unit,
) {
val dragging = index == dragDropState.draggingItemIndex
val draggingModifier = if (dragging) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationX = dragDropState.draggingItemOffset.x
translationY = dragDropState.draggingItemOffset.y
}
} else if (index == dragDropState.previousIndexOfDraggedItem) {
Modifier
.zIndex(1f)
.graphicsLayer {
translationX = dragDropState.previousItemOffset.value.x
translationY = dragDropState.previousItemOffset.value.y
}
} else {
Modifier.animateItemPlacement()
}
Box(modifier = modifier.then(draggingModifier), propagateMinConstraints = true) {
content(dragging)
}
}
@Composable
fun rememberGridDragDropState(
gridState: LazyGridState,
onMove: (Int, Int) -> Unit,
onDragModeStart: () -> Unit,
onDragModeEnd: () -> Unit,
lockedIndices: List<Int> // New parameter for locked indices
): GridDragDropState {
val scope = rememberCoroutineScope()
val state = remember(gridState) {
GridDragDropState(
state = gridState,
onMove = onMove,
scope = scope,
onDragModeStart = onDragModeStart,
onDragModeEnd = onDragModeEnd,
lockedIndices = lockedIndices // Pass the locked indices
)
}
LaunchedEffect(state) {
while (true) {
val diff = state.scrollChannel.receive()
gridState.scrollBy(diff)
}
}
return state
}
class GridDragDropState internal constructor(
private val state: LazyGridState,
private val scope: CoroutineScope,
private val onMove: (Int, Int) -> Unit,
private val onDragModeStart: () -> Unit,
private val onDragModeEnd: () -> Unit,
private val lockedIndices: List<Int> // New parameter for locked indices
) {
var draggingItemIndex by mutableStateOf<Int?>(null)
private set
internal val scrollChannel = Channel<Float>()
private var draggingItemDraggedDelta by mutableStateOf(Offset.Zero)
private var draggingItemInitialOffset by mutableStateOf(Offset.Zero)
internal val draggingItemOffset: Offset
get() = draggingItemLayoutInfo?.let { item ->
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset.toOffset()
} ?: Offset.Zero
private val draggingItemLayoutInfo: LazyGridItemInfo?
get() = state.layoutInfo.visibleItemsInfo
.firstOrNull { it.index == draggingItemIndex }
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
internal var previousItemOffset = Animatable(Offset.Zero, Offset.VectorConverter)
private set
internal fun onDragStart(offset: Offset) {
state.layoutInfo.visibleItemsInfo
.firstOrNull { item ->
offset.x.toInt() in item.offset.x..item.offsetEnd.x &&
offset.y.toInt() in item.offset.y..item.offsetEnd.y
}?.also {
if (it.index !in lockedIndices) { // Check if the item is not locked
draggingItemIndex = it.index
draggingItemInitialOffset = it.offset.toOffset()
onDragModeStart() // Notify drag start
}
}
}
internal fun onDragInterrupted() {
if (draggingItemIndex != null) {
previousIndexOfDraggedItem = draggingItemIndex
val startOffset = draggingItemOffset
scope.launch {
previousItemOffset.snapTo(startOffset)
previousItemOffset.animateTo(
Offset.Zero,
spring(
stiffness = Spring.StiffnessMediumLow,
visibilityThreshold = Offset.VisibilityThreshold
)
)
previousIndexOfDraggedItem = null
}
}
draggingItemDraggedDelta = Offset.Zero
draggingItemIndex = null
draggingItemInitialOffset = Offset.Zero
onDragModeEnd() // Notify drag end
}
internal fun onDrag(offset: Offset) {
draggingItemDraggedDelta += offset
val draggingItem = draggingItemLayoutInfo ?: return
val startOffset = draggingItem.offset.toOffset() + draggingItemOffset
val endOffset = startOffset + draggingItem.size.toSize()
val middleOffset = startOffset + (endOffset - startOffset) / 2f
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
middleOffset.x.toInt() in item.offset.x..item.offsetEnd.x &&
middleOffset.y.toInt() in item.offset.y..item.offsetEnd.y &&
draggingItem.index != item.index &&
item.index !in lockedIndices // Check if the target item is not locked
}
if (targetItem != null) {
val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) {
draggingItem.index
} else if (draggingItem.index == state.firstVisibleItemIndex) {
targetItem.index
} else {
null
}
if (scrollToIndex != null) {
scope.launch {
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
onMove.invoke(draggingItem.index, targetItem.index)
}
} else {
onMove.invoke(draggingItem.index, targetItem.index)
}
draggingItemIndex = targetItem.index
} else {
val overscroll = when {
draggingItemDraggedDelta.y > 0 ->
(endOffset.y - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
draggingItemDraggedDelta.y < 0 ->
(startOffset.y - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
else -> 0f
}
if (overscroll != 0f) {
scrollChannel.trySend(overscroll)
}
}
}
private val LazyGridItemInfo.offsetEnd: IntOffset
get() = this.offset + this.size
}
operator fun IntOffset.plus(size: IntSize): IntOffset {
return IntOffset(x + size.width, y + size.height)
}
operator fun Offset.plus(size: Size): Offset {
return Offset(x + size.width, y + size.height)
}

View File

@@ -0,0 +1,87 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
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.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
data class MenuItem(
val title: String,
val icon: Int,
val action: () -> Unit
)
@Composable
fun DropdownMenu(
expanded: Boolean = false,
menuItems: List<MenuItem> = emptyList(),
width: Int? = null,
onDismissRequest: () -> Unit = {},
) {
val AppColors = LocalAppTheme.current
MaterialTheme(
shapes = MaterialTheme.shapes.copy(
extraSmall = RoundedCornerShape(
16.dp
)
)
) {
androidx.compose.material3.DropdownMenu(
expanded = expanded,
onDismissRequest = onDismissRequest,
modifier = Modifier
.let {
if (width != null) it.width(width.dp) else it
}
.background(AppColors.background)
) {
for (item in menuItems) {
Box(
modifier = Modifier
.padding(vertical = 14.dp, horizontal = 24.dp)
.noRippleClickable {
item.action()
}) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text(
item.title,
fontWeight = FontWeight.W500,
color = AppColors.text,
)
if (width != null) {
Spacer(modifier = Modifier.weight(1f))
} else {
Spacer(modifier = Modifier.width(16.dp))
}
Icon(
painter = painterResource(id = item.icon),
contentDescription = "",
modifier = Modifier.size(24.dp),
tint = AppColors.text
)
}
}
}
}
}
}

View File

@@ -0,0 +1,171 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.tween
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.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.Icon
import androidx.compose.material.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.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
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 com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.CommentEntity
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
@Composable
fun EditCommentBottomModal(
replyComment: CommentEntity? = null,
onSend: (String) -> Unit = {}
) {
val AppColors = LocalAppTheme.current
var text by remember { mutableStateOf("") }
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val focusRequester = remember { FocusRequester() }
val context = LocalContext.current
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
Column(
modifier = Modifier
.fillMaxWidth()
.background(AppColors.background)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
Text(
if (replyComment == null) "Comment" else "Reply",
fontWeight = FontWeight.W600,
modifier = Modifier.weight(1f),
fontSize = 20.sp,
fontStyle = FontStyle.Italic,
color = AppColors.text
)
Crossfade(
targetState = text.isNotEmpty(), animationSpec = tween(500),
label = ""
) { isNotEmpty ->
Icon(
painter = painterResource(id = R.drawable.rider_pro_video_share),
contentDescription = "Emoji",
modifier = Modifier
.size(32.dp)
.noRippleClickable {
if (text.isNotEmpty()) {
onSend(text)
text = ""
}
},
tint = if (isNotEmpty) AppColors.main else AppColors.nonActive
)
}
}
Spacer(modifier = Modifier.height(16.dp))
if (replyComment != null) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
) {
CustomAsyncImage(
context,
replyComment.avatar,
modifier = Modifier.fillMaxSize(),
contentDescription = "Avatar",
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
replyComment.name,
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
color = AppColors.text
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
replyComment.comment,
maxLines = 1,
modifier = Modifier
.fillMaxWidth()
.padding(start = 32.dp),
overflow = TextOverflow.Ellipsis,
color = AppColors.text
)
Spacer(modifier = Modifier.height(16.dp))
}
Row(
modifier = Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.clip(RoundedCornerShape(20.dp))
.background(AppColors.inputBackground)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
BasicTextField(
value = text,
onValueChange = {
text = it
},
cursorBrush = SolidColor(AppColors.text),
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
textStyle = TextStyle(
color = AppColors.text,
fontWeight = FontWeight.Normal
),
minLines = 5
)
}
}
Spacer(modifier = Modifier.height(navBarHeight))
}
}

View File

@@ -0,0 +1,52 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape
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.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
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 FollowButton(
isFollowing: Boolean,
fontSize: TextUnit = 12.sp,
onFollowClick: () -> Unit,
){
val AppColors = LocalAppTheme.current
Box(
modifier = Modifier
.wrapContentWidth()
.clip(RoundedCornerShape(8.dp))
.background(
color = if (isFollowing) AppColors.main else AppColors.nonActive
)
.padding(horizontal = 16.dp, vertical = 8.dp)
.noRippleClickable {
onFollowClick()
},
contentAlignment = Alignment.Center
) {
Text(
text = if (isFollowing) stringResource(R.string.following_upper) else stringResource(
R.string.follow_upper
),
fontSize = fontSize,
color = if (isFollowing) AppColors.mainText else AppColors.nonActiveText,
style = TextStyle(fontWeight = FontWeight.Bold)
)
}
}

View File

@@ -0,0 +1,70 @@
package com.aiosman.ravenow.ui.composables
import android.content.Context
import android.graphics.Bitmap
import androidx.annotation.DrawableRes
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.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.request.ImageRequest
import coil.request.SuccessResult
import com.aiosman.ravenow.utils.Utils.getImageLoader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@Composable
fun rememberImageBitmap(imageUrl: String, imageLoader: ImageLoader): Bitmap? {
val context = LocalContext.current
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
LaunchedEffect(imageUrl) {
val request = ImageRequest.Builder(context)
.data(imageUrl)
.crossfade(true)
.build()
val result = withContext(Dispatchers.IO) {
(imageLoader.execute(request) as? SuccessResult)?.drawable?.toBitmap()
}
bitmap = result
}
return bitmap
}
@Composable
fun CustomAsyncImage(
context: Context? = null,
imageUrl: Any?,
contentDescription: String?,
modifier: Modifier = Modifier,
blurHash: String? = null,
@DrawableRes
placeholderRes: Int? = null,
contentScale: ContentScale = ContentScale.Crop
) {
val localContext = LocalContext.current
val imageLoader = getImageLoader(context ?: localContext)
AsyncImage(
model = ImageRequest.Builder(context ?: localContext)
.data(imageUrl)
.crossfade(200)
.build(),
contentDescription = contentDescription,
modifier = modifier,
contentScale = contentScale,
imageLoader = imageLoader
)
}

View File

@@ -0,0 +1,540 @@
package com.aiosman.ravenow.ui.composables
import androidx.annotation.DrawableRes
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.wrapContentWidth
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.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material3.CircularProgressIndicator
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.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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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.MomentEntity
import com.aiosman.ravenow.entity.MomentImageEntity
import com.aiosman.ravenow.exp.timeAgo
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.CommentModalContent
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
@Composable
fun MomentCard(
momentEntity: MomentEntity,
onLikeClick: () -> Unit = {},
onFavoriteClick: () -> Unit = {},
onAddComment: () -> Unit = {},
onFollowClick: () -> Unit = {},
hideAction: Boolean = false,
showFollowButton: Boolean = true
) {
val AppColors = LocalAppTheme.current
var imageIndex by remember { mutableStateOf(0) }
val navController = LocalNavController.current
Column(
modifier = Modifier
.fillMaxWidth()
.background(AppColors.background)
) {
Box(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
) {
MomentTopRowGroup(
momentEntity = momentEntity,
onFollowClick = onFollowClick,
showFollowButton = showFollowButton
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.noRippleClickable {
navController.navigateToPost(
momentEntity.id,
highlightCommentId = 0,
initImagePagerIndex = imageIndex
)
}
) {
MomentContentGroup(
momentEntity = momentEntity,
onPageChange = { index -> imageIndex = index }
)
}
if (!hideAction) {
MomentBottomOperateRowGroup(
momentEntity = momentEntity,
onLikeClick = onLikeClick,
onAddComment = onAddComment,
onFavoriteClick = onFavoriteClick,
imageIndex = imageIndex,
onCommentClick = {
navController.navigateToPost(
momentEntity.id,
highlightCommentId = 0,
initImagePagerIndex = imageIndex
)
}
)
}
}
}
@Composable
fun ModificationListHeader() {
val navController = LocalNavController.current
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFF8F8F8))
.padding(4.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
navController.navigate("ModificationList")
}
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.background(Color(0xFFEB4869))
.padding(8.dp)
) {
Icon(
Icons.Filled.Build,
contentDescription = "Modification Icon",
tint = Color.White, // Assuming the icon should be white
modifier = Modifier.size(12.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Modification List",
color = Color(0xFF333333),
fontSize = 14.sp,
textAlign = TextAlign.Left
)
}
}
}
}
@Composable
fun MomentName(name: String, modifier: Modifier = Modifier) {
val AppColors = LocalAppTheme.current
Text(
modifier = modifier,
textAlign = TextAlign.Start,
text = name,
color = AppColors.text,
fontSize = 16.sp, style = TextStyle(fontWeight = FontWeight.Bold)
)
}
@Composable
fun MomentFollowBtn() {
Box(
modifier = Modifier
.size(width = 53.dp, height = 18.dp)
.padding(start = 8.dp),
contentAlignment = Alignment.Center
) {
Image(
modifier = Modifier
.fillMaxSize(),
painter = painterResource(id = R.drawable.follow_bg),
contentDescription = ""
)
Text(
text = "Follow",
color = Color.White,
fontSize = 12.sp
)
}
}
@Composable
fun MomentPostLocation(location: String) {
val AppColors = LocalAppTheme.current
Text(
text = location,
color = AppColors.secondaryText,
fontSize = 12.sp
)
}
@Composable
fun MomentPostTime(time: String) {
val AppColors = LocalAppTheme.current
Text(
modifier = Modifier,
text = time, color = AppColors.text,
fontSize = 12.sp
)
}
@Composable
fun MomentTopRowGroup(
momentEntity: MomentEntity,
showFollowButton: Boolean = true,
onFollowClick: () -> Unit = {}
) {
val navController = LocalNavController.current
val context = LocalContext.current
Row(
modifier = Modifier
) {
CustomAsyncImage(
context,
momentEntity.avatar,
contentDescription = "",
modifier = Modifier
.size(40.dp)
.clip(RoundedCornerShape(40.dp))
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
momentEntity.authorId.toString()
)
)
},
contentScale = ContentScale.Crop
)
Column(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp, end = 12.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(22.dp),
verticalAlignment = Alignment.CenterVertically
) {
MomentName(
modifier = Modifier.weight(1f),
name = momentEntity.nickname
)
Spacer(modifier = Modifier.width(16.dp))
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(21.dp),
verticalAlignment = Alignment.CenterVertically
) {
MomentPostTime(momentEntity.time.timeAgo(context))
Spacer(modifier = Modifier.width(8.dp))
MomentPostLocation(momentEntity.location)
}
}
val isFollowing = momentEntity.followStatus
if (showFollowButton && !isFollowing) {
Spacer(modifier = Modifier.width(16.dp))
if (AppState.UserId != momentEntity.authorId) {
FollowButton(
isFollowing = false
) {
onFollowClick()
}
}
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun PostImageView(
images: List<MomentImageEntity>,
onPageChange: (Int) -> Unit = {}
) {
val pagerState = rememberPagerState(pageCount = { images.size })
LaunchedEffect(pagerState.currentPage) {
onPageChange(pagerState.currentPage)
}
val context = LocalContext.current
Column(
modifier = Modifier.fillMaxWidth()
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
) { page ->
val image = images[page]
CustomAsyncImage(
context,
image.thumbnail,
contentDescription = "Image",
blurHash = image.blurHash,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
)
}
}
}
@Composable
fun MomentContentGroup(
momentEntity: MomentEntity,
onPageChange: (Int) -> Unit = {}
) {
val AppColors = LocalAppTheme.current
if (momentEntity.momentTextContent.isNotEmpty()) {
Text(
text = momentEntity.momentTextContent,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
fontSize = 16.sp,
color = AppColors.text
)
}
if (momentEntity.relMoment != null) {
RelPostCard(
momentEntity = momentEntity.relMoment!!,
modifier = Modifier.background(Color(0xFFF8F8F8))
)
} else {
Box(
modifier = Modifier.fillMaxWidth()
) {
PostImageView(
images = momentEntity.images,
onPageChange = onPageChange
)
}
}
}
@Composable
fun MomentOperateBtn(@DrawableRes icon: Int, count: String) {
val AppColors = LocalAppTheme.current
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
modifier = Modifier
.size(width = 24.dp, height = 24.dp),
painter = painterResource(id = icon),
contentDescription = "",
colorFilter = ColorFilter.tint(AppColors.text)
)
Text(
text = count,
modifier = Modifier.padding(start = 7.dp),
fontSize = 12.sp,
color = AppColors.text
)
}
}
@Composable
fun MomentOperateBtn(count: String, content: @Composable () -> Unit) {
Row(
modifier = Modifier,
verticalAlignment = Alignment.CenterVertically
) {
content()
AnimatedCounter(
count = count.toInt(),
fontSize = 14,
modifier = Modifier
.padding(start = 7.dp)
.width(24.dp)
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MomentBottomOperateRowGroup(
onLikeClick: () -> Unit = {},
onAddComment: () -> Unit = {},
onCommentClick: () -> Unit = {},
onFavoriteClick: () -> Unit = {},
momentEntity: MomentEntity,
imageIndex: Int = 0
) {
var showCommentModal by remember { mutableStateOf(false) }
if (showCommentModal) {
ModalBottomSheet(
onDismissRequest = { showCommentModal = false },
containerColor = Color.White,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
windowInsets = WindowInsets(0),
dragHandle = {
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.clip(CircleShape)
) {
}
}
) {
CommentModalContent(
postId = momentEntity.id,
commentCount = momentEntity.commentCount,
onCommentAdded = {
onAddComment()
}
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(56.dp)
.padding(start = 16.dp, end = 0.dp)
) {
Row(
modifier = Modifier.fillMaxSize()
) {
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.Center
) {
MomentOperateBtn(count = momentEntity.likeCount.toString()) {
AnimatedLikeIcon(
modifier = Modifier.size(24.dp),
liked = momentEntity.liked
) {
onLikeClick()
}
}
}
Spacer(modifier = Modifier.width(4.dp))
Box(
modifier = Modifier
.fillMaxHeight()
.noRippleClickable {
onCommentClick()
},
contentAlignment = Alignment.Center
) {
MomentOperateBtn(
icon = R.drawable.rider_pro_comment,
count = momentEntity.commentCount.toString()
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.noRippleClickable {
onFavoriteClick()
},
contentAlignment = Alignment.CenterEnd
) {
MomentOperateBtn(count = momentEntity.favoriteCount.toString()) {
AnimatedFavouriteIcon(
modifier = Modifier.size(24.dp),
isFavourite = momentEntity.isFavorite
) {
onFavoriteClick()
}
}
}
}
if (momentEntity.images.size > 1) {
Row(
modifier = Modifier.fillMaxSize(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
momentEntity.images.forEachIndexed { index, _ ->
Box(
modifier = Modifier
.size(4.dp)
.clip(CircleShape)
.background(
if (imageIndex == index) Color.Red else Color.Gray.copy(
alpha = 0.5f
)
)
.padding(1.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
}
@Composable
fun MomentListLoading() {
CircularProgressIndicator(
modifier =
Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally),
color = Color.Red
)
}

View File

@@ -0,0 +1,38 @@
package com.aiosman.ravenow.ui.composables
import android.app.Activity
import android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import com.aiosman.ravenow.utils.Utils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.File
/**
* 选择图片并压缩
*/
@Composable
fun pickupAndCompressLauncher(
context: Context,
scope: CoroutineScope,
maxSize: Int = 512,
quality: Int = 85,
onImagePicked: (Uri, File) -> Unit
) = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri = result.data?.data
uri?.let {
scope.launch {
// Compress the image
val file = Utils.compressImage(context, it, maxSize = maxSize, quality = quality)
// Check the compressed image size
onImagePicked(it, file)
}
}
}
}

View File

@@ -0,0 +1,153 @@
package com.aiosman.ravenow.ui.composables
import android.net.http.SslError
import android.webkit.SslErrorHandler
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.ClickableText
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
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.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.DictService
import com.aiosman.ravenow.data.DictServiceImpl
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PolicyCheckbox(
checked: Boolean = false,
error: Boolean = false,
onCheckedChange: (Boolean) -> Unit,
) {
var showModal by remember { mutableStateOf(false) }
var modalSheetState = androidx.compose.material3.rememberModalBottomSheetState(
skipPartiallyExpanded = true,
)
var scope = rememberCoroutineScope()
val dictService: DictService = DictServiceImpl()
var policyUrl by remember { mutableStateOf("") }
val appColor = LocalAppTheme.current
fun openPolicyModel() {
scope.launch {
try {
val resp = dictService.getDictByKey(ConstVars.DICT_KEY_PRIVATE_POLICY_URL)
policyUrl = resp.value
showModal = true
} catch (e: Exception) {
e.printStackTrace()
}
}
}
if (showModal) {
ModalBottomSheet(
onDismissRequest = {
showModal = false
},
sheetState = modalSheetState,
windowInsets = WindowInsets(0),
containerColor = Color.White,
) {
WebViewDisplay(
url = policyUrl
)
}
}
Row {
Checkbox(
checked = checked,
onCheckedChange = {
onCheckedChange(it)
},
size = 16
)
val text = buildAnnotatedString {
val keyword = stringResource(R.string.private_policy_keyword)
val template = stringResource(R.string.private_policy_template)
append(template)
append(" ")
withStyle(style = SpanStyle(color = if (error) appColor.error else appColor.text)) {
append(keyword)
}
addStyle(
style = SpanStyle(
color = appColor.main,
textDecoration = TextDecoration.Underline
),
start = template.length + 1,
end = template.length + keyword.length + 1
)
append(".")
}
ClickableText(
text = text,
modifier = Modifier.padding(start = 8.dp),
onClick = {
openPolicyModel()
},
style = TextStyle(
fontSize = 12.sp,
color = if (error) appColor.error else appColor.text
)
)
}
}
@Composable
fun WebViewDisplay(modifier: Modifier = Modifier, url: String) {
LazyColumn(
modifier = modifier.fillMaxSize()
) {
item {
AndroidView(
factory = { context ->
WebView(context).apply {
webViewClient = object : WebViewClient() {
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?
) {
handler?.proceed() // 忽略证书错误
}
}
settings.apply {
domStorageEnabled = true
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
}
loadUrl(url)
}
},
modifier = modifier.fillMaxSize()
)
}
}
}

View File

@@ -0,0 +1,39 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.entity.MomentEntity
@Composable
fun RelPostCard(
momentEntity: MomentEntity,
modifier: Modifier = Modifier,
) {
val image = momentEntity.images.firstOrNull()
val context = LocalContext.current
Column(
modifier = modifier
) {
MomentTopRowGroup(momentEntity = momentEntity)
Box(
modifier=Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
image?.let {
CustomAsyncImage(
context,
image.thumbnail,
contentDescription = null,
modifier = Modifier.size(100.dp),
contentScale = ContentScale.Crop
)
}
}
}
}

View File

@@ -0,0 +1,69 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.systemBars
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.LocalAppTheme
import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Composable
fun StatusBarMask(darkIcons: Boolean = true) {
val paddingValues = WindowInsets.systemBars.asPaddingValues()
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = darkIcons)
}
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
}
@Composable
fun StatusBarMaskLayout(
modifier: Modifier = Modifier,
darkIcons: Boolean = true,
useNavigationBarMask: Boolean = true,
maskBoxBackgroundColor: Color = Color.Transparent,
content: @Composable ColumnScope.() -> Unit
) {
val AppColors = LocalAppTheme.current
val paddingValues = WindowInsets.systemBars.asPaddingValues()
val systemUiController = rememberSystemUiController()
val navigationBarPaddings =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
LaunchedEffect(Unit) {
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = darkIcons)
}
Column(
modifier = modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.height(paddingValues.calculateTopPadding())
.fillMaxWidth()
.background(maskBoxBackgroundColor)
) {
}
content()
if (navigationBarPaddings > 24.dp && useNavigationBarMask) {
Box(
modifier = Modifier
.height(navigationBarPaddings).fillMaxWidth().background(AppColors.background)
)
}
}
}

View File

@@ -0,0 +1,15 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.systemBars
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun StatusBarSpacer() {
val paddingValues = WindowInsets.systemBars.asPaddingValues()
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
}

View File

@@ -0,0 +1,145 @@
package com.aiosman.ravenow.ui.composables
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
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 TextInputField(
modifier: Modifier = Modifier,
text: String,
onValueChange: (String) -> Unit,
password: Boolean = false,
label: String? = null,
hint: String? = null,
error: String? = null,
enabled: Boolean = true
) {
val AppColors = LocalAppTheme.current
var showPassword by remember { mutableStateOf(!password) }
var isFocused by remember { mutableStateOf(false) }
Column(modifier = modifier) {
label?.let {
Text(it, color = AppColors.secondaryText)
Spacer(modifier = Modifier.height(16.dp))
}
Box(
contentAlignment = Alignment.CenterStart,
modifier = Modifier
.clip(RoundedCornerShape(24.dp))
.background(AppColors.inputBackground)
.border(
width = 2.dp,
color = if (error == null) Color.Transparent else AppColors.error,
shape = RoundedCornerShape(24.dp)
)
.padding(horizontal = 16.dp, vertical = 16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically){
BasicTextField(
value = text,
onValueChange = onValueChange,
modifier = Modifier
.weight(1f)
.onFocusChanged { focusState ->
isFocused = focusState.isFocused
},
textStyle = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.W500,
color = AppColors.text
),
keyboardOptions = KeyboardOptions(
keyboardType = if (password) KeyboardType.Password else KeyboardType.Text
),
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
singleLine = true,
enabled = enabled,
cursorBrush = SolidColor(AppColors.text),
)
if (password) {
Image(
painter = painterResource(id = R.drawable.rider_pro_eye),
contentDescription = "Password",
modifier = Modifier
.size(18.dp)
.noRippleClickable {
showPassword = !showPassword
},
colorFilter = ColorFilter.tint(AppColors.text)
)
}
}
if (text.isEmpty()) {
hint?.let {
Text(it, modifier = Modifier.padding(start = 5.dp), color = AppColors.inputHint, fontWeight = FontWeight.W600)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(
visible = error != null,
enter = fadeIn(),
exit = fadeOut()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(id = R.mipmap.rider_pro_input_error),
contentDescription = "Error",
modifier = Modifier.size(8.dp)
)
Spacer(modifier = Modifier.size(4.dp))
AnimatedContent(targetState = error) { targetError ->
Text(targetError ?: "", color = AppColors.text, fontSize = 12.sp)
}
}
}
}
}
}

View File

@@ -0,0 +1,137 @@
package com.aiosman.ravenow.ui.composables.form
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
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.SolidColor
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.LocalAppTheme
import com.aiosman.ravenow.R
@Composable
fun FormTextInput(
modifier: Modifier = Modifier,
value: String,
label: String? = null,
error: String? = null,
hint: String? = null,
onValueChange: (String) -> Unit
) {
val AppColors = LocalAppTheme.current
Column(
modifier = modifier
) {
Row(
modifier = Modifier.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(AppColors.inputBackground)
.let {
if (error != null) {
it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp))
} else {
it
}
}
.padding(17.dp),
verticalAlignment = Alignment.CenterVertically
) {
label?.let {
Text(
text = it,
modifier = Modifier
.widthIn(100.dp),
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = AppColors.text
)
)
}
Box(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
) {
if (value.isEmpty()) {
Text(
text = hint ?: "",
style = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = AppColors.inputHint
)
)
}
BasicTextField(
maxLines = 1,
value = value,
onValueChange = {
onValueChange(it)
},
singleLine = true,
textStyle = TextStyle(
fontSize = 16.sp,
fontWeight = FontWeight.Normal,
color = AppColors.text
),
cursorBrush = SolidColor(AppColors.text),
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.height(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
AnimatedVisibility(
visible = error != null,
enter = fadeIn(),
exit = fadeOut()
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Image(
painter = painterResource(id = R.mipmap.rider_pro_input_error),
contentDescription = "Error",
modifier = Modifier.size(8.dp)
)
Spacer(modifier = Modifier.size(4.dp))
AnimatedContent(targetState = error) { targetError ->
Text(targetError ?: "", color = AppColors.error, fontSize = 12.sp)
}
}
}
}
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
@RequiresOptIn(
message = "This is an experimental API of compose-collapsing-toolbar. Any declarations with " +
"the annotation might be removed or changed in some way without any notice.",
level = RequiresOptIn.Level.WARNING
)
@Target(
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY,
AnnotationTarget.CLASS
)
@Retention(AnnotationRetention.BINARY)
annotation class ExperimentalToolbarApi

View File

@@ -0,0 +1,191 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import kotlin.math.max
@Deprecated(
"Use AppBarContainer for naming consistency",
replaceWith = ReplaceWith(
"AppBarContainer(modifier, scrollStrategy, collapsingToolbarState, content)",
"me.onebone.toolbar"
)
)
@Composable
fun AppbarContainer(
modifier: Modifier = Modifier,
scrollStrategy: ScrollStrategy,
collapsingToolbarState: CollapsingToolbarState,
content: @Composable AppbarContainerScope.() -> Unit
) {
AppBarContainer(
modifier = modifier,
scrollStrategy = scrollStrategy,
collapsingToolbarState = collapsingToolbarState,
content = content
)
}
@Deprecated(
"AppBarContainer is replaced with CollapsingToolbarScaffold",
replaceWith = ReplaceWith(
"CollapsingToolbarScaffold",
"me.onebone.toolbar"
)
)
@Composable
fun AppBarContainer(
modifier: Modifier = Modifier,
scrollStrategy: ScrollStrategy,
/** The state of a connected collapsing toolbar */
collapsingToolbarState: CollapsingToolbarState,
content: @Composable AppbarContainerScope.() -> Unit
) {
val offsetY = remember { mutableStateOf(0) }
val flingBehavior = ScrollableDefaults.flingBehavior()
val (scope, measurePolicy) = remember(scrollStrategy, collapsingToolbarState) {
AppbarContainerScopeImpl(scrollStrategy.create(offsetY, collapsingToolbarState, flingBehavior)) to
AppbarMeasurePolicy(scrollStrategy, collapsingToolbarState, offsetY)
}
Layout(
content = { scope.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}
interface AppbarContainerScope {
fun Modifier.appBarBody(): Modifier
}
internal class AppbarContainerScopeImpl(
private val nestedScrollConnection: NestedScrollConnection
): AppbarContainerScope {
override fun Modifier.appBarBody(): Modifier {
return this
.then(AppBarBodyMarkerModifier)
.nestedScroll(nestedScrollConnection)
}
}
private object AppBarBodyMarkerModifier: ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return AppBarBodyMarker
}
}
private object AppBarBodyMarker
private class AppbarMeasurePolicy(
private val scrollStrategy: ScrollStrategy,
private val toolbarState: CollapsingToolbarState,
private val offsetY: State<Int>
): MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
var width = 0
var height = 0
var toolbarPlaceable: Placeable? = null
val nonToolbars = measurables.filter {
val data = it.parentData
if(data != AppBarBodyMarker) {
if(toolbarPlaceable != null)
throw IllegalStateException("There cannot exist multiple toolbars under single parent")
val placeable = it.measure(constraints.copy(
minWidth = 0,
minHeight = 0
))
width = max(width, placeable.width)
height = max(height, placeable.height)
toolbarPlaceable = placeable
false
}else{
true
}
}
val placeables = nonToolbars.map { measurable ->
val childConstraints = if(scrollStrategy == ScrollStrategy.ExitUntilCollapsed) {
constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = max(0, constraints.maxHeight - toolbarState.minHeight)
)
}else{
constraints.copy(
minWidth = 0,
minHeight = 0
)
}
val placeable = measurable.measure(childConstraints)
width = max(width, placeable.width)
height = max(height, placeable.height)
placeable
}
height += (toolbarPlaceable?.height ?: 0)
return layout(
width.coerceIn(constraints.minWidth, constraints.maxWidth),
height.coerceIn(constraints.minHeight, constraints.maxHeight)
) {
toolbarPlaceable?.place(x = 0, y = offsetY.value)
placeables.forEach { placeable ->
placeable.place(
x = 0,
y = offsetY.value + (toolbarPlaceable?.height ?: 0)
)
}
}
}
}

View File

@@ -0,0 +1,386 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.annotation.FloatRange
import androidx.compose.animation.core.AnimationState
import androidx.compose.animation.core.animateTo
import androidx.compose.animation.core.tween
import androidx.compose.foundation.MutatePriority
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.gestures.ScrollScope
import androidx.compose.foundation.gestures.ScrollableState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
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.clipToBounds
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasurePolicy
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import kotlin.math.absoluteValue
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@Stable
class CollapsingToolbarState(
initial: Int = Int.MAX_VALUE
): ScrollableState {
/**
* [height] indicates current height of the toolbar.
*/
var height: Int by mutableStateOf(initial)
private set
/**
* [minHeight] indicates the minimum height of the collapsing toolbar. The toolbar
* may collapse its height to [minHeight] but not smaller. This size is determined by
* the smallest child.
*/
var minHeight: Int
get() = minHeightState
internal set(value) {
minHeightState = value
if(height < value) {
height = value
}
}
/**
* [maxHeight] indicates the maximum height of the collapsing toolbar. The toolbar
* may expand its height to [maxHeight] but not larger. This size is determined by
* the largest child.
*/
var maxHeight: Int
get() = maxHeightState
internal set(value) {
maxHeightState = value
if(value < height) {
height = value
}
}
private var maxHeightState by mutableStateOf(Int.MAX_VALUE)
private var minHeightState by mutableStateOf(0)
val progress: Float
@FloatRange(from = 0.0, to = 1.0)
get() =
if(minHeight == maxHeight) {
0f
}else{
((height - minHeight).toFloat() / (maxHeight - minHeight)).coerceIn(0f, 1f)
}
private val scrollableState = ScrollableState { value ->
val consume = if(value < 0) {
max(minHeight.toFloat() - height, value)
}else{
min(maxHeight.toFloat() - height, value)
}
val current = consume + deferredConsumption
val currentInt = current.toInt()
if(current.absoluteValue > 0) {
height += currentInt
deferredConsumption = current - currentInt
}
consume
}
private var deferredConsumption: Float = 0f
/**
* @return consumed scroll value is returned
*/
@Deprecated(
message = "feedScroll() is deprecated, use dispatchRawDelta() instead.",
replaceWith = ReplaceWith("dispatchRawDelta(value)")
)
fun feedScroll(value: Float): Float = dispatchRawDelta(value)
@ExperimentalToolbarApi
suspend fun expand(duration: Int = 200) {
val anim = AnimationState(height.toFloat())
scroll {
var prev = anim.value
anim.animateTo(maxHeight.toFloat(), tween(duration)) {
scrollBy(value - prev)
prev = value
}
}
}
@ExperimentalToolbarApi
suspend fun collapse(duration: Int = 200) {
val anim = AnimationState(height.toFloat())
scroll {
var prev = anim.value
anim.animateTo(minHeight.toFloat(), tween(duration)) {
scrollBy(value - prev)
prev = value
}
}
}
/**
* @return Remaining velocity after fling
*/
suspend fun fling(flingBehavior: FlingBehavior, velocity: Float): Float {
var left = velocity
scroll {
with(flingBehavior) {
left = performFling(left)
}
}
return left
}
override val isScrollInProgress: Boolean
get() = scrollableState.isScrollInProgress
override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta)
override suspend fun scroll(
scrollPriority: MutatePriority,
block: suspend ScrollScope.() -> Unit
) = scrollableState.scroll(scrollPriority, block)
}
@Composable
fun rememberCollapsingToolbarState(
initial: Int = Int.MAX_VALUE
): CollapsingToolbarState {
return remember {
CollapsingToolbarState(
initial = initial
)
}
}
@Composable
fun CollapsingToolbar(
modifier: Modifier = Modifier,
clipToBounds: Boolean = true,
collapsingToolbarState: CollapsingToolbarState,
content: @Composable CollapsingToolbarScope.() -> Unit
) {
val measurePolicy = remember(collapsingToolbarState) {
CollapsingToolbarMeasurePolicy(collapsingToolbarState)
}
Layout(
content = { CollapsingToolbarScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier.then(
if (clipToBounds) {
Modifier.clipToBounds()
} else {
Modifier
}
)
)
}
private class CollapsingToolbarMeasurePolicy(
private val collapsingToolbarState: CollapsingToolbarState
): MeasurePolicy {
override fun MeasureScope.measure(
measurables: List<Measurable>,
constraints: Constraints
): MeasureResult {
val placeables = measurables.map {
it.measure(
constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = Constraints.Infinity
)
)
}
val placeStrategy = measurables.map { it.parentData }
val minHeight = placeables.minOfOrNull { it.height }
?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0
val maxHeight = placeables.maxOfOrNull { it.height }
?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0
val maxWidth = placeables.maxOfOrNull{ it.width }
?.coerceIn(constraints.minWidth, constraints.maxWidth) ?: 0
collapsingToolbarState.also {
it.minHeight = minHeight
it.maxHeight = maxHeight
}
val height = collapsingToolbarState.height
return layout(maxWidth, height) {
val progress = collapsingToolbarState.progress
placeables.forEachIndexed { i, placeable ->
val strategy = placeStrategy[i]
if(strategy is CollapsingToolbarData) {
strategy.progressListener?.onProgressUpdate(progress)
}
when(strategy) {
is CollapsingToolbarRoadData -> {
val collapsed = strategy.whenCollapsed
val expanded = strategy.whenExpanded
val collapsedOffset = collapsed.align(
size = IntSize(placeable.width, placeable.height),
space = IntSize(maxWidth, height),
layoutDirection = layoutDirection
)
val expandedOffset = expanded.align(
size = IntSize(placeable.width, placeable.height),
space = IntSize(maxWidth, height),
layoutDirection = layoutDirection
)
val offset = collapsedOffset + (expandedOffset - collapsedOffset) * progress
placeable.place(offset.x, offset.y)
}
is CollapsingToolbarParallaxData ->
placeable.placeRelative(
x = 0,
y = -((maxHeight - minHeight) * (1 - progress) * strategy.ratio).roundToInt()
)
else -> placeable.placeRelative(0, 0)
}
}
}
}
}
interface CollapsingToolbarScope {
fun Modifier.progress(listener: ProgressListener): Modifier
fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier
fun Modifier.parallax(ratio: Float = 0.2f): Modifier
fun Modifier.pin(): Modifier
}
internal object CollapsingToolbarScopeInstance: CollapsingToolbarScope {
override fun Modifier.progress(listener: ProgressListener): Modifier {
return this.then(ProgressUpdateListenerModifier(listener))
}
override fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier {
return this.then(RoadModifier(whenCollapsed, whenExpanded))
}
override fun Modifier.parallax(ratio: Float): Modifier {
return this.then(ParallaxModifier(ratio))
}
override fun Modifier.pin(): Modifier {
return this.then(PinModifier())
}
}
internal class RoadModifier(
private val whenCollapsed: Alignment,
private val whenExpanded: Alignment
): ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return CollapsingToolbarRoadData(
this@RoadModifier.whenCollapsed, this@RoadModifier.whenExpanded,
(parentData as? CollapsingToolbarData)?.progressListener
)
}
}
internal class ParallaxModifier(
private val ratio: Float
): ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return CollapsingToolbarParallaxData(ratio, (parentData as? CollapsingToolbarData)?.progressListener)
}
}
internal class PinModifier: ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return CollapsingToolbarPinData((parentData as? CollapsingToolbarData)?.progressListener)
}
}
internal class ProgressUpdateListenerModifier(
private val listener: ProgressListener
): ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return CollapsingToolbarProgressData(listener)
}
}
fun interface ProgressListener {
fun onProgressUpdate(value: Float)
}
internal sealed class CollapsingToolbarData(
var progressListener: ProgressListener?
)
internal class CollapsingToolbarProgressData(
progressListener: ProgressListener?
): CollapsingToolbarData(progressListener)
internal class CollapsingToolbarRoadData(
var whenCollapsed: Alignment,
var whenExpanded: Alignment,
progressListener: ProgressListener? = null
): CollapsingToolbarData(progressListener)
internal class CollapsingToolbarPinData(
progressListener: ProgressListener? = null
): CollapsingToolbarData(progressListener)
internal class CollapsingToolbarParallaxData(
var ratio: Float,
progressListener: ProgressListener? = null
): CollapsingToolbarData(progressListener)

View File

@@ -0,0 +1,234 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.SaverScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.ParentDataModifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.IntSize
import kotlin.math.max
@Stable
class CollapsingToolbarScaffoldState(
val toolbarState: CollapsingToolbarState,
initialOffsetY: Int = 0
) {
val offsetY: Int
get() = offsetYState.value
internal val offsetYState = mutableStateOf(initialOffsetY)
}
private class CollapsingToolbarScaffoldStateSaver: Saver<CollapsingToolbarScaffoldState, List<Any>> {
override fun restore(value: List<Any>): CollapsingToolbarScaffoldState =
CollapsingToolbarScaffoldState(
CollapsingToolbarState(value[0] as Int),
value[1] as Int
)
override fun SaverScope.save(value: CollapsingToolbarScaffoldState): List<Any> =
listOf(
value.toolbarState.height,
value.offsetY
)
}
@Composable
fun rememberCollapsingToolbarScaffoldState(
toolbarState: CollapsingToolbarState = rememberCollapsingToolbarState()
): CollapsingToolbarScaffoldState {
return rememberSaveable(toolbarState, saver = CollapsingToolbarScaffoldStateSaver()) {
CollapsingToolbarScaffoldState(toolbarState)
}
}
interface CollapsingToolbarScaffoldScope {
@ExperimentalToolbarApi
fun Modifier.align(alignment: Alignment): Modifier
}
@Composable
fun CollapsingToolbarScaffold(
modifier: Modifier,
state: CollapsingToolbarScaffoldState,
scrollStrategy: ScrollStrategy,
enabled: Boolean = true,
toolbarModifier: Modifier = Modifier,
toolbarClipToBounds: Boolean = true,
toolbarScrollable: Boolean = false,
toolbar: @Composable CollapsingToolbarScope.(ScrollState) -> Unit,
body: @Composable CollapsingToolbarScaffoldScope.() -> Unit
) {
val flingBehavior = ScrollableDefaults.flingBehavior()
val layoutDirection = LocalLayoutDirection.current
val nestedScrollConnection = remember(scrollStrategy, state) {
scrollStrategy.create(state.offsetYState, state.toolbarState, flingBehavior)
}
val toolbarState = state.toolbarState
val toolbarScrollState = rememberScrollState()
Layout(
content = {
CollapsingToolbar(
modifier = toolbarModifier,
clipToBounds = toolbarClipToBounds,
collapsingToolbarState = toolbarState,
) {
ToolbarScrollableBox(
enabled,
toolbarScrollable,
toolbarState,
toolbarScrollState
)
toolbar(toolbarScrollState)
}
CollapsingToolbarScaffoldScopeInstance.body()
},
modifier = modifier
.then(
if (enabled) {
Modifier.nestedScroll(nestedScrollConnection)
} else {
Modifier
}
)
) { measurables, constraints ->
check(measurables.size >= 2) {
"the number of children should be at least 2: toolbar, (at least one) body"
}
val toolbarConstraints = constraints.copy(
minWidth = 0,
minHeight = 0
)
val bodyConstraints = constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = when (scrollStrategy) {
ScrollStrategy.ExitUntilCollapsed ->
(constraints.maxHeight - toolbarState.minHeight).coerceAtLeast(0)
ScrollStrategy.EnterAlways, ScrollStrategy.EnterAlwaysCollapsed ->
constraints.maxHeight
}
)
val toolbarPlaceable = measurables[0].measure(toolbarConstraints)
val bodyMeasurables = measurables.subList(1, measurables.size)
val childrenAlignments = bodyMeasurables.map {
(it.parentData as? ScaffoldParentData)?.alignment
}
val bodyPlaceables = bodyMeasurables.map {
it.measure(bodyConstraints)
}
val toolbarHeight = toolbarPlaceable.height
val width = max(
toolbarPlaceable.width,
bodyPlaceables.maxOfOrNull { it.width } ?: 0
).coerceIn(constraints.minWidth, constraints.maxWidth)
val height = max(
toolbarHeight,
bodyPlaceables.maxOfOrNull { it.height } ?: 0
).coerceIn(constraints.minHeight, constraints.maxHeight)
layout(width, height) {
bodyPlaceables.forEachIndexed { index, placeable ->
val alignment = childrenAlignments[index]
if (alignment == null) {
placeable.placeRelative(0, toolbarHeight + state.offsetY)
} else {
val offset = alignment.align(
size = IntSize(placeable.width, placeable.height),
space = IntSize(width, height),
layoutDirection = layoutDirection
)
placeable.place(offset)
}
}
toolbarPlaceable.placeRelative(0, state.offsetY)
}
}
}
@Composable
private fun ToolbarScrollableBox(
enabled: Boolean,
toolbarScrollable: Boolean,
toolbarState: CollapsingToolbarState,
toolbarScrollState: ScrollState
) {
val toolbarScrollableEnabled = enabled && toolbarScrollable
if (toolbarScrollableEnabled && toolbarState.height != Constraints.Infinity) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(with(LocalDensity.current) { toolbarState.height.toDp() })
.verticalScroll(state = toolbarScrollState)
)
}
}
internal object CollapsingToolbarScaffoldScopeInstance: CollapsingToolbarScaffoldScope {
@ExperimentalToolbarApi
override fun Modifier.align(alignment: Alignment): Modifier =
this.then(ScaffoldChildAlignmentModifier(alignment))
}
private class ScaffoldChildAlignmentModifier(
private val alignment: Alignment
) : ParentDataModifier {
override fun Density.modifyParentData(parentData: Any?): Any {
return (parentData as? ScaffoldParentData) ?: ScaffoldParentData(alignment)
}
}
private data class ScaffoldParentData(
var alignment: Alignment? = null
)

View File

@@ -0,0 +1,32 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.compose.runtime.Immutable
@Immutable
class FabPlacement(
val left: Int,
val width: Int,
val height: Int
)

View File

@@ -0,0 +1,6 @@
package com.aiosman.ravenow.ui.composables.toolbar
enum class FabPosition {
Center,
End
}

View File

@@ -0,0 +1,239 @@
/*
* Copyright (c) 2021 onebone <me@onebone.me>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
* OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.runtime.MutableState
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
enum class ScrollStrategy {
EnterAlways {
override fun create(
offsetY: MutableState<Int>,
toolbarState: CollapsingToolbarState,
flingBehavior: FlingBehavior
): NestedScrollConnection =
EnterAlwaysNestedScrollConnection(offsetY, toolbarState, flingBehavior)
},
EnterAlwaysCollapsed {
override fun create(
offsetY: MutableState<Int>,
toolbarState: CollapsingToolbarState,
flingBehavior: FlingBehavior
): NestedScrollConnection =
EnterAlwaysCollapsedNestedScrollConnection(offsetY, toolbarState, flingBehavior)
},
ExitUntilCollapsed {
override fun create(
offsetY: MutableState<Int>,
toolbarState: CollapsingToolbarState,
flingBehavior: FlingBehavior
): NestedScrollConnection =
ExitUntilCollapsedNestedScrollConnection(toolbarState, flingBehavior)
};
internal abstract fun create(
offsetY: MutableState<Int>,
toolbarState: CollapsingToolbarState,
flingBehavior: FlingBehavior
): NestedScrollConnection
}
private class ScrollDelegate(
private val offsetY: MutableState<Int>
) {
private var scrollToBeConsumed: Float = 0f
fun doScroll(delta: Float) {
val scroll = scrollToBeConsumed + delta
val scrollInt = scroll.toInt()
scrollToBeConsumed = scroll - scrollInt
offsetY.value += scrollInt
}
}
internal class EnterAlwaysNestedScrollConnection(
private val offsetY: MutableState<Int>,
private val toolbarState: CollapsingToolbarState,
private val flingBehavior: FlingBehavior
): NestedScrollConnection {
private val scrollDelegate = ScrollDelegate(offsetY)
//private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl())
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val dy = available.y
val toolbar = toolbarState.height.toFloat()
val offset = offsetY.value.toFloat()
// -toolbarHeight <= offsetY + dy <= 0
val consume = if(dy < 0) {
val toolbarConsumption = toolbarState.dispatchRawDelta(dy)
val remaining = dy - toolbarConsumption
val offsetConsumption = remaining.coerceAtLeast(-toolbar - offset)
scrollDelegate.doScroll(offsetConsumption)
toolbarConsumption + offsetConsumption
}else{
val offsetConsumption = dy.coerceAtMost(-offset)
scrollDelegate.doScroll(offsetConsumption)
val toolbarConsumption = toolbarState.dispatchRawDelta(dy - offsetConsumption)
offsetConsumption + toolbarConsumption
}
return Offset(0f, consume)
}
override suspend fun onPreFling(available: Velocity): Velocity {
val left = if(available.y > 0) {
toolbarState.fling(flingBehavior, available.y)
}else{
// If velocity < 0, the main content should have a remaining scroll space
// so the scroll resumes to the onPreScroll(..., Fling) phase. Hence we do
// not need to process it at onPostFling() manually.
available.y
}
return Velocity(x = 0f, y = available.y - left)
}
}
internal class EnterAlwaysCollapsedNestedScrollConnection(
private val offsetY: MutableState<Int>,
private val toolbarState: CollapsingToolbarState,
private val flingBehavior: FlingBehavior
): NestedScrollConnection {
private val scrollDelegate = ScrollDelegate(offsetY)
//private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl())
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val dy = available.y
val consumed = if(dy > 0) { // expanding: offset -> body -> toolbar
val offsetConsumption = dy.coerceAtMost(-offsetY.value.toFloat())
scrollDelegate.doScroll(offsetConsumption)
offsetConsumption
}else{ // collapsing: toolbar -> offset -> body
val toolbarConsumption = toolbarState.dispatchRawDelta(dy)
val offsetConsumption = (dy - toolbarConsumption).coerceAtLeast(-toolbarState.height.toFloat() - offsetY.value)
scrollDelegate.doScroll(offsetConsumption)
toolbarConsumption + offsetConsumption
}
return Offset(0f, consumed)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
val dy = available.y
return if(dy > 0) {
Offset(0f, toolbarState.dispatchRawDelta(dy))
}else{
Offset(0f, 0f)
}
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val dy = available.y
val left = if(dy > 0) {
// onPostFling() has positive available scroll value only called if the main scroll
// has leftover scroll, i.e. the scroll of the main content has done. So we just process
// fling if the available value is positive.
toolbarState.fling(flingBehavior, dy)
}else{
dy
}
return Velocity(x = 0f, y = available.y - left)
}
}
internal class ExitUntilCollapsedNestedScrollConnection(
private val toolbarState: CollapsingToolbarState,
private val flingBehavior: FlingBehavior
): NestedScrollConnection {
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
val dy = available.y
val consume = if(dy < 0) { // collapsing: toolbar -> body
toolbarState.dispatchRawDelta(dy)
}else{
0f
}
return Offset(0f, consume)
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
val dy = available.y
val consume = if(dy > 0) { // expanding: body -> toolbar
toolbarState.dispatchRawDelta(dy)
}else{
0f
}
return Offset(0f, consume)
}
override suspend fun onPreFling(available: Velocity): Velocity {
val left = if(available.y < 0) {
toolbarState.fling(flingBehavior, available.y)
}else{
available.y
}
return Velocity(x = 0f, y = available.y - left)
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
val velocity = available.y
val left = if(velocity > 0) {
toolbarState.fling(flingBehavior, velocity)
}else{
velocity
}
return Velocity(x = 0f, y = available.y - left)
}
}

View File

@@ -0,0 +1,107 @@
package com.aiosman.ravenow.ui.composables.toolbar
import androidx.compose.foundation.ScrollState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
@ExperimentalToolbarApi
@Composable
fun ToolbarWithFabScaffold(
modifier: Modifier,
state: CollapsingToolbarScaffoldState,
scrollStrategy: ScrollStrategy,
toolbarModifier: Modifier = Modifier,
toolbarClipToBounds: Boolean = true,
toolbar: @Composable CollapsingToolbarScope.(ScrollState) -> Unit,
toolbarScrollable: Boolean = false,
fab: @Composable () -> Unit,
fabPosition: FabPosition = FabPosition.End,
body: @Composable CollapsingToolbarScaffoldScope.() -> Unit
) {
SubcomposeLayout(
modifier = modifier
) { constraints ->
val toolbarScaffoldConstraints = constraints.copy(
minWidth = 0,
minHeight = 0,
maxHeight = constraints.maxHeight
)
val toolbarScaffoldPlaceables = subcompose(ToolbarWithFabScaffoldContent.ToolbarScaffold) {
CollapsingToolbarScaffold(
modifier = modifier,
state = state,
scrollStrategy = scrollStrategy,
toolbarModifier = toolbarModifier,
toolbarClipToBounds = toolbarClipToBounds,
toolbar = toolbar,
body = body,
toolbarScrollable = toolbarScrollable
)
}.map { it.measure(toolbarScaffoldConstraints) }
val fabConstraints = constraints.copy(
minWidth = 0,
minHeight = 0
)
val fabPlaceables = subcompose(
ToolbarWithFabScaffoldContent.Fab,
fab
).mapNotNull { measurable ->
measurable.measure(fabConstraints).takeIf { it.height != 0 && it.width != 0 }
}
val fabPlacement = if (fabPlaceables.isNotEmpty()) {
val fabWidth = fabPlaceables.maxOfOrNull { it.width } ?: 0
val fabHeight = fabPlaceables.maxOfOrNull { it.height } ?: 0
// FAB distance from the left of the layout, taking into account LTR / RTL
val fabLeftOffset = if (fabPosition == FabPosition.End) {
if (layoutDirection == LayoutDirection.Ltr) {
constraints.maxWidth - 16.dp.roundToPx() - fabWidth
} else {
16.dp.roundToPx()
}
} else {
(constraints.maxWidth - fabWidth) / 2
}
FabPlacement(
left = fabLeftOffset,
width = fabWidth,
height = fabHeight
)
} else {
null
}
val fabOffsetFromBottom = fabPlacement?.let {
it.height + 16.dp.roundToPx()
}
val width = constraints.maxWidth
val height = constraints.maxHeight
layout(width, height) {
toolbarScaffoldPlaceables.forEach {
it.place(0, 0)
}
fabPlacement?.let { placement ->
fabPlaceables.forEach {
it.place(placement.left, height - fabOffsetFromBottom!!)
}
}
}
}
}
private enum class ToolbarWithFabScaffoldContent {
ToolbarScaffold, Fab
}

View File

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

View File

@@ -0,0 +1,237 @@
package com.aiosman.ravenow.ui.dialogs
import android.content.Intent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
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.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.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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.FileProvider
import com.aiosman.ravenow.ConstVars
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.model.UpdateInfo
import com.aiosman.ravenow.ui.composables.ActionButton
import com.google.firebase.perf.config.RemoteConfigManager.getVersionCode
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CheckUpdateDialog() {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val scope = rememberCoroutineScope()
var showDialog by remember { mutableStateOf(false) }
var newApkUrl by remember { mutableStateOf("") }
var progress by remember { mutableStateOf(0f) }
var isDownloading by remember { mutableStateOf(false) } // Add downloading state
var message by remember { mutableStateOf("") }
var versionName by remember { mutableStateOf("") }
fun checkUpdate() {
scope.launch(Dispatchers.IO) {
try {
val client = OkHttpClient()
val request = Request.Builder()
.url("${ConstVars.BASE_SERVER}/static/update/beta/version.json")
.build()
val response = client.newCall(request).execute()
if (response.isSuccessful) {
val responseBody = response.body?.string()
val updateInfo = Gson().fromJson(responseBody, UpdateInfo::class.java)
val versionCode = getVersionCode(context)
if (updateInfo.versionCode > versionCode) {
withContext(Dispatchers.Main) {
message = updateInfo.updateContent
versionName = updateInfo.versionName
showDialog = true
newApkUrl = updateInfo.downloadUrl
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun downloadApk() {
isDownloading = true
scope.launch(Dispatchers.IO) {
val request = Request.Builder()
.url(newApkUrl)
.build()
val client = OkHttpClient()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) throw Exception("Unexpected code $response")
val body = response.body
if (body != null) {
val apkFile = File(context.cacheDir, "rider_pro.apk")
val totalBytes = body.contentLength()
var downloadedBytes = 0L
apkFile.outputStream().use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(8 * 1024)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
output.write(buffer, 0, bytesRead)
downloadedBytes += bytesRead
progress = downloadedBytes / totalBytes.toFloat()
}
}
}
val apkUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile)
val intent = Intent(Intent.ACTION_VIEW)
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
context.startActivity(intent)
}
}
isDownloading = false
}
}
LaunchedEffect(Unit) {
checkUpdate()
}
if (showDialog) {
BasicAlertDialog(
onDismissRequest = {
if (!isDownloading) {
showDialog = false
}
},
modifier = Modifier,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = 24.dp, end = 24.dp, bottom = 120.dp),
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.TopCenter
) {
Column(
modifier = Modifier
.fillMaxWidth()
) {
Spacer(modifier = Modifier.height(96.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(32.dp))
.background(AppColors.background)
.padding(vertical = 32.dp, horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(48.dp))
Text(
stringResource(id = R.string.update_find_new_version),
fontWeight = FontWeight.W900,
fontSize = 22.sp,
color = AppColors.text
)
Spacer(modifier = Modifier.height(16.dp))
Text(
versionName,
modifier = Modifier.fillMaxWidth(),
color = AppColors.text
)
Text(
message,
modifier = Modifier.fillMaxWidth(),
color = AppColors.text
)
if (progress > 0) {
Spacer(modifier = Modifier.height(16.dp))
LinearProgressIndicator(
progress = { progress },
color = AppColors.main,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp)),
trackColor = AppColors.basicMain
)
}
Spacer(modifier = Modifier.height(32.dp))
Row(
modifier = Modifier.fillMaxWidth()
) {
ActionButton(
text = stringResource(id = R.string.update_later),
color = AppColors.text,
backgroundColor = AppColors.basicMain,
modifier = Modifier.weight(1f),
fullWidth = false,
roundCorner = 16f,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
) {
showDialog = false
}
Spacer(modifier = Modifier.width(16.dp))
ActionButton(
text = stringResource(id = R.string.update_update_now),
backgroundColor = AppColors.main,
color = AppColors.mainText,
modifier = Modifier.weight(1f),
fullWidth = false,
roundCorner = 16f,
fontSize = 14.sp,
fontWeight = FontWeight.Normal,
) {
downloadApk()
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
Image(
painter = painterResource(id = R.mipmap.rider_pro_update_header),
contentDescription = null,
modifier = Modifier.padding(16.dp)
)
}
}
}
}
}

View File

@@ -0,0 +1,132 @@
package com.aiosman.ravenow.ui.favourite
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.aspectRatio
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.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.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.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.res.stringResource
import androidx.compose.ui.unit.dp
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.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel.refreshPager
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FavouriteListPage() {
val AppColors = LocalAppTheme.current
val model = FavouriteListViewModel
var dataFlow = model.favouriteMomentsFlow
var moments = dataFlow.collectAsLazyPagingItems()
val context = LocalContext.current
val navController = LocalNavController.current
val state = rememberPullRefreshState(FavouriteListViewModel.isLoading, onRefresh = {
model.refreshPager(force = true)
})
LaunchedEffect(Unit) {
refreshPager()
}
Column(
modifier = Modifier.fillMaxSize().background(AppColors.background)
) {
StatusBarSpacer()
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.pullRefresh(state)
) {
Column(
modifier = Modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp),
) {
NoticeScreenHeader(stringResource(R.string.favourites_upper), moreIcon = false)
}
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)
) {
items(moments.itemCount) { idx ->
val momentItem = moments[idx] ?: return@items
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.padding(2.dp)
.noRippleClickable {
navController.navigateToPost(
id = momentItem.id,
highlightCommentId = 0,
initImagePagerIndex = 0
)
}
) {
CustomAsyncImage(
imageUrl = momentItem.images[0].thumbnail,
contentDescription = "",
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp)),
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 = "",
)
}
}
}
}
}
}
PullRefreshIndicator(
FavouriteListViewModel.isLoading,
state,
Modifier.align(Alignment.TopCenter)
)
}
}
}

View File

@@ -0,0 +1,52 @@
package com.aiosman.ravenow.ui.favourite
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 com.aiosman.ravenow.AppState
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 FavouriteListViewModel:ViewModel() {
private val momentService: MomentService = MomentServiceImpl()
private val _favouriteMomentsFlow =
MutableStateFlow<PagingData<MomentEntity>>(PagingData.empty())
val favouriteMomentsFlow = _favouriteMomentsFlow.asStateFlow()
var isLoading by mutableStateOf(false)
fun refreshPager(force:Boolean = false) {
viewModelScope.launch {
if (force) {
isLoading = true
}
isLoading = false
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
MomentPagingSource(
MomentRemoteDataSource(momentService),
favoriteUserId = AppState.UserId
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_favouriteMomentsFlow.value = it
}
}
}
fun ResetModel() {
isLoading = false
}
}

View File

@@ -0,0 +1,85 @@
package com.aiosman.ravenow.ui.favourite
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder
import com.aiosman.ravenow.ui.like.ActionPostNoticeItem
/**
* 收藏消息界面
*/
@Composable
fun FavouriteNoticeScreen() {
val model = FavouriteNoticeViewModel
val listState = rememberLazyListState()
var dataFlow = model.favouriteItemsFlow
var favourites = dataFlow.collectAsLazyPagingItems()
LaunchedEffect(Unit) {
model.reload()
model.updateNotice()
}
StatusBarMaskLayout(
darkIcons = !AppState.darkMode,
maskBoxBackgroundColor = Color(0xFFFFFFFF)
) {
Column(
modifier = Modifier
.weight(1f)
.background(color = Color(0xFFFFFFFF))
.padding(horizontal = 16.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
NoticeScreenHeader(
stringResource(R.string.favourites_upper),
moreIcon = false
)
}
LazyColumn(
modifier = Modifier.weight(1f),
state = listState,
) {
items(favourites.itemCount) {
val favouriteItem = favourites[it]
if (favouriteItem != null) {
ActionPostNoticeItem(
avatar = favouriteItem.user.avatar,
nickName = favouriteItem.user.nickName,
likeTime = favouriteItem.favoriteTime,
thumbnail = favouriteItem.post.images[0].thumbnail,
action = "favourite",
userId = favouriteItem.user.id,
postId = favouriteItem.post.id,
)
}
}
item {
BottomNavigationPlaceholder()
}
}
}
}
}

View File

@@ -0,0 +1,62 @@
package com.aiosman.ravenow.ui.favourite
import android.icu.util.Calendar
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.entity.AccountFavouriteEntity
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.entity.FavoriteItemPagingSource
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.UpdateNoticeRequestBody
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* 收藏消息列表的 ViewModel
*/
object FavouriteNoticeViewModel : ViewModel() {
private val accountService: AccountService = AccountServiceImpl()
private val _favouriteItemsFlow =
MutableStateFlow<PagingData<AccountFavouriteEntity>>(PagingData.empty())
val favouriteItemsFlow = _favouriteItemsFlow.asStateFlow()
var isFirstLoad = true
fun reload(force: Boolean = false) {
if (!isFirstLoad && !force) {
return
}
isFirstLoad = false
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
FavoriteItemPagingSource(
accountService
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_favouriteItemsFlow.value = it
}
}
}
// 更新收藏消息的查看时间
suspend fun updateNotice() {
var now = Calendar.getInstance().time
accountService.updateNotice(
UpdateNoticeRequestBody(
lastLookFavouriteTime = ApiClient.formatTime(now)
)
)
}
fun ResetModel() {
isFirstLoad = true
}
}

View File

@@ -0,0 +1,89 @@
package com.aiosman.ravenow.ui.follower
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FollowerListScreen(userId: Int) {
val AppColors = LocalAppTheme.current
val model = FollowerListViewModel
val scope = rememberCoroutineScope()
val refreshState = rememberPullRefreshState(model.isLoading, onRefresh = {
model.loadData(userId, true)
})
LaunchedEffect(Unit) {
model.loadData(userId)
}
StatusBarMaskLayout(
modifier = Modifier
.background(color = AppColors.background)
.padding(horizontal = 16.dp),
maskBoxBackgroundColor = AppColors.background,
) {
var dataFlow = model.usersFlow
var users = dataFlow.collectAsLazyPagingItems()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
NoticeScreenHeader(stringResource(R.string.followers_upper), moreIcon = false)
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.pullRefresh(refreshState)
) {
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(users.itemCount) { index ->
users[index]?.let { user ->
FollowItem(
avatar = user.avatar,
nickname = user.nickName,
userId = user.id,
isFollowing = user.isFollowing
) {
scope.launch {
if (user.isFollowing) {
model.unFollowUser(user.id)
} else {
model.followUser(user.id)
}
}
}
}
}
}
PullRefreshIndicator(
refreshing = model.isLoading,
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
}

View File

@@ -0,0 +1,71 @@
package com.aiosman.ravenow.ui.follower
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.UserServiceImpl
import com.aiosman.ravenow.entity.AccountPagingSource
import com.aiosman.ravenow.entity.AccountProfileEntity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object FollowerListViewModel : ViewModel() {
private val userService = UserServiceImpl()
private val _usersFlow = MutableStateFlow<PagingData<AccountProfileEntity>>(PagingData.empty())
val usersFlow = _usersFlow.asStateFlow()
private var userId by mutableStateOf<Int?>(null)
var isLoading by mutableStateOf(false)
fun loadData(id: Int,force : Boolean = false) {
if (userId == id && !force) {
return
}
isLoading = true
userId = id
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
AccountPagingSource(
userService,
followerId = id
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_usersFlow.value = it
}
}
isLoading = false
}
private fun updateIsFollow(id: Int, isFollow: Boolean = true) {
val currentPagingData = usersFlow.value
val updatedPagingData = currentPagingData.map { user ->
if (user.id == id) {
user.copy(isFollowing = isFollow)
} else {
user
}
}
_usersFlow.value = updatedPagingData
}
suspend fun followUser(userId: Int) {
userService.followUser(userId.toString())
updateIsFollow(userId)
}
suspend fun unFollowUser(userId: Int) {
userService.unFollowUser(userId.toString())
updateIsFollow(userId, false)
}
}

View File

@@ -0,0 +1,168 @@
package com.aiosman.ravenow.ui.follower
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
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.compose.ui.draw.clip
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.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.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import kotlinx.coroutines.launch
/**
* 关注消息列表
*/
@Composable
fun FollowerNoticeScreen() {
val scope = rememberCoroutineScope()
val AppColors = LocalAppTheme.current
StatusBarMaskLayout(
modifier = Modifier.background(color = AppColors.background).padding(horizontal = 16.dp),
darkIcons = !AppState.darkMode,
maskBoxBackgroundColor = AppColors.background
) {
val model = FollowerNoticeViewModel
var dataFlow = model.followerItemsFlow
var followers = dataFlow.collectAsLazyPagingItems()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
.background(color = AppColors.background)
) {
NoticeScreenHeader(stringResource(R.string.followers_upper), moreIcon = false)
}
LaunchedEffect(Unit) {
model.reload()
model.updateNotice()
}
if (followers.itemCount == 0) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.mipmap.rider_pro_followers_empty),
contentDescription = null,
modifier = Modifier.size(140.dp)
)
Spacer(modifier = Modifier.size(32.dp))
androidx.compose.material.Text(
text = "No followers yet",
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.size(16.dp))
androidx.compose.material.Text(
text = "Share your life and get more followers.",
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W400
)
}
}
}else{
LazyColumn(
modifier = Modifier.weight(1f)
.background(color = AppColors.background)
) {
items(followers.itemCount) { index ->
followers[index]?.let { follower ->
FollowItem(
avatar = follower.avatar,
nickname = follower.nickname,
userId = follower.userId,
isFollowing = follower.isFollowing
) {
scope.launch {
model.followUser(follower.userId)
}
}
}
}
}
}
}
}
@Composable
fun FollowItem(
avatar: String,
nickname: String,
userId: Int,
isFollowing: Boolean,
onFollow: () -> Unit = {}
) {
val AppColors = LocalAppTheme.current
val context = LocalContext.current
val navController = LocalNavController.current
Box(
modifier = Modifier.padding(vertical = 16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
CustomAsyncImage(
context = context,
imageUrl = avatar,
contentDescription = nickname,
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
userId.toString()
)
)
}
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(nickname, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = AppColors.text)
}
}
}
}

View File

@@ -0,0 +1,81 @@
package com.aiosman.ravenow.ui.follower
import android.icu.util.Calendar
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.AccountFollow
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.entity.FollowItemPagingSource
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.UpdateNoticeRequestBody
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* 关注消息列表的 ViewModel
*/
object FollowerNoticeViewModel : ViewModel() {
private val accountService: AccountService = AccountServiceImpl()
private val userService: UserService = UserServiceImpl()
private val _followerItemsFlow =
MutableStateFlow<PagingData<AccountFollow>>(PagingData.empty())
val followerItemsFlow = _followerItemsFlow.asStateFlow()
var isFirstLoad = true
fun reload(force: Boolean = false) {
if (!isFirstLoad && !force) {
return
}
isFirstLoad = false
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
FollowItemPagingSource(
accountService
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_followerItemsFlow.value = it
}
}
}
private fun updateIsFollow(id: Int) {
val currentPagingData = _followerItemsFlow.value
val updatedPagingData = currentPagingData.map { follow ->
if (follow.userId == id) {
follow.copy(isFollowing = true)
} else {
follow
}
}
_followerItemsFlow.value = updatedPagingData
}
suspend fun followUser(userId: Int) {
userService.followUser(userId.toString())
updateIsFollow(userId)
}
suspend fun updateNotice() {
var now = Calendar.getInstance().time
accountService.updateNotice(
UpdateNoticeRequestBody(
lastLookFollowTime = ApiClient.formatTime(now)
)
)
}
fun ResetModel() {
isFirstLoad = true
}
}

View File

@@ -0,0 +1,130 @@
package com.aiosman.ravenow.ui.follower
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.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.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.compose.collectAsLazyPagingItems
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun FollowingListScreen(userId: Int) {
val AppColors = LocalAppTheme.current
val model = FollowingListViewModel
val scope = rememberCoroutineScope()
val refreshState = rememberPullRefreshState(model.isLoading, onRefresh = {
model.loadData(userId, true)
})
LaunchedEffect(Unit) {
model.loadData(userId)
}
StatusBarMaskLayout(
modifier = Modifier
.background(color = AppColors.background)
.padding(horizontal = 16.dp),
maskBoxBackgroundColor = AppColors.background
) {
var dataFlow = model.usersFlow
var users = dataFlow.collectAsLazyPagingItems()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
NoticeScreenHeader(stringResource(R.string.following_upper), moreIcon = false)
}
if(users.itemCount == 0) {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.mipmap.rider_pro_following_empty),
contentDescription = null,
modifier = Modifier.size(140.dp)
)
Spacer(modifier = Modifier.size(32.dp))
androidx.compose.material.Text(
text = "You haven't followed anyone yet",
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W600
)
Spacer(modifier = Modifier.size(16.dp))
androidx.compose.material.Text(
text = "Click start your social journey.",
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W400
)
}
}
}else{
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.pullRefresh(refreshState)
) {
LazyColumn(
modifier = Modifier.fillMaxSize()
) {
items(users.itemCount) { index ->
users[index]?.let { user ->
FollowItem(
avatar = user.avatar,
nickname = user.nickName,
userId = user.id,
isFollowing = user.isFollowing
) {
scope.launch {
if (user.isFollowing) {
model.unfollowUser(user.id)
} else {
model.followUser(user.id)
}
}
}
}
}
}
PullRefreshIndicator(
refreshing = model.isLoading,
state = refreshState,
modifier = Modifier.align(Alignment.TopCenter)
)
}
}
}
}

View File

@@ -0,0 +1,75 @@
package com.aiosman.ravenow.ui.follower
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.UserServiceImpl
import com.aiosman.ravenow.entity.AccountPagingSource
import com.aiosman.ravenow.entity.AccountProfileEntity
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object FollowingListViewModel : ViewModel() {
private val userService = UserServiceImpl()
private val _usersFlow = MutableStateFlow<PagingData<AccountProfileEntity>>(PagingData.empty())
var isLoading by mutableStateOf(false)
val usersFlow = _usersFlow.asStateFlow()
private var userId by mutableStateOf<Int?>(null)
fun loadData(id: Int, force: Boolean = false) {
if (userId == id && !force) {
return
}
isLoading = true
userId = id
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
AccountPagingSource(
userService,
followingId = id
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_usersFlow.value = it
}
}
isLoading = false
}
private fun updateIsFollow(id: Int, isFollow: Boolean = true) {
val currentPagingData = usersFlow.value
val updatedPagingData = currentPagingData.map { user ->
if (user.id == id) {
user.copy(isFollowing = isFollow)
} else {
user
}
}
_usersFlow.value = updatedPagingData
}
suspend fun followUser(userId: Int) {
userService.followUser(userId.toString())
updateIsFollow(userId)
}
suspend fun unfollowUser(userId: Int) {
userService.unFollowUser(userId.toString())
updateIsFollow(userId, false)
}
fun ResetModel() {
userId = null
}
}

View File

@@ -0,0 +1,278 @@
package com.aiosman.ravenow.ui.gallery
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
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.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathEffect
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 com.aiosman.ravenow.R
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ProfileTimelineScreen() {
val pagerState = rememberPagerState(pageCount = { 2 })
val scope = rememberCoroutineScope()
val systemUiController = rememberSystemUiController()
fun switchToPage(page: Int) {
scope.launch {
pagerState.animateScrollToPage(page)
}
}
LaunchedEffect(Unit) {
systemUiController.setNavigationBarColor(Color.Transparent)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text("Gallery")
},
navigationIcon = { },
actions = { })
},
) { paddingValues: PaddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
Column(modifier = Modifier) {
ScrollableTabRow(
edgePadding = 0.dp,
selectedTabIndex = pagerState.currentPage,
modifier = Modifier,
divider = { },
indicator = { tabPositions ->
Box(
modifier = Modifier
.tabIndicatorOffset(tabPositions[pagerState.currentPage])
) {
Box(
modifier = Modifier
.align(Alignment.Center)
.height(4.dp)
.width(16.dp)
.background(color = Color.Red)
)
}
}
) {
Tab(
text = { Text("Timeline", color = Color.Black) },
selected = pagerState.currentPage == 0,
onClick = { switchToPage(0) }
)
Tab(
text = { Text("Position", color = Color.Black) },
selected = pagerState.currentPage == 1,
onClick = { switchToPage(1) }
)
}
HorizontalPager(
state = pagerState,
modifier = Modifier
.weight(1f)
.fillMaxSize()
) { page ->
when (page) {
0 -> GalleryTimeline()
1 -> GalleryPosition()
}
}
}
}
}
}
@Composable
fun GalleryTimeline() {
val mockList = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
Box(
modifier = Modifier
.fillMaxSize()
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
) {
items(mockList) { item ->
TimelineItem()
}
}
}
}
@Composable
fun DashedVerticalLine(modifier: Modifier = Modifier) {
BoxWithConstraints(modifier = modifier) {
Canvas(modifier = Modifier.height(maxHeight)) {
val path = Path().apply {
moveTo(size.width / 2, 0f)
lineTo(size.width / 2, size.height)
}
drawPath(
path = path,
color = Color.Gray,
)
}
}
}
@Composable
fun DashedLine() {
Canvas(modifier = Modifier
.width(1.dp) // 控制线条的宽度
.fillMaxHeight()) { // 填满父容器的高度
val canvasWidth = size.width
val canvasHeight = size.height
// 创建一个PathEffect来定义如何绘制线段
val pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
drawLine(
color = Color.Gray, // 线条颜色
start = Offset(x = canvasWidth / 2, y = 0f), // 起始点
end = Offset(x = canvasWidth / 2, y = canvasHeight), // 终点
pathEffect = pathEffect // 应用虚线效果
)
}
}
@Preview
@Composable
fun TimelineItem() {
val itemsList = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9")
Box(
modifier = Modifier
.padding(16.dp)
.fillMaxSize()
.wrapContentWidth()
) {
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
) {
Column(
modifier = Modifier
.width(64.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text("12", fontSize = 22.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)
Text("7月", fontSize = 20.sp,fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)
// add vertical dash line
// Box(
// modifier = Modifier
// .height(120.dp)
// .width(3.dp)
// .background(Color.Gray)
// )
DashedLine()
}
Column {
Row(
modifier = Modifier
.padding(bottom = 16.dp)
) {
Image(
painter = painterResource(id = R.drawable.default_avatar),
contentDescription = "",
modifier = Modifier
.padding(end = 16.dp)
.size(24.dp)
.clip(CircleShape) // Clip the image to a circle
)
Text("Onyama Limba")
}
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
) {
Column {
repeat(3) { // Create three rows
Row(modifier = Modifier.weight(1f)) {
repeat(3) { // Create three columns in each row
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(1f) // Keep the aspect ratio 1:1 for square shape
.padding(4.dp)
){
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Gray)
) {
Text("1")
}
}
}
}
}
}
}
}
}
}
}
@Composable
fun GalleryPosition() {
val mockList = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
Box(
modifier = Modifier
.fillMaxSize()
) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
) {
items(mockList) { item ->
TimelineItem()
}
}
}
}

View File

@@ -0,0 +1,201 @@
package com.aiosman.ravenow.ui.gallery
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
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.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
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.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder
@Preview
@Composable
fun OfficialGalleryScreen() {
StatusBarMaskLayout {
Column(
modifier = Modifier
.fillMaxSize()
.padding(start = 16.dp, end = 16.dp)
) {
OfficialGalleryPageHeader()
Spacer(modifier = Modifier.height(16.dp))
ImageGrid()
}
}
}
@Composable
fun OfficialGalleryPageHeader() {
Row(
modifier = Modifier
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.rider_pro_back_icon), // Replace with your logo resource
contentDescription = "Logo",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "官方摄影师作品", fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
}
@Composable
fun CertificationSection() {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color(0xFFFFF3CD), RoundedCornerShape(8.dp))
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.ic_launcher_foreground), // Replace with your certification icon resource
contentDescription = "Certification Icon",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "成为认证摄影师", fontWeight = FontWeight.Bold, fontSize = 16.sp)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = { /*TODO*/ },
) {
Text(text = "去认证", color = Color.White)
}
}
}
@Composable
fun ImageGrid() {
val photographers = listOf(
Pair(
"Diego Morata",
R.drawable.rider_pro_moment_demo_1
), // Replace with your image resources
Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2),
Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3),
Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1),
Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2),
Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3),
Pair(
"Diego Morata",
R.drawable.rider_pro_moment_demo_1
), // Replace with your image resources
Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2),
Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3),
Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1),
Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2),
Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3),
Pair(
"Diego Morata",
R.drawable.rider_pro_moment_demo_1
), // Replace with your image resources
Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2),
Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3),
Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1),
Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2),
Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3),
Pair(
"Diego Morata",
R.drawable.rider_pro_moment_demo_1
), // Replace with your image resources
Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2),
Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3),
Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1),
Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2),
Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3)
)
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(photographers.size) { index ->
PhotographerCard(photographers[index].first, photographers[index].second)
}
item{
BottomNavigationPlaceholder()
}
}
}
@Composable
fun PhotographerCard(name: String, imageRes: Int) {
val navController = LocalNavController.current
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(8.dp))
.background(Color.LightGray)
) {
Image(
painter = painterResource(id = imageRes),
contentDescription = name,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxWidth()
.height(270.dp)
)
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0x55000000))
.align(Alignment.BottomStart)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
.clickable {
navController.navigate("OfficialPhotographer")
},
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.default_avatar), // Replace with your profile picture resource
contentDescription = "Profile Picture",
modifier = Modifier
.size(24.dp)
.clip(CircleShape)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = name, color = Color.White)
}
}
}
}

View File

@@ -0,0 +1,294 @@
package com.aiosman.ravenow.ui.gallery
import android.util.Log
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.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
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 com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder
data class ArtWork(
val id: Int,
val resId: Int,
)
fun GenerateMockArtWorks(): List<ArtWork> {
val pickupImage = listOf(
R.drawable.default_avatar,
R.drawable.default_moment_img,
R.drawable.rider_pro_moment_demo_1,
R.drawable.rider_pro_moment_demo_2,
R.drawable.rider_pro_moment_demo_3,
)
return List(30) {
ArtWork(
id = it,
resId = pickupImage.random()
)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Preview
@Composable
fun OfficialPhotographerScreen() {
val lazyListState = rememberLazyListState()
var artWorks by remember { mutableStateOf<List<ArtWork>>(emptyList()) }
LaunchedEffect(Unit) {
artWorks = GenerateMockArtWorks()
}
// Observe the scroll state and calculate opacity
val alpha by remember {
derivedStateOf {
// Example calculation: Adjust the range and formula as needed
val alp = minOf(1f, lazyListState.firstVisibleItemScrollOffset / 900f)
Log.d("alpha", "alpha: $alp")
alp
}
}
StatusBarMaskLayout(
maskBoxBackgroundColor = Color.Black,
darkIcons = false
) {
Column {
Box(
modifier = Modifier
.background(Color.Black)
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = lazyListState
) {
item {
Box(
modifier = Modifier
.height(400.dp)
.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.drawable.default_moment_img),
contentDescription = "Logo",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
// dark alpha overlay
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = alpha))
)
// on bottom of box
Box(
modifier = Modifier
.fillMaxWidth()
.height(120.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Black.copy(alpha = 1f),
Color.Black.copy(alpha = 1f),
Color.Black.copy(alpha = 0f),
),
startY = Float.POSITIVE_INFINITY,
endY = 0f
)
)
.padding(16.dp)
.align(alignment = Alignment.BottomCenter)
) {
Column(
modifier = Modifier.fillMaxSize(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.default_avatar),
contentDescription = "",
modifier = Modifier
.size(32.dp)
.clip(CircleShape) // Clip the image to a circle
)
Spacer(modifier = Modifier.width(8.dp))
// name
Text("Onyama Limba", color = Color.White, fontSize = 14.sp)
Spacer(modifier = Modifier.width(8.dp))
// round box
Box(
modifier = Modifier
.background(Color.Red, CircleShape)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
// certification
Text("摄影师", color = Color.White, fontSize = 12.sp)
}
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = { /*TODO*/ },
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Filled.Favorite,
contentDescription = null,
tint = Color.White
)
}
Spacer(modifier = Modifier.width(4.dp))
Text("123", color = Color.White)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = {},
modifier = Modifier.size(32.dp)
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_eye),
contentDescription = "",
modifier = Modifier
.size(24.dp)
)
}
Spacer(modifier = Modifier.width(4.dp))
Text("123", color = Color.White)
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) {
// description
Text(
"摄影师 Diego Morata 的作品",
color = Color.White,
modifier = Modifier.align(Alignment.Center)
)
}
}
// circle avatar
}
}
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
val imageSize =
(screenWidth - (4.dp * 4)) / 3 // Subtracting total padding and divi
val itemWidth = screenWidth / 3 - 4.dp * 2
FlowRow(
modifier = Modifier.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
maxItemsInEachRow = 3
) {
for (artWork in artWorks) {
Box(
modifier = Modifier
.width(itemWidth)
.aspectRatio(1f)
.background(Color.Gray)
) {
Image(
painter = painterResource(id = artWork.resId),
contentDescription = "",
contentScale = ContentScale.Crop,
modifier = Modifier
.width(imageSize)
.aspectRatio(1f)
)
}
}
BottomNavigationPlaceholder()
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(64.dp)
.background(Color.Black.copy(alpha = alpha))
.padding(horizontal = 16.dp)
) {
Row(
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
colorFilter = ColorFilter.tint(Color.White),
contentDescription = "",
modifier = Modifier
.size(32.dp)
.clip(CircleShape) // Clip the image to a circle
)
if (alpha == 1f) {
Spacer(modifier = Modifier.width(8.dp))
Image(
painter = painterResource(id = R.drawable.default_avatar),
contentDescription = "",
modifier = Modifier
.size(32.dp)
.clip(CircleShape) // Clip the image to a circle
)
Spacer(modifier = Modifier.width(8.dp))
Text("Onyama Limba", color = Color.White)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,14 @@
package com.aiosman.ravenow.ui.imageviewer
import androidx.lifecycle.ViewModel
import com.aiosman.ravenow.entity.MomentImageEntity
object ImageViewerViewModel:ViewModel() {
var imageList = mutableListOf<MomentImageEntity>()
var initialIndex = 0
fun asNew(images: List<MomentImageEntity>, index: Int = 0) {
imageList.clear()
imageList.addAll(images)
initialIndex = index
}
}

View File

@@ -0,0 +1,203 @@
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.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.CircularProgressIndicator
import androidx.compose.material.Icon
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
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.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.imageviewer.ImageViewerViewModel
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.utils.FileUtil.saveImageToGallery
import kotlinx.coroutines.launch
import net.engawapg.lib.zoomable.rememberZoomState
import net.engawapg.lib.zoomable.zoomable
@OptIn(
ExperimentalFoundationApi::class,
)
@Composable
fun ImageViewer() {
val model = ImageViewerViewModel
val images = model.imageList
val pagerState =
rememberPagerState(pageCount = { images.size }, initialPage = model.initialIndex)
val navController = LocalNavController.current
val context = LocalContext.current
val navigationBarPaddings =
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp
val scope = rememberCoroutineScope()
val showRawImageStates = remember { mutableStateListOf(*Array(images.size) { false }) }
var isDownloading by remember { mutableStateOf(false) }
var currentPage by remember { mutableStateOf(model.initialIndex) }
LaunchedEffect(pagerState) {
currentPage = pagerState.currentPage
}
StatusBarMaskLayout(
modifier = Modifier.background(Color.Black),
) {
Column(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
horizontalAlignment = Alignment.CenterHorizontally
) {
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxWidth()
.weight(0.8f),
) { page ->
val zoomState = rememberZoomState()
CustomAsyncImage(
context,
if (showRawImageStates[page]) images[page].url else images[page].thumbnail,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.zoomable(
zoomState = zoomState,
onTap = {
navController.navigateUp()
}
)
,
contentScale = ContentScale.Fit,
)
}
Box(modifier = Modifier.padding(top = 10.dp, bottom = 10.dp)){
if (images.size > 1) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(Color(0xff333333).copy(alpha = 0.6f))
.padding(vertical = 4.dp, horizontal = 24.dp)
) {
Text(
text = "${pagerState.currentPage + 1}/${images.size}",
color = Color.White,
)
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.weight(0.2f)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black
),
)
)
.padding(start = 16.dp, end = 16.dp, bottom = navigationBarPaddings),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 72.dp, end = 72.dp)
.padding(top = 16.dp),
horizontalArrangement = Arrangement.Center
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.noRippleClickable {
if (isDownloading) {
return@noRippleClickable
}
isDownloading = true
scope.launch {
saveImageToGallery(context, images[pagerState.currentPage].url)
isDownloading = false
}
}
) {
if (isDownloading) {
CircularProgressIndicator(
modifier = Modifier.size(32.dp),
color = Color.White
)
} else {
Icon(
painter = painterResource(id = R.drawable.rider_pro_download_icon),
contentDescription = "",
modifier = Modifier.size(32.dp),
tint = Color.White
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
stringResource(R.string.download),
color = Color.White
)
}
if (!showRawImageStates[pagerState.currentPage]) {
Spacer(modifier = Modifier.weight(1f))
}
if (!showRawImageStates[pagerState.currentPage]) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.noRippleClickable {
showRawImageStates[pagerState.currentPage] = true
}
) {
Icon(
painter = painterResource(id = R.drawable.rider_pro_original_raw),
contentDescription = "",
modifier = Modifier.size(32.dp),
tint = Color.White
)
Spacer(modifier = Modifier.height(4.dp))
Text(
stringResource(R.string.original),
color = Color.White
)
}
}
}
}
}
}
}

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
)
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,58 @@
package com.aiosman.ravenow.ui.like
import android.icu.util.Calendar
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.entity.AccountLikeEntity
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.entity.LikeItemPagingSource
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.UpdateNoticeRequestBody
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object LikeNoticeViewModel : ViewModel() {
private val accountService: AccountService = AccountServiceImpl()
private val _likeItemsFlow = MutableStateFlow<PagingData<AccountLikeEntity>>(PagingData.empty())
val likeItemsFlow = _likeItemsFlow.asStateFlow()
var isFirstLoad = true
fun reload(force: Boolean = false) {
if (!isFirstLoad && !force) {
return
}
isFirstLoad = false
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
LikeItemPagingSource(
accountService
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_likeItemsFlow.value = it
}
}
}
suspend fun updateNotice() {
var now = Calendar.getInstance().time
accountService.updateNotice(
UpdateNoticeRequestBody(
lastLookLikeTime = ApiClient.formatTime(now)
)
)
}
fun ResetModel() {
isFirstLoad = true
}
}

View File

@@ -0,0 +1,338 @@
package com.aiosman.ravenow.ui.like
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.layout.ContentScale
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.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.AccountLikeEntity
import com.aiosman.ravenow.exp.timeAgo
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
import java.util.Date
@Preview
@Composable
fun LikeNoticeScreen() {
val model = LikeNoticeViewModel
val listState = rememberLazyListState()
var dataFlow = model.likeItemsFlow
var likes = dataFlow.collectAsLazyPagingItems()
val AppColors = LocalAppTheme.current
LaunchedEffect(Unit) {
model.reload()
model.updateNotice()
}
StatusBarMaskLayout(
darkIcons = !AppState.darkMode,
maskBoxBackgroundColor = AppColors.background
) {
Column(
modifier = Modifier
.weight(1f)
.background(color = AppColors.background)
.padding(horizontal = 16.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
NoticeScreenHeader(
stringResource(R.string.like_upper),
moreIcon = false
)
}
// Spacer(modifier = Modifier.height(28.dp))
if (likes.itemCount == 0) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painter = painterResource(id = R.mipmap.rider_pro_like_empty),
contentDescription = "No Notice",
modifier = Modifier.size(140.dp)
)
Spacer(modifier = Modifier.height(32.dp))
Text(
text = "Your like notification box is feeling lonely",
color = AppColors.text,
fontSize = 16.sp,
fontWeight = FontWeight.W500,
)
}
}
}else{
LazyColumn(
modifier = Modifier.weight(1f),
state = listState,
) {
items(likes.itemCount) {
val likeItem = likes[it]
if (likeItem != null) {
likeItem.post?.let { post ->
ActionPostNoticeItem(
avatar = likeItem.user.avatar,
nickName = likeItem.user.nickName,
likeTime = likeItem.likeTime,
thumbnail = post.images[0].thumbnail,
action = "like",
userId = likeItem.user.id,
postId = post.id
)
}
likeItem.comment?.let { comment ->
LikeCommentNoticeItem(likeItem)
}
}
}
item {
BottomNavigationPlaceholder()
}
}
}
}
}
}
@Composable
fun ActionPostNoticeItem(
avatar: String,
nickName: String,
likeTime: Date,
thumbnail: String,
action: String,
userId: Int,
postId: Int
) {
val context = LocalContext.current
val navController = LocalNavController.current
val AppColors = LocalAppTheme.current
Box(
modifier = Modifier.padding(vertical = 16.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
CustomAsyncImage(
context,
imageUrl = avatar,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
userId.toString()
)
)
},
contentDescription = action,
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier
.weight(1f)
.noRippleClickable {
navController.navigateToPost(
id = postId,
highlightCommentId = 0,
initImagePagerIndex = 0
)
}
) {
Text(nickName, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = AppColors.text)
Spacer(modifier = Modifier.height(2.dp))
when (action) {
"like" -> Text(stringResource(R.string.like_your_post), color = AppColors.text)
"favourite" -> Text(stringResource(R.string.favourite_your_post), color = AppColors.text)
}
Spacer(modifier = Modifier.height(2.dp))
Row {
Text(likeTime.timeAgo(context), fontSize = 12.sp, color = AppColors.secondaryText)
}
}
CustomAsyncImage(
context,
imageUrl = thumbnail,
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp)),
contentDescription = action,
)
}
}
}
@Composable
fun LikeCommentNoticeItem(
item: AccountLikeEntity
) {
val navController = LocalNavController.current
val context = LocalContext.current
val AppColors = LocalAppTheme.current
Box(
modifier = Modifier
.padding(vertical = 16.dp)
.noRippleClickable {
item.comment?.postId.let {
navController.navigateToPost(
id = it ?: 0,
highlightCommentId = item.comment?.id ?: 0,
initImagePagerIndex = 0
)
}
}
) {
Row {
Column(
modifier = Modifier.weight(1f)
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top,
) {
CustomAsyncImage(
imageUrl = item.user.avatar,
modifier = Modifier
.size(48.dp)
.clip(CircleShape),
contentDescription = stringResource(R.string.like_your_comment)
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier
.weight(1f)
) {
Text(item.user.nickName, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = AppColors.text)
Spacer(modifier = Modifier.height(2.dp))
Text(stringResource(R.string.like_your_comment), color = AppColors.text)
Spacer(modifier = Modifier.height(2.dp))
Row {
Text(
item.likeTime.timeAgo(context),
fontSize = 12.sp,
color = AppColors.secondaryText
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.padding(start = 48.dp)
) {
Box(
modifier = Modifier
.size(24.dp)
.background(Color.Gray.copy(alpha = 0.1f))
) {
CustomAsyncImage(
context = context,
imageUrl = MyProfileViewModel.avatar,
contentDescription = "Comment Profile Picture",
modifier = Modifier
.size(24.dp)
.clip(RoundedCornerShape(24.dp)),
contentScale = ContentScale.Crop
)
}
Spacer(modifier = Modifier.width(8.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = MyProfileViewModel.nickName,
fontWeight = FontWeight.W600,
fontSize = 14.sp,
color = AppColors.text
)
Text(
text = item.comment?.content ?: "",
fontSize = 12.sp,
color = AppColors.secondaryText,
maxLines = 2
)
}
}
}
Spacer(modifier = Modifier.width(16.dp))
if (item.comment?.replyComment?.post != null) {
item.comment.replyComment.post.let {
CustomAsyncImage(
context = context,
imageUrl = it.images[0].thumbnail,
contentDescription = "Post Thumbnail",
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}
} else {
item.comment?.post?.let {
CustomAsyncImage(
context = context,
imageUrl = it.images[0].thumbnail,
contentDescription = "Post Thumbnail",
modifier = Modifier
.size(48.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
}
}
}
}
}

View File

@@ -0,0 +1,456 @@
package com.aiosman.ravenow.ui.location
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.ScrollableDefaults
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
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.staggeredgrid.LazyVerticalStaggeredGrid
import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells
import androidx.compose.foundation.lazy.staggeredgrid.items
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.LocationOn
import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberBottomSheetScaffoldState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
data class OfficialGalleryItem(
val id: Int,
val resId: Int,
)
fun getOfficialGalleryItems(): List<OfficialGalleryItem> {
return listOf(
OfficialGalleryItem(1, R.drawable.default_moment_img),
OfficialGalleryItem(2, R.drawable.default_moment_img),
OfficialGalleryItem(3, R.drawable.default_moment_img),
OfficialGalleryItem(4, R.drawable.default_moment_img),
OfficialGalleryItem(5, R.drawable.default_moment_img),
OfficialGalleryItem(6, R.drawable.default_moment_img),
OfficialGalleryItem(7, R.drawable.default_moment_img),
OfficialGalleryItem(8, R.drawable.default_moment_img),
OfficialGalleryItem(9, R.drawable.default_moment_img),
OfficialGalleryItem(10, R.drawable.default_moment_img),
OfficialGalleryItem(11, R.drawable.default_moment_img),
OfficialGalleryItem(12, R.drawable.default_moment_img),
OfficialGalleryItem(13, R.drawable.default_moment_img),
OfficialGalleryItem(14, R.drawable.default_moment_img),
OfficialGalleryItem(15, R.drawable.default_moment_img),
OfficialGalleryItem(16, R.drawable.default_moment_img),
OfficialGalleryItem(17, R.drawable.default_moment_img),
OfficialGalleryItem(18, R.drawable.default_moment_img),
OfficialGalleryItem(19, R.drawable.default_moment_img),
OfficialGalleryItem(20, R.drawable.default_moment_img),
)
}
data class FeedItem(
val id: Int,
val resId: Int,
)
fun getFeedItems(): List<FeedItem> {
val image_pickups = listOf(
R.drawable.default_moment_img,
R.drawable.default_avatar,
R.drawable.rider_pro_moment_demo_1,
R.drawable.rider_pro_moment_demo_2,
R.drawable.rider_pro_moment_demo_3,
)
return (1..100).map {
FeedItem(it, image_pickups.random())
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun LocationDetailScreen(x: Float, y: Float) {
val scope = rememberCoroutineScope()
val scaffoldState = rememberBottomSheetScaffoldState(
SheetState(
skipPartiallyExpanded = false,
density = LocalDensity.current, initialValue = SheetValue.PartiallyExpanded,
skipHiddenState = true
)
)
val configuration = LocalConfiguration.current
val officialGalleryItems = getOfficialGalleryItems()
val feedItems = getFeedItems()
val navController = LocalNavController.current
// 2/3 height of the screen
fun getPeekHeight(): Dp {
val screenHeight = configuration.screenHeightDp
val peekHeight = (screenHeight * 2 / 3).dp
return peekHeight
}
fun getNoPeekHeight(): Dp {
val screenHeight = configuration.screenHeightDp
val peekHeight = (screenHeight * 1 / 3).dp
return peekHeight
}
val view = LocalView.current
// LaunchedEffect(key1 = Unit) {
// val locationOnScreen = IntArray(2).apply {
// view.getLocationOnScreen(this)
// }
// val startX = x - locationOnScreen[0]
// val startY = y - locationOnScreen[1]
// val radius = hypot(view.width.toDouble(), view.height.toDouble()).toFloat()
//
// val anim = ViewAnimationUtils.createCircularReveal(view, startX.toInt(), startY.toInt(), 0f, radius).apply {
// duration = 600
// start()
// }
//
// }
val staggeredGridState = rememberLazyStaggeredGridState()
val coroutineScope = rememberCoroutineScope()
var showGalleryAndInfo by remember { mutableStateOf(true) }
LaunchedEffect(staggeredGridState) {
snapshotFlow { staggeredGridState.firstVisibleItemIndex }
.collect { index ->
// Assuming index 0 corresponds to the top of the feed
showGalleryAndInfo = index == 0
}
}
Box(
modifier = Modifier.fillMaxSize().background(Color.Transparent)
) {
Image(
painter = painterResource(id = R.drawable.default_moment_img),
contentDescription = "Location Image",
modifier = Modifier
.fillMaxWidth()
.height(getNoPeekHeight() + 100.dp),
contentScale = ContentScale.Crop
)
val bottomSheetScaffoldState = rememberBottomSheetScaffoldState()
BottomSheetScaffold(
scaffoldState = scaffoldState,
sheetPeekHeight = getPeekHeight(),
sheetShadowElevation = 0.dp,
sheetContainerColor = Color.Transparent,
sheetShape = RoundedCornerShape(16.dp, 16.dp, 0.dp, 0.dp),
sheetDragHandle = null,
sheetContent = {
Column(
Modifier
.fillMaxWidth()
.background(color = Color.White)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
Spacer(modifier = Modifier.height(12.dp))
// 自定义短线
Box(
modifier = Modifier
.width(32.dp) // 修改宽度
.height(4.dp) // 修改高度
.background(
Color(0f, 0f, 0f, 0.4f),
RoundedCornerShape(3.dp)
) // 修改颜色和圆角
.padding(top = 12.dp) // 调整位置
.align(Alignment.CenterHorizontally)
)
}
Spacer(modifier = Modifier.height(16.dp))
GalleryAndInfo(showGalleryAndInfo)
// feed
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 16.dp)
.animateContentSize()
) {
AnimatedVisibility(visible = !showGalleryAndInfo) {
Row {
Icon(
Icons.Filled.LocationOn,
contentDescription = "Location",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("在云龟山景区的", fontSize = 16.sp)
}
}
Text(
"车友动态",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.weight(1f)
)
}
LazyVerticalStaggeredGrid(
columns = StaggeredGridCells.Fixed(2), // Set to 2 columns
modifier = Modifier.fillMaxSize(),
state = staggeredGridState,
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
flingBehavior = ScrollableDefaults.flingBehavior(),
) {
items(feedItems) { item ->
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
Image(
painter = painterResource(id = item.resId),
contentDescription = "Feed",
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.medium)
.clickable {
navController.navigate("Post")
},
contentScale = ContentScale.FillWidth
)
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp)
) {
Box(
modifier = Modifier.fillMaxWidth()
) {
Text("Some text")
}
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Image(
painter = painterResource(id = R.drawable.default_avatar),
contentDescription = "Avatar",
modifier = Modifier
.size(18.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
Spacer(modifier = Modifier.width(8.dp))
Text(
"Username",
color = Color(0xFF666666),
fontSize = 14.sp
)
Spacer(modifier = Modifier.weight(1f))
Icon(
Icons.Filled.Favorite,
contentDescription = "Location"
)
Spacer(modifier = Modifier.width(4.dp))
Text("100K", fontSize = 14.sp)
}
}
}
}
}
}
},
) {
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun GalleryAndInfo(showGalleryAndInfo: Boolean) {
val navController = LocalNavController.current
Column(modifier = Modifier.animateContentSize()) {
AnimatedVisibility(visible = showGalleryAndInfo) {
Column {
// info panel
Column(
modifier = Modifier.padding(horizontal = 16.dp)
) {
Text(
"Location Name",
modifier = Modifier.padding(top = 24.dp),
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
repeat(10) {
Box(
modifier = Modifier
.background(Color(0xFFF5F5F5))
.padding(horizontal = 7.dp, vertical = 2.dp)
) {
Text("Tag $it", color = Color(0xFFb2b2b2), fontSize = 12.sp)
}
}
}
HorizontalDivider(
modifier = Modifier.padding(top = 16.dp),
color = Color(0xFFF5F5F5)
)
Row(
modifier = Modifier.padding(vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
"Location name",
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text("距离46KM,骑行时间77分钟", fontSize = 12.sp)
}
Spacer(modifier = Modifier.weight(1f))
Image(
painter = painterResource(id = R.drawable.rider_pro_location_map),
contentDescription = ""
)
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.background(Color(0xFFF5F5F5))
)
// official gallery
Column(
modifier = Modifier
.padding(horizontal = 16.dp)
.padding(top = 18.dp)
) {
Row {
Text("官方摄影师作品", fontSize = 15.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.weight(1f))
Image(
painter = painterResource(id = R.drawable.rider_pro_nav_next),
contentDescription = "Next",
modifier = Modifier
.size(24.dp)
.clickable {
navController.navigate("OfficialPhoto")
}
)
}
Spacer(modifier = Modifier.height(17.dp))
Row(
modifier = Modifier.height(232.dp)
) {
Box(
modifier = Modifier.weight(1f)
) {
Image(
painter = painterResource(id = R.drawable.default_avatar),
contentDescription = "Avatar",
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.Crop,
)
}
Spacer(modifier = Modifier.width(16.dp))
Box(
modifier = Modifier.weight(1f)
) {
Image(
painter = painterResource(id = R.drawable.default_avatar),
contentDescription = "Avatar",
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.Crop
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.height(8.dp)
.background(Color(0xFFF5F5F5))
)
}
}
}
}

View File

@@ -0,0 +1,278 @@
package com.aiosman.ravenow.ui.login
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.width
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.api.getErrorMessageCode
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.composables.CheckboxWithLabel
import com.aiosman.ravenow.ui.composables.PolicyCheckbox
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.TextInputField
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun EmailSignupScreen() {
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var confirmPassword by remember { mutableStateOf("") }
var rememberMe by remember { mutableStateOf(false) }
var acceptTerms by remember { mutableStateOf(false) }
var acceptPromotions by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val context = LocalContext.current
val accountService: AccountService = AccountServiceImpl()
var emailError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(null) }
var confirmPasswordError by remember { mutableStateOf<String?>(null) }
var termsError by remember { mutableStateOf<Boolean>(false) }
var promotionsError by remember { mutableStateOf<Boolean>(false) }
fun validateForm(): Boolean {
emailError = when {
// 非空
email.isEmpty() -> context.getString(R.string.text_error_email_required)
// 邮箱格式
!android.util.Patterns.EMAIL_ADDRESS.matcher(email)
.matches() -> context.getString(R.string.text_error_email_format)
else -> null
}
passwordError = when {
// 非空
password.isEmpty() -> context.getString(R.string.text_error_password_required)
// 包含大写字母
!password.matches(Regex(".*[A-Z].*")) -> context.getString(R.string.text_error_password_format)
// 至少8位
password.length < 8 -> context.getString(R.string.text_error_password_format)
// 至少一个数字
!password.matches(Regex(".*\\d.*")) -> context.getString(R.string.text_error_password_format)
// 包含小写字母
!password.matches(Regex(".*[a-z].*")) -> context.getString(R.string.text_error_password_format)
else -> null
}
confirmPasswordError = when {
// 非空
confirmPassword.isEmpty() -> context.getString(R.string.text_error_confirm_password_required)
// 与密码一致
confirmPassword != password -> context.getString(R.string.text_error_confirm_password_mismatch)
else -> null
}
if (!acceptTerms) {
scope.launch(Dispatchers.Main) {
Toast.makeText(
context,
context.getString(R.string.error_not_accept_term),
Toast.LENGTH_SHORT
).show()
}
termsError = true
return false
} else {
termsError = false
}
if (!acceptPromotions) {
scope.launch(Dispatchers.Main) {
Toast.makeText(
context,
context.getString(R.string.error_not_accept_recive_notice),
Toast.LENGTH_SHORT
).show()
}
promotionsError = true
return false
} else {
promotionsError = false
}
return emailError == null && passwordError == null && confirmPasswordError == null
}
suspend fun registerUser() {
if (!validateForm()) return
// 注册
try {
accountService.registerUserWithPassword(email, password)
} catch (e: ServiceException) {
scope.launch(Dispatchers.Main) {
if (e.code == ErrorCode.USER_EXIST.code) {
emailError = context.getString(R.string.error_10001_user_exist)
return@launch
}
Toast.makeText(context, context.getErrorMessageCode(e.code), Toast.LENGTH_SHORT)
.show()
}
return
} catch (e: Exception) {
scope.launch(Dispatchers.Main) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
return
}
// 获取 token
val authResp = accountService.loginUserWithPassword(email, password)
if (authResp.token != null) {
scope.launch(Dispatchers.Main) {
Toast.makeText(context, "Successfully registered", Toast.LENGTH_SHORT).show()
}
}
AppStore.apply {
token = authResp.token
this.rememberMe = rememberMe
saveData()
}
// 获取token 信息
try {
AppState.initWithAccount(scope, context)
} catch (e: ServiceException) {
scope.launch(Dispatchers.Main) {
Toast.makeText(context, "Failed to get account", Toast.LENGTH_SHORT).show()
}
}
scope.launch(Dispatchers.Main) {
navController.navigate(NavigationRoute.Index.route) {
popUpTo(NavigationRoute.Login.route) { inclusive = true }
}
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.background(Color.White)
) {
StatusBarSpacer()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
) {
NoticeScreenHeader(stringResource(R.string.sign_up_upper), moreIcon = false)
}
Spacer(modifier = Modifier.padding(32.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 24.dp)
) {
TextInputField(
modifier = Modifier
.fillMaxWidth(),
text = email,
onValueChange = {
email = it
},
hint = stringResource(R.string.text_hint_email),
error = emailError
)
Spacer(modifier = Modifier.padding(4.dp))
TextInputField(
modifier = Modifier
.fillMaxWidth(),
text = password,
onValueChange = {
password = it
},
password = true,
hint = stringResource(R.string.text_hint_password),
error = passwordError
)
Spacer(modifier = Modifier.padding(4.dp))
TextInputField(
modifier = Modifier
.fillMaxWidth(),
text = confirmPassword,
onValueChange = {
confirmPassword = it
},
password = true,
hint = stringResource(R.string.text_hint_confirm_password),
error = confirmPasswordError
)
Spacer(modifier = Modifier.height(8.dp))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start,
) {
CheckboxWithLabel(
checked = rememberMe,
checkSize = 16,
fontSize = 12,
label = stringResource(R.string.remember_me),
) {
rememberMe = it
}
Spacer(modifier = Modifier.height(16.dp))
PolicyCheckbox(
checked = acceptTerms,
error = termsError
) {
acceptTerms = it
}
Spacer(modifier = Modifier.height(16.dp))
CheckboxWithLabel(
checked = acceptPromotions,
checkSize = 16,
fontSize = 12,
label = stringResource(R.string.agree_promotion),
error = promotionsError
) {
acceptPromotions = it
}
}
Spacer(modifier = Modifier.height(32.dp))
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
ActionButton(
modifier = Modifier
.width(345.dp),
text = stringResource(R.string.lets_ride_upper),
backgroundColor = Color(0xffda3832),
color = Color.White
) {
scope.launch(Dispatchers.IO) {
registerUser()
}
}
}
}
}
}

View File

@@ -0,0 +1,476 @@
package com.aiosman.ravenow.ui.login
import android.content.ContentValues.TAG
import android.content.res.Resources
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.rememberScrollableState
import androidx.compose.foundation.gestures.scrollable
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.fillMaxHeight
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.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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
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.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
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.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.api.getErrorMessageCode
import com.aiosman.ravenow.data.api.showToast
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.utils.GoogleLogin
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun LoginPage() {
val navController = LocalNavController.current
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val accountService = AccountServiceImpl()
val statusBarController = rememberSystemUiController()
val AppColors = LocalAppTheme.current
LaunchedEffect(Unit) {
statusBarController.setStatusBarColor(Color.Transparent, darkIcons = !AppState.darkMode)
}
fun googleLogin() {
coroutineScope.launch {
try {
GoogleLogin(context) {
coroutineScope.launch {
try {
accountService.regiterUserWithGoogleAccount(it)
}catch (e : ServiceException) {
when (e.errorType) {
ErrorCode.USER_EXIST ->
Toast.makeText(
context,
context.getErrorMessageCode(e.errorType.code),
Toast.LENGTH_SHORT
).show()
else -> {
e.errorType.showToast(context)
}
}
Log.e(TAG, "Failed to register with google", e)
return@launch
} catch (e: Exception) {
Log.e(TAG, "Failed to register with google", e)
return@launch
}
// 获取用户信息
// 获取 token
val authResp = accountService.loginUserWithGoogle(it)
if (authResp.token != null) {
coroutineScope.launch(Dispatchers.Main) {
Toast.makeText(
context,
"Successfully registered",
Toast.LENGTH_SHORT
)
.show()
}
}
AppStore.apply {
token = authResp.token
this.rememberMe = true
saveData()
}
// 获取token 信息
try {
AppState.initWithAccount(coroutineScope, context)
} catch (e: Exception) {
Log.e(TAG, "Failed to init with account", e)
} catch (e: ServiceException) {
coroutineScope.launch(Dispatchers.Main) {
Toast.makeText(context, "Failed to get account", Toast.LENGTH_SHORT)
.show()
}
}
coroutineScope.launch(Dispatchers.Main) {
navController.navigate(NavigationRoute.Index.route) {
popUpTo(NavigationRoute.Login.route) { inclusive = true }
}
}
}
}
} catch (e: Exception) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
}
}
Box(
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
) {
Box(
modifier = Modifier
.fillMaxSize()
) {
val localContext = LocalContext.current // 获取 Context
MovingImageWall(localContext.resources) // 将 resources 传递给 MovingImageWall
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.weight(1f))
Image(
painter = painterResource(id = R.mipmap.rider_pro_color_logo_next),
contentDescription = "Rave Now",
modifier = Modifier
.size(52.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Rave Now",
fontSize = 28.sp,
fontWeight = FontWeight.W900,
color = AppColors.text
)
Spacer(modifier = Modifier.height(16.dp))
Text(
"Your Night Starts Here",
fontSize = 20.sp,
fontWeight = FontWeight.W600,
color = AppColors.text
)
Spacer(modifier = Modifier.height(8.dp))
ActionButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.sign_up_upper),
color = AppColors.mainText,
backgroundColor = AppColors.main
) {
navController.navigate(
NavigationRoute.EmailSignUp.route,
)
}
Spacer(modifier = Modifier.height(16.dp))
ActionButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.sign_in_with_google),
color = AppColors.text,
leading = {
Image(
painter = painterResource(id = R.drawable.rider_pro_google),
contentDescription = "Google",
modifier = Modifier.size(36.dp)
)
},
expandText = true,
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 8.dp)
) {
googleLogin()
}
Spacer(modifier = Modifier.height(24.dp))
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth()
) {
Text(
stringResource(R.string.login_upper),
fontSize = 17.sp,
fontWeight = FontWeight.W900,
color = AppColors.text,
modifier = Modifier.noRippleClickable {
navController.navigate(
NavigationRoute.UserAuth.route,
)
}
)
}
Spacer(modifier = Modifier.height(70.dp))
}
}
}
@Composable
fun MovingImageWall(resources: Resources) {
val AppColors = LocalAppTheme.current
val imageList1 = remember {
mutableStateListOf(
R.drawable.wall_1_1,
R.drawable.wall_1_2,
R.drawable.wall_1_3,
R.drawable.wall_1_1,
R.drawable.wall_1_2,
R.drawable.wall_1_3
)
}
val imageList2 = remember {
mutableStateListOf(
R.drawable.wall_2_1,
R.drawable.wall_2_2,
R.drawable.wall_2_3,
R.drawable.wall_2_1,
R.drawable.wall_2_2,
R.drawable.wall_2_3
)
}
val imageList3 = remember {
mutableStateListOf(
R.drawable.wall_3_1,
R.drawable.wall_3_2,
R.drawable.wall_3_3,
R.drawable.wall_3_1,
R.drawable.wall_3_2,
R.drawable.wall_3_3
)
}
val density = resources.displayMetrics.density // 获取屏幕密度
val imageHeight = 208.dp
val imageHeightPx = imageHeight.value * density // 将 dp 转换为像素
val resetThreshold = imageHeightPx
// 使用 remember 保存动画状态,并在应用停止时重置
// 每次 recomposition 时重置 offset
var offset1 by remember { mutableFloatStateOf(-resetThreshold * 3) }
var offset2 by remember { mutableFloatStateOf(0f) }
var offset3 by remember { mutableFloatStateOf(-resetThreshold * 3) }
val coroutineScope = rememberCoroutineScope()
// 使用 LaunchedEffect 在每次 recomposition 时启动动画
LaunchedEffect(Unit) {
coroutineScope.launch {
animateImageWall(imageList1, offset1, speed = 1f, resources = resources) {
offset1 = it
}
}
coroutineScope.launch {
animateImageWall(
imageList2,
offset2,
speed = 1.5f,
reverse = true,
resources = resources
) { offset2 = it }
}
coroutineScope.launch {
animateImageWall(imageList3, offset3, speed = 2f, resources = resources) {
offset3 = it
}
}
}
Box(
modifier = Modifier
.fillMaxWidth()
) {
val scrollState1 = rememberScrollState()
val scrollableState1 = rememberScrollableState { delta ->
// 消耗所有滚动事件,阻止用户滚动
delta
}
val scrollState2 = rememberScrollState()
val scrollableState2 = rememberScrollableState { delta ->
// 消耗所有滚动事件,阻止用户滚动
delta
}
val scrollState3 = rememberScrollState()
val scrollableState3 = rememberScrollableState { delta ->
// 消耗所有滚动事件,阻止用户滚动
delta
}
Row(
modifier = Modifier
.fillMaxWidth()
) {
// 第1列
ImageColumn(
imageList1, offset1,
Modifier
.verticalScroll(scrollState1)
.scrollable(
state = scrollableState1,
orientation = Orientation.Vertical
)
.weight(1f)
)
// 第2列
ImageColumn(
imageList2, offset2,
Modifier
.verticalScroll(scrollState2)
.scrollable(
state = scrollableState2,
orientation = Orientation.Vertical
)
.weight(1f), reverse = true
)
// 第3列
ImageColumn(
imageList3, offset3,
Modifier
.verticalScroll(scrollState3)
.scrollable(
state = scrollableState3,
orientation = Orientation.Vertical
)
.weight(1f)
)
}
// 白色叠加层
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.background(AppColors.background.copy(alpha = 0.3f)),
contentAlignment = Alignment.BottomCenter
) {
Box(
modifier = Modifier
.fillMaxWidth()
.height(500.dp)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
AppColors.background
),
startY = 0f,
endY = 500f
)
)
)
}
}
}
@Composable
fun ImageColumn(
imageList: List<Int>,
offset: Float,
modifier: Modifier,
reverse: Boolean = false
) {
val imageCount = imageList.size
val imageHeight = 208.dp
var currentImage by remember { mutableStateOf(0) }
val totalHeight = imageHeight.value * imageCount // 计算总高度
Column(modifier = modifier) {
for (i in 0 until imageCount) {
Box(modifier = Modifier
.width(156.dp)
.height(208.dp)
.scale(1f)
.graphicsLayer {
val translation = if (reverse) {
offset
} else {
offset
}
translationY = translation
}
.clip(RoundedCornerShape(16.dp)),
contentAlignment = Alignment.Center) {
Image(
painter = painterResource(id = imageList[(currentImage + i) % imageCount]),
contentDescription = "背景图片",
modifier = Modifier
.width(156.dp)
.height(208.dp)
.scale(0.9f)
.clip(RoundedCornerShape(16.dp)),
contentScale = ContentScale.Crop
)
}
}
}
}
suspend fun animateImageWall(
imageList: MutableList<Int>,
initialOffset: Float,
speed: Float, // speed 现在以像素为单位
reverse: Boolean = false,
resources: Resources, // 添加 resources 参数
onUpdate: (Float) -> Unit,
) {
val density = resources.displayMetrics.density // 获取屏幕密度
var currentOffset = initialOffset
val imageCount = imageList.size
val imageHeight = 208.dp
val imageHeightPx = imageHeight.value * density // 将 dp 转换为像素
val resetThreshold = imageHeightPx
Log.d(TAG, "speed: $speed")
Log.d(TAG, "resetThreshold: $resetThreshold")
while (true) {
onUpdate(currentOffset)
if (reverse) {
currentOffset -= speed
// Log.d(TAG, "currentOffset: $currentOffset")
if (currentOffset <= -resetThreshold * 3) { // 检查是否向上超出阈值
// 使用 imageHeightPx 进行计算
// 复位
currentOffset = initialOffset
}
} else {
currentOffset += speed
if (currentOffset >= 0) { // 检查是否向上超出阈值
Log.d(TAG, "currentOffset: $currentOffset")
// 使用 imageHeightPx 进行计算
// 复位
currentOffset = initialOffset
}
// 使用 imageHeightPx 进行计算
}
delay(16)
}
}

View File

@@ -0,0 +1,249 @@
package com.aiosman.ravenow.ui.login
import android.content.ContentValues.TAG
import android.util.Log
import android.widget.Toast
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.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.material3.Scaffold
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.graphics.Color
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.unit.dp
import androidx.compose.ui.unit.sp
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.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.utils.GoogleLogin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@Composable
fun SignupScreen() {
val navController = LocalNavController.current
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val accountService: AccountService = AccountServiceImpl()
val appColor = LocalAppTheme.current
fun googleLogin() {
coroutineScope.launch {
try {
GoogleLogin(context) {
coroutineScope.launch {
try {
accountService.regiterUserWithGoogleAccount(it)
} catch (e: Exception) {
Log.e(TAG, "Failed to register with google", e)
return@launch
}
// 获取用户信息
// 获取 token
val authResp = accountService.loginUserWithGoogle(it)
if (authResp.token != null) {
coroutineScope.launch(Dispatchers.Main) {
Toast.makeText(
context,
"Successfully registered",
Toast.LENGTH_SHORT
)
.show()
}
}
AppStore.apply {
token = authResp.token
this.rememberMe = true
saveData()
}
// 获取token 信息
try {
AppState.initWithAccount(coroutineScope, context)
} catch (e: Exception) {
Log.e(TAG, "Failed to init with account", e)
} catch (e: ServiceException) {
coroutineScope.launch(Dispatchers.Main) {
Toast.makeText(context, "Failed to get account", Toast.LENGTH_SHORT)
.show()
}
}
coroutineScope.launch(Dispatchers.Main) {
navController.navigate(NavigationRoute.Index.route) {
popUpTo(NavigationRoute.Login.route) { inclusive = true }
}
}
}
}
} catch (e: Exception) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
}
}
Scaffold(
modifier = Modifier
.fillMaxSize()
.background(appColor.background)
) {
it
Box(
modifier = Modifier.fillMaxSize()
) {
// to bottom
Box(
contentAlignment = Alignment.TopCenter,
modifier = Modifier.padding(top = 211.dp)
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.mipmap.rider_pro_logo),
contentDescription = "Rave Now",
modifier = Modifier
.width(108.dp)
.height(45.dp)
)
Spacer(modifier = Modifier.height(32.dp))
Text(
"Rave Now".uppercase(),
fontSize = 28.sp,
fontWeight = FontWeight.Bold
)
Text("Your Night Starts Here".uppercase(), fontSize = 20.sp, fontWeight = FontWeight.W600)
}
}
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 82.dp, start = 24.dp, end = 24.dp)
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ActionButton(
modifier = Modifier
.width(345.dp)
.height(48.dp),
text = stringResource(R.string.sign_in_with_email),
leading = {
Image(
painter = painterResource(id = R.mipmap.rider_pro_email),
contentDescription = "Email",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
) {
navController.navigate(NavigationRoute.EmailSignUp.route)
}
// Spacer(modifier = Modifier.height(16.dp))
// ActionButton(
// modifier = Modifier
// .width(345.dp)
// .height(48.dp),
// text = "CONTINUE WITH FACEBOOK".uppercase(),
// backgroundImage = R.mipmap.rider_pro_signup_facebook_bg,
// leading = {
// Image(
// painter = painterResource(id = R.mipmap.rider_pro_signup_facebook),
// contentDescription = "Facebook",
// modifier = Modifier.size(24.dp)
// )
// Spacer(modifier = Modifier.width(8.dp))
// }
// )
Spacer(modifier = Modifier.height(16.dp))
ActionButton(
modifier = Modifier
.width(345.dp)
.height(48.dp),
color = Color.Black,
leading = {
Image(
painter = painterResource(id = R.mipmap.rider_pro_signup_google),
contentDescription = "Google",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
},
text = stringResource(R.string.sign_in_with_google),
) {
googleLogin()
}
// Spacer(modifier = Modifier.height(16.dp))
// Box(
// modifier = Modifier
// .width(345.dp)
// .height(48.dp)
// .clip(RoundedCornerShape(8.dp))
// .background(Color.Black)
//
// ) {
// Text(
// "Sign in with Apple",
// color = Color.White,
// modifier = Modifier.align(Alignment.Center),
// fontSize = 16.sp,
// fontWeight = FontWeight.Bold
// )
// }
Spacer(modifier = Modifier.height(40.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.noRippleClickable {
navController.navigateUp()
}
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_back_icon),
contentDescription = "Back",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
stringResource(R.string.back_upper),
color = Color.Black,
fontSize = 16.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
}
}

View File

@@ -0,0 +1,321 @@
package com.aiosman.ravenow.ui.login
import android.widget.Toast
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.PaddingValues
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.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.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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.AppState
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.CaptchaService
import com.aiosman.ravenow.data.CaptchaServiceImpl
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.api.CaptchaInfo
import com.aiosman.ravenow.data.api.CaptchaResponseBody
import com.aiosman.ravenow.data.api.DotPosition
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.ActionButton
import com.aiosman.ravenow.ui.composables.ClickCaptchaDialog
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
import com.aiosman.ravenow.ui.composables.TextInputField
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.utils.GoogleLogin
import kotlinx.coroutines.launch
@Composable
fun UserAuthScreen() {
val AppColors = LocalAppTheme.current
var email by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var rememberMe by remember { mutableStateOf(false) }
val accountService: AccountService = AccountServiceImpl()
val captchaService: CaptchaService = CaptchaServiceImpl()
val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val context = LocalContext.current
var emailError by remember { mutableStateOf<String?>(null) }
var passwordError by remember { mutableStateOf<String?>(null) }
var captchaInfo by remember { mutableStateOf<CaptchaInfo?>(null) }
fun validateForm(): Boolean {
emailError =
if (email.isEmpty()) context.getString(R.string.text_error_email_required) else null
passwordError =
if (password.isEmpty()) context.getString(R.string.text_error_password_required) else null
return emailError == null && passwordError == null
}
var captchaData by remember { mutableStateOf<CaptchaResponseBody?>(null) }
fun loadLoginCaptcha() {
scope.launch {
try {
captchaData = captchaService.generateLoginCaptcha(email)
captchaData?.let {
captchaInfo = CaptchaInfo(
id = it.id,
dot = emptyList()
)
}
} catch (e: ServiceException) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
}
}
fun onLogin(captchaInfo: CaptchaInfo? = null) {
if (!validateForm()) {
return
}
scope.launch {
try {
// 检查是否需要验证码
if (captchaInfo == null && captchaService.checkLoginCaptcha(email)) {
loadLoginCaptcha()
return@launch
}
// 获取用户凭证
val authResp = accountService.loginUserWithPassword(email, password, captchaInfo)
if (authResp.token != null) {
AppStore.apply {
token = authResp.token
this.rememberMe = rememberMe
saveData()
}
AppState.initWithAccount(scope, context)
navController.navigate(NavigationRoute.Index.route) {
popUpTo(NavigationRoute.Login.route) { inclusive = true }
}
}
} catch (e: ServiceException) {
// handle error
when (e.code) {
12005 -> {
emailError = context.getString(R.string.error_invalidate_username_password)
passwordError = context.getString(R.string.error_invalidate_username_password)
}
ErrorCode.InvalidateCaptcha.code -> {
loadLoginCaptcha()
Toast.makeText(
context,
context.getString(R.string.incorrect_captcha_please_try_again),
Toast.LENGTH_SHORT
).show()
}
else -> {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
}
} catch (e: Exception) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
} finally {
captchaData = null
}
}
}
fun googleLogin() {
scope.launch {
try {
GoogleLogin(context) {
scope.launch {
try {
val authResp = accountService.loginUserWithGoogle(it)
if (authResp.token != null) {
AppStore.apply {
token = authResp.token
this.rememberMe = rememberMe
saveData()
}
navController.navigate(NavigationRoute.Index.route) {
popUpTo(NavigationRoute.Login.route) { inclusive = true }
}
}
} catch (e: ServiceException) {
// handle error
e.printStackTrace()
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
}
}
} catch (e: Exception) {
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
}
}
}
captchaData?.let {
ClickCaptchaDialog(
onDismissRequest = {
captchaData = null
},
captchaData = it,
onLoadCaptcha = {
loadLoginCaptcha()
},
onPositionClicked = { offset ->
captchaInfo?.let { info ->
val dots = info.dot.toMutableList()
val lastDotIndex = dots.size - 1
dots += DotPosition(
index = lastDotIndex + 1,
x = offset.x.toInt(),
y = offset.y.toInt()
)
captchaInfo = info.copy(dot = dots)
// 检查是否完成
if (dots.size == it.count) {
onLogin(captchaInfo)
}
}
},
)
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxSize()
.background(AppColors.background)
) {
StatusBarSpacer()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(top = 16.dp, start = 16.dp, end = 16.dp)
) {
NoticeScreenHeader(stringResource(R.string.login_upper), moreIcon = false)
}
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
StatusBarSpacer()
TextInputField(
modifier = Modifier
.fillMaxWidth(),
text = email,
onValueChange = {
email = it
},
hint = stringResource(R.string.text_hint_email),
error = emailError
)
Spacer(modifier = Modifier.padding(4.dp))
TextInputField(
modifier = Modifier
.fillMaxWidth(),
text = password,
onValueChange = {
password = it
},
password = true,
hint = stringResource(R.string.text_hint_password),
error = passwordError
)
Spacer(modifier = Modifier.height(32.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
com.aiosman.ravenow.ui.composables.Checkbox(
checked = rememberMe,
onCheckedChange = {
rememberMe = it
},
size = 18
)
Text(
stringResource(R.string.remember_me),
modifier = Modifier.padding(start = 8.dp),
fontSize = 12.sp,
style = TextStyle(
fontWeight = FontWeight.W500
),
color = AppColors.text
)
Spacer(modifier = Modifier.weight(1f))
Text(
stringResource(R.string.forgot_password),
fontSize = 12.sp,
modifier = Modifier.noRippleClickable {
navController.navigate(NavigationRoute.ResetPassword.route)
},
style = TextStyle(
fontWeight = FontWeight.W500
),
color = AppColors.text
)
}
Spacer(modifier = Modifier.height(46.dp))
ActionButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.lets_ride_upper),
backgroundColor = AppColors.main,
color = AppColors.mainText,
) {
onLogin()
}
Spacer(modifier = Modifier.height(16.dp))
Text(stringResource(R.string.or_login_with), color = AppColors.secondaryText)
Spacer(modifier = Modifier.height(16.dp))
ActionButton(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.sign_in_with_google),
color = AppColors.text,
leading = {
Image(
painter = painterResource(id = R.drawable.rider_pro_google),
contentDescription = "Google",
modifier = Modifier.size(36.dp)
)
},
expandText = true,
contentPadding = PaddingValues(vertical = 8.dp, horizontal = 8.dp)
) {
googleLogin()
}
}
}
}

View File

@@ -0,0 +1,228 @@
package com.aiosman.ravenow.ui.message
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.fillMaxHeight
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.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
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.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.paging.LoadState
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.ravenow.ui.composables.MomentListLoading
import com.aiosman.ravenow.R
import com.aiosman.ravenow.model.ChatNotificationData
import com.aiosman.ravenow.model.TestChatBackend
val chatNotificationData = ChatNotificationData(
R.drawable.default_avatar,
"Tokunaga Yae",
"Memphis",
"3 minutes ago",
6
)
private val ChatData = (0..10).toList().map { chatNotificationData }
@Composable
fun MessagePage(){
val myBackend = remember { TestChatBackend(ChatData) }
val pager = remember {
Pager(
PagingConfig(
pageSize = myBackend.DataBatchSize,
enablePlaceholders = true,
maxSize = 200
)
) {
myBackend.getAllData()
}
}
val lazyPagingItems = pager.flow.collectAsLazyPagingItems()
MessageTopSwitchBtnGroup()
MessageBarrierLine()
MessageNotification()
LazyColumn (
modifier = Modifier.padding(bottom = 20.dp)
){
if (lazyPagingItems.loadState.refresh == LoadState.Loading) {
item {
MomentListLoading()
}
}
items(count = lazyPagingItems.itemCount) { index ->
val item = lazyPagingItems[index]
if (item != null) {
ChatItem(item)
}
}
if (lazyPagingItems.loadState.append == LoadState.Loading) {
item {
MomentListLoading()
}
}
}
}
@Composable
fun MessageTopSwitchBtnGroup(){
Column (
modifier = Modifier
.fillMaxWidth()
.height(113.dp)
) {
Row(modifier = Modifier.fillMaxSize()){
val notificationBtnModifier = Modifier
.fillMaxHeight()
.weight(1f)
NotificationBtn(notificationBtnModifier,drawableId = R.drawable.rider_pro_like,
displayText = "LIKE")
NotificationBtn(notificationBtnModifier,drawableId = R.drawable.rider_pro_followers,
displayText = "FOLLOWERS")
NotificationBtn(notificationBtnModifier,drawableId = R.drawable.rider_pro_comments,
displayText = "COMMENTS")
}
}
}
@Composable
fun MessageBarrierLine(){
Box(modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.padding(start = 24.dp, end = 24.dp)
.border(width = 1.dp, Color(0f, 0f, 0f, 0.1f))
)
}
@Composable
fun NotificationBtn(modifier: Modifier, drawableId: Int, displayText: String){
Box(modifier = modifier,
contentAlignment = Alignment.Center){
Column(modifier = Modifier
.size(width = 79.dp, height = 88.dp),
verticalArrangement = Arrangement.Top,
horizontalAlignment = Alignment.CenterHorizontally){
Box(modifier = Modifier
.padding(top = 8.dp)
.size(width = 55.dp, height = 55.dp)
.shadow(
spotColor = Color.White,
ambientColor = Color(0f, 0f, 0f, 0.4f),
elevation = 12.dp,
),
contentAlignment = Alignment.Center,
){
Image(
modifier = Modifier
.size(width = 24.dp, height = 24.dp),
painter = painterResource(id = drawableId),
contentDescription = ""
)
}
Text(
modifier = Modifier.padding(top = 8.dp),
text = displayText,
fontSize = 12.sp, style = TextStyle(fontWeight = FontWeight.Bold)
)
}
}
}
@Composable
fun MessageNotification(){
Row(modifier = Modifier
.fillMaxWidth()
.height(88.dp)
.padding(start = 22.dp, top = 20.dp, bottom = 20.dp, end = 24.dp),
verticalAlignment = Alignment.CenterVertically){
Box(modifier = Modifier
.size(width = 48.dp, height = 48.dp)
.border(width = 1.dp, Color(0f, 0f, 0f, 0.1f), RoundedCornerShape(2.dp)),
contentAlignment = Alignment.Center){
Icon(
modifier = Modifier
.size(width = 24.dp, height = 24.dp),
painter = painterResource(R.drawable.rider_pro_notification),
contentDescription = ""
)
}
Text(text = "NOTIFICATIONS", fontSize = 18.sp, modifier = Modifier.padding(start = 12.dp), style = TextStyle(fontWeight = FontWeight.Bold))
Box(modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.CenterEnd){
Box(modifier = Modifier
.height(18.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.Red),
contentAlignment = Alignment.Center){
Text(text = "18", fontSize = 10.sp, color = Color.White, modifier = Modifier.padding(start = 6.dp, end = 6.dp, top = 2.dp, bottom = 2.dp))
}
}
}
}
@Composable
fun ChatItem(chatNotificationData: ChatNotificationData){
Row(modifier = Modifier
.fillMaxWidth()
.height(88.dp)
.padding(start = 22.dp, top = 20.dp, bottom = 20.dp, end = 24.dp),
verticalAlignment = Alignment.CenterVertically){
Image(modifier = Modifier
.size(width = 48.dp, height = 48.dp)
.clip(RoundedCornerShape(2.dp)),
painter = painterResource(chatNotificationData.avatar),
contentDescription = "")
Column (
modifier = Modifier.fillMaxHeight().padding(start = 12.dp),
verticalArrangement = Arrangement.SpaceAround
){
Text(text = chatNotificationData.name, fontSize = 18.sp, style = TextStyle(fontWeight = FontWeight.Bold))
Text(text = chatNotificationData.message, fontSize = 14.sp,
color = Color(0f, 0f, 0f, 0.6f))
}
Box(modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.CenterEnd){
Column (
modifier = Modifier.fillMaxHeight(),
verticalArrangement = Arrangement.SpaceAround,
horizontalAlignment = Alignment.End
){
Text(text = chatNotificationData.time, fontSize = 12.sp,color = Color(0f, 0f, 0f, 0.4f))
Box(modifier = Modifier
.height(18.dp)
.clip(RoundedCornerShape(10.dp))
.background(Color.Red),
contentAlignment = Alignment.Center){
Text(text = chatNotificationData.unread.toString(), fontSize = 10.sp, color = Color.White, modifier = Modifier.padding(start = 6.dp, end = 6.dp, top = 2.dp, bottom = 2.dp))
}
}
}
}
}

View File

@@ -0,0 +1,241 @@
package com.aiosman.ravenow.ui.modification
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
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.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathEffect
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.ui.post.NewPostViewModel
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.R
import com.aiosman.ravenow.utils.Utils
@Preview
@Composable
fun EditModificationScreen() {
val model = NewPostViewModel
Column(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFFf8f8f8))
) {
Box(
modifier = Modifier.padding(vertical = 16.dp, horizontal = 18.dp)
) {
NoticeScreenHeader("Modification List")
}
LazyColumn(
modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp)
) {
items(NewPostViewModel.modificationList) { mod ->
AddModificationItem(mod) { updatedMod ->
NewPostViewModel.modificationList = NewPostViewModel.modificationList.map { existingMod ->
if (existingMod.key == updatedMod.key) updatedMod else existingMod
}.toMutableList()
}
Spacer(modifier = Modifier.height(16.dp))
}
item {
AddModificationButton {
NewPostViewModel.modificationList += Modification(
key = Utils.generateRandomString(4),
name = "",
price = "0.0"
)
}
}
}
}
}
data class Modification(
val key: String,
val name: String,
val price: String
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddModificationItem(modification: Modification, onUpdate: (Modification) -> Unit) {
var isEditPriceBottomModalVisible by remember { mutableStateOf(false) }
if (isEditPriceBottomModalVisible) {
ModalBottomSheet(
onDismissRequest = {
isEditPriceBottomModalVisible = false
},
containerColor = Color.White,
sheetState = rememberModalBottomSheetState(
skipPartiallyExpanded = true
),
dragHandle = {},
scrimColor = Color.Transparent,
shape = RectangleShape
) {
EditPriceBottomModal {
isEditPriceBottomModalVisible = false
onUpdate(
modification.copy(price = it)
)
}
}
}
Column {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
.padding(vertical = 13.dp, horizontal = 16.dp),
) {
if (modification.name.isEmpty()) {
Text("Please enter the name", fontSize = 14.sp, color = Color(0xFFd6d6d6))
}
BasicTextField(
value = modification.name,
onValueChange = {
onUpdate(
modification.copy(name = it)
)
},
modifier = Modifier
.fillMaxWidth()
.align(Alignment.Center)
)
}
Spacer(modifier = Modifier.height(1.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
.padding(top = 13.dp, bottom = 13.dp, start = 16.dp, end = 8.dp)
.clickable {
isEditPriceBottomModalVisible = true
}
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Text("Price", fontSize = 16.sp)
Spacer(modifier = Modifier.weight(1f))
Text(modification.price, fontSize = 16.sp, color = Color(0xffda3832))
Spacer(modifier = Modifier.width(6.dp))
Image(
painter = painterResource(id = R.drawable.rider_pro_nav_next),
contentDescription = "Edit",
modifier = Modifier.size(24.dp)
)
}
}
}
}
@Composable
fun AddModificationButton(onClick: () -> Unit = {}) {
val stroke = Stroke(
width = 2f,
pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.drawBehind {
drawRoundRect(color = Color(0xFFd6d6d6), style = stroke)
}
.clickable {
onClick()
}
) {
Image(
painter = painterResource(id = R.drawable.rider_pro_new_post_add_pic),
contentDescription = "Add",
modifier = Modifier
.size(24.dp)
.align(Alignment.Center),
)
}
}
@Composable
fun EditPriceBottomModal(onOkClick: (String) -> Unit = {}) {
val focusRequester = remember { FocusRequester() }
val keyboardController = LocalSoftwareKeyboardController.current
var text by remember { mutableStateOf("") }
LaunchedEffect(Unit) {
focusRequester.requestFocus()
}
// Modal content including BasicTextField
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.White)
.padding(16.dp)
) {
Text("Price", fontSize = 16.sp)
Spacer(modifier = Modifier.width(16.dp))
BasicTextField(
value = text,
onValueChange = { text = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done
),
singleLine = true,
keyboardActions = KeyboardActions(
onDone = {
keyboardController?.hide()
// Logic to close the dialog/modal
onOkClick(text)
}
),
)
}
}

View File

@@ -0,0 +1,99 @@
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Preview
@Composable
fun ModificationListScreen() {
val systemUiController = rememberSystemUiController()
LaunchedEffect(Unit) {
systemUiController.setNavigationBarColor(Color.Transparent)
}
val modifications = getModifications()
StatusBarMaskLayout {
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
.background(Color(0xFFF8F8F8))
) {
NoticeScreenHeader("Modification List")
Spacer(modifier = Modifier.height(8.dp))
LazyColumn(modifier = Modifier.padding(16.dp)) {
items(modifications.size) { index ->
val modification = modifications[index]
ModificationItem(name = modification.name, price = modification.price)
Spacer(modifier = Modifier.height(8.dp))
}
item {
BottomNavigationPlaceholder()
}
}
}
}
}
data class Modification(val name: String, val price: String)
fun getModifications(): List<Modification> {
return listOf(
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
Modification("Modification name", "$74.00"),
)
}
@Composable
fun ModificationItem(name: String, price: String) {
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = Color.White,
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(text = name, fontSize = 16.sp, fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.height(8.dp))
Text(text = price, fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.Red)
}
}
}

View File

@@ -0,0 +1,39 @@
package com.aiosman.ravenow.ui.modifiers
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed {
this.clickable(indication = null,
interactionSource = remember { MutableInteractionSource() }) {
onClick()
}
}
inline fun Modifier.noRippleClickable(
debounceTime: Long = 300L,
crossinline onClick: () -> Unit
): Modifier = composed {
var job: Job? = null
val scope = rememberCoroutineScope()
this.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
job?.cancel()
job = scope.launch {
delay(debounceTime)
onClick()
}
}
}

Some files were not shown because too many files have changed in this diff Show More