改包名com.aiosman.ravenow
This commit is contained in:
427
app/src/main/java/com/aiosman/ravenow/ui/Navi.kt
Normal file
427
app/src/main/java/com/aiosman/ravenow/ui/Navi.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
240
app/src/main/java/com/aiosman/ravenow/ui/account/edit.kt
Normal file
240
app/src/main/java/com/aiosman/ravenow/ui/account/edit.kt
Normal 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
190
app/src/main/java/com/aiosman/ravenow/ui/account/edit2.kt
Normal file
190
app/src/main/java/com/aiosman/ravenow/ui/account/edit2.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
644
app/src/main/java/com/aiosman/ravenow/ui/chat/ChatScreen.kt
Normal file
644
app/src/main/java/com/aiosman/ravenow/ui/chat/ChatScreen.kt
Normal 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
|
||||
}
|
||||
263
app/src/main/java/com/aiosman/ravenow/ui/chat/ChatViewModel.kt
Normal file
263
app/src/main/java/com/aiosman/ravenow/ui/chat/ChatViewModel.kt
Normal 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"
|
||||
}
|
||||
240
app/src/main/java/com/aiosman/ravenow/ui/comment/CommentModal.kt
Normal file
240
app/src/main/java/com/aiosman/ravenow/ui/comment/CommentModal.kt
Normal 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))
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
540
app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt
Normal file
540
app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt
Normal 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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
enum class FabPosition {
|
||||
Center,
|
||||
End
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
175
app/src/main/java/com/aiosman/ravenow/ui/crop/ImageCropScreen.kt
Normal file
175
app/src/main/java/com/aiosman/ravenow/ui/crop/ImageCropScreen.kt
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
278
app/src/main/java/com/aiosman/ravenow/ui/gallery/Gallery.kt
Normal file
278
app/src/main/java/com/aiosman/ravenow/ui/gallery/Gallery.kt
Normal 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()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
231
app/src/main/java/com/aiosman/ravenow/ui/index/Index.kt
Normal file
231
app/src/main/java/com/aiosman/ravenow/ui/index/Index.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.aiosman.ravenow.ui.index.tabs.moment
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
|
||||
object MomentViewModel : ViewModel() {
|
||||
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
// 按钮
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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 = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
338
app/src/main/java/com/aiosman/ravenow/ui/like/LikePage.kt
Normal file
338
app/src/main/java/com/aiosman/ravenow/ui/like/LikePage.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
278
app/src/main/java/com/aiosman/ravenow/ui/login/emailsignup.kt
Normal file
278
app/src/main/java/com/aiosman/ravenow/ui/login/emailsignup.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
476
app/src/main/java/com/aiosman/ravenow/ui/login/login.kt
Normal file
476
app/src/main/java/com/aiosman/ravenow/ui/login/login.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
249
app/src/main/java/com/aiosman/ravenow/ui/login/signup.kt
Normal file
249
app/src/main/java/com/aiosman/ravenow/ui/login/signup.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
321
app/src/main/java/com/aiosman/ravenow/ui/login/userauth.kt
Normal file
321
app/src/main/java/com/aiosman/ravenow/ui/login/userauth.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
228
app/src/main/java/com/aiosman/ravenow/ui/message/Message.kt
Normal file
228
app/src/main/java/com/aiosman/ravenow/ui/message/Message.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user