From 07b89fe3cc9581273280eed251c3dc30ca742b88 Mon Sep 17 00:00:00 2001 From: Kevinlinpr Date: Tue, 27 Aug 2024 04:20:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E5=8F=91=E9=80=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=EF=BC=8C=E4=BD=86=E6=98=AF=E6=B2=A1=E6=9C=89=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/aiosman/riderpro/llama.py | 23 + .../main/java/com/aiosman/riderpro/llama.txt | 12920 ++++++++++++++++ .../com/aiosman/riderpro/ui/post/NewPost.kt | 50 +- .../riderpro/ui/post/NewPostViewModel.kt | 6 +- 4 files changed, 12991 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/aiosman/riderpro/llama.py create mode 100644 app/src/main/java/com/aiosman/riderpro/llama.txt diff --git a/app/src/main/java/com/aiosman/riderpro/llama.py b/app/src/main/java/com/aiosman/riderpro/llama.py new file mode 100644 index 0000000..44c7129 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/llama.py @@ -0,0 +1,23 @@ +import os + +def read_files_recursively(directory, output_filename="llama.txt"): + """ + 递归读取指定目录下的所有文件,并将它们的内容按顺序写入一个新文件中。 + 每个文件的内容以文件名和相对路径作为注释开头。 + """ + + script_filename = os.path.basename(__file__) # 获取当前脚本的文件名 + + with open(output_filename, "w", encoding="utf-8") as outfile: + for root, dirs, files in os.walk(directory): + for filename in sorted(files): + if filename != script_filename: + filepath = os.path.join(root, filename) + relative_path = os.path.relpath(filepath, directory) + outfile.write(f"### {relative_path} ###\n") + with open(filepath, "r", encoding="utf-8") as infile: + outfile.write(infile.read()) + outfile.write("\n\n") + +if __name__ == "__main__": + read_files_recursively(".") # 从当前目录开始递归读取 \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/llama.txt b/app/src/main/java/com/aiosman/riderpro/llama.txt new file mode 100644 index 0000000..133b7dd --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/llama.txt @@ -0,0 +1,12920 @@ +### Const.kt ### +package com.aiosman.riderpro + +object ConstVars { + // api 地址 +// const val BASE_SERVER = "http://192.168.31.250:8088" + const val BASE_SERVER = "https://8.137.22.101:8088" +} + +### ImageListScreen.kt ### +import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest +import coil.ImageLoader +import coil.disk.DiskCache +import coil.memory.MemoryCache + +data class ImageItem(val url: String) + +@Composable +fun ImageListScreen(context: Context, imageList: List) { + val imageLoader = getImageLoader(context) + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp) + ) { + items(imageList) { item -> + ImageItem(item, imageLoader, context) // 传递 context 参数 + } + } +} + +@Composable +fun ImageItem(item: ImageItem, imageLoader: ImageLoader, context: Context) { // 接收 context 参数 + val painter = rememberAsyncImagePainter( + model = ImageRequest.Builder(context) // 使用 context 参数 + .data(item.url) + .crossfade(true) + .build(), + imageLoader = imageLoader + ) + + Image( + painter = painter, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .height(200.dp), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.height(16.dp)) +} + +fun getImageLoader(context: Context): ImageLoader { + return ImageLoader.Builder(context) + .memoryCache { + MemoryCache.Builder(context) + .maxSizePercent(0.25) // 设置内存缓存大小为可用内存的 25% + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("image_cache")) + .maxSizePercent(0.02) // 设置磁盘缓存大小为可用存储空间的 2% + .build() + } + .build() +} + +### MainActivity.kt ### +package com.aiosman.riderpro + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +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.statusBarsPadding +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.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.ui.Navigation +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.index.NavigationItem +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.google.android.libraries.places.api.Places +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class MainActivity : ComponentActivity() { + private val scope = CoroutineScope(Dispatchers.Main) + suspend fun getAccount(): Boolean { + val accountService: AccountService = AccountServiceImpl() + try { + val resp = accountService.getMyAccount() + return true + } catch (e: Exception) { + return false + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + if (!Places.isInitialized()) { + Places.initialize(applicationContext, "AIzaSyDpgLDH1-SECw_pdjJq_msynq1XrxwgKVI") + } + AppStore.init(this) + enableEdgeToEdge() + + scope.launch { + val isAccountValidate = getAccount() + var startDestination = NavigationRoute.Login.route + if (AppStore.token != null && AppStore.rememberMe && isAccountValidate) { + startDestination = NavigationRoute.Index.route + } + setContent { + Navigation(startDestination) + } + } + } +} + +val LocalNavController = compositionLocalOf { + error("NavController not provided") +} + +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedTransitionScope = compositionLocalOf { + error("SharedTransitionScope not provided") +} + +val LocalAnimatedContentScope = compositionLocalOf { + error("AnimatedContentScope not provided") +} + + +// 用于带导航栏的路由的可复用 composable +@Composable +fun ScaffoldWithNavigationBar( + navController: NavHostController, + content: @Composable () -> Unit +) { + val navigationBarHeight = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + val item = listOf( + NavigationItem.Home, + NavigationItem.Street, + NavigationItem.Add, + NavigationItem.Message, + NavigationItem.Profile + ) + Scaffold( + modifier = Modifier.statusBarsPadding(), + bottomBar = { + NavigationBar( + modifier = Modifier.height(56.dp + navigationBarHeight), + containerColor = Color.Black + ) { + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + val systemUiController = rememberSystemUiController() + item.forEach { it -> + val isSelected = currentRoute == it.route + val iconTint by animateColorAsState( + targetValue = if (isSelected) Color.Red else Color.White, + animationSpec = tween(durationMillis = 250), label = "" + ) + NavigationBarItem( + selected = currentRoute == it.route, + onClick = { + // Check if the current route is not the same as the tab's route to avoid unnecessary navigation + if (currentRoute != it.route) { + navController.navigate(it.route) { + // Avoid creating a new layer on top of the navigation stack + launchSingleTop = true + // Attempt to pop up to the existing instance of the destination, if present + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + // Restore state when navigating back to the composable + restoreState = true + } + } + // Additional logic for system UI color changes + when (it.route) { + NavigationItem.Add.route -> { + systemUiController.setSystemBarsColor(color = Color.Black) + } + + NavigationItem.Message.route -> { + systemUiController.setSystemBarsColor(color = Color.Black) + } + + else -> { + systemUiController.setSystemBarsColor(color = Color.Transparent) + } + } + }, + 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 (currentRoute == it.route) it.selectedIcon() else it.icon(), + contentDescription = null, + tint = iconTint + ) + } + ) + } + + } + } + ) { innerPadding -> + Box( + modifier = Modifier.padding(innerPadding) + ) { + content() + } + } +} + + + +### llama.txt ### + + +### store.kt ### +package com.aiosman.riderpro + +import android.content.Context +import android.content.SharedPreferences +import com.google.android.gms.auth.api.signin.GoogleSignInOptions + +/** + * 持久化本地数据 + */ +object AppStore { + private const val STORE_VERSION = 1 + private const val PREFS_NAME = "app_prefs_$STORE_VERSION" + var token: String? = null + var rememberMe: Boolean = false + private lateinit var sharedPreferences: SharedPreferences + lateinit var googleSignInOptions: GoogleSignInOptions + fun init(context: Context) { + sharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + this.loadData() + + val gso = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestIdToken("754277015802-uarf8br8k8gkpbj0t9g65bvkvit630q5.apps.googleusercontent.com") // Replace with your server's client ID + .requestEmail() + .build() + googleSignInOptions = gso + } + + suspend fun saveData() { + // shared preferences + sharedPreferences.edit().apply { + putString("token", token) + putBoolean("rememberMe", rememberMe) + }.apply() + } + + fun loadData() { + // shared preferences + token = sharedPreferences.getString("token", null) + rememberMe = sharedPreferences.getBoolean("rememberMe", false) + } +} + +### ui/Navi.kt ### +package com.aiosman.riderpro.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.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +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.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.riderpro.LocalAnimatedContentScope +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.LocalSharedTransitionScope +import com.aiosman.riderpro.ui.account.AccountEditScreen +import com.aiosman.riderpro.ui.comment.CommentsScreen +import com.aiosman.riderpro.ui.favourite.FavouriteScreen +import com.aiosman.riderpro.ui.follower.FollowerScreen +import com.aiosman.riderpro.ui.gallery.OfficialGalleryScreen +import com.aiosman.riderpro.ui.gallery.OfficialPhotographerScreen +import com.aiosman.riderpro.ui.gallery.ProfileTimelineScreen +import com.aiosman.riderpro.ui.index.IndexScreen +import com.aiosman.riderpro.ui.like.LikeScreen +import com.aiosman.riderpro.ui.location.LocationDetailScreen +import com.aiosman.riderpro.ui.login.EmailSignupScreen +import com.aiosman.riderpro.ui.login.LoginPage +import com.aiosman.riderpro.ui.login.SignupScreen +import com.aiosman.riderpro.ui.login.UserAuthScreen +import com.aiosman.riderpro.ui.index.tabs.message.NotificationsScreen +import com.aiosman.riderpro.ui.modification.EditModificationScreen +import com.aiosman.riderpro.ui.post.NewPostImageGridScreen +import com.aiosman.riderpro.ui.post.NewPostScreen +import com.aiosman.riderpro.ui.post.PostScreen +import com.aiosman.riderpro.ui.profile.AccountProfile + +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}") + 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") +} + + +@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 }), + enterTransition = { + fadeIn(animationSpec = tween(durationMillis = 100)) + }, + exitTransition = { + fadeOut(animationSpec = tween(durationMillis = 100)) + } + ) { backStackEntry -> + CompositionLocalProvider( + LocalAnimatedContentScope provides this, + ) { + val id = backStackEntry.arguments?.getString("id") + PostScreen( + id!! + ) + } + } + composable(route = NavigationRoute.ModificationList.route) { + ModificationListScreen() + } + composable(route = NavigationRoute.MyMessage.route) { + NotificationsScreen() + } + composable(route = NavigationRoute.Comments.route) { + CommentsScreen() + } + composable(route = NavigationRoute.Likes.route) { + LikeScreen() + } + composable(route = NavigationRoute.Followers.route) { + FollowerScreen() + } + composable( + route = NavigationRoute.NewPost.route, + enterTransition = { + + fadeIn(animationSpec = tween(durationMillis = 100)) + }, + exitTransition = { + fadeOut(animationSpec = tween(durationMillis = 100)) + } + ) { + 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, + ) { + AccountProfile(it.arguments?.getString("id")!!) + } + } + composable(route = NavigationRoute.SignUp.route) { + SignupScreen() + } + composable(route = NavigationRoute.UserAuth.route) { + UserAuthScreen() + } + composable(route = NavigationRoute.EmailSignUp.route) { + EmailSignupScreen() + } + composable(route = NavigationRoute.AccountEdit.route) { + AccountEditScreen() + } + composable(route = NavigationRoute.ImageViewer.route) { + CompositionLocalProvider( + LocalAnimatedContentScope provides this, + ) { + ImageViewer() + } + } + composable(route = NavigationRoute.ChangePasswordScreen.route) { + ChangePasswordScreen() + } + composable(route = NavigationRoute.FavouritesScreen.route) { + FavouriteScreen() + } + composable(route = NavigationRoute.NewPostImageGrid.route) { + NewPostImageGridScreen() + } + } + + +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun Navigation(startDestination: String = NavigationRoute.Login.route) { + val navController = rememberNavController() + SharedTransitionLayout { + CompositionLocalProvider( + LocalNavController provides navController, + LocalSharedTransitionScope provides this@SharedTransitionLayout, + ) { + Box { + NavigationController( + navController = navController, + startDestination = startDestination + ) + } + } + } +} + +### ui/like/LikePage.kt ### +package com.aiosman.riderpro.ui.like + +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.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.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.graphics.Color +import androidx.compose.ui.platform.LocalContext +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.riderpro.LocalNavController +import com.aiosman.riderpro.data.AccountLike +import com.aiosman.riderpro.exp.timeAgo +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +import com.aiosman.riderpro.ui.comment.NoticeScreenHeader +import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import java.util.Date + +@Preview +@Composable +fun LikeScreen() { + val model = LikePageViewModel + val listState = rememberLazyListState() + var dataFlow = model.likeItemsFlow + var likes = dataFlow.collectAsLazyPagingItems() + LaunchedEffect(Unit) { + model.updateNotice() + } + StatusBarMaskLayout( + darkIcons = true, + 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( + "LIKES", + moreIcon = false + ) + } + +// Spacer(modifier = Modifier.height(28.dp)) + LazyColumn( + modifier = Modifier.weight(1f), + state = listState, + ) { + items(likes.itemCount) { + val likeItem = likes[it] + if (likeItem != null) { + ActionNoticeItem( + avatar = likeItem.user.avatar, + nickName = likeItem.user.nickName, + likeTime = likeItem.likeTime, + thumbnail = likeItem.post.images[0].thumbnail, + action = "like", + userId = likeItem.user.id, + postId = likeItem.post.id + ) + } + } + item { + BottomNavigationPlaceholder() + } + } + } + } +} + + +@Composable +fun ActionNoticeItem( + avatar: String, + nickName: String, + likeTime: Date, + thumbnail: String, + action: String, + userId: Int, + postId: Int +) { + val context = LocalContext.current + val navController = LocalNavController.current + Box( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Top, + ) { + CustomAsyncImage( + context, + imageUrl = avatar, + modifier = Modifier + .size(40.dp) + .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.navigate( + NavigationRoute.Post.route.replace( + "{id}", + postId.toString() + ) + ) + } + ) { + Text(nickName, fontWeight = FontWeight.Bold, fontSize = 16.sp) + Spacer(modifier = Modifier.height(5.dp)) + Row { + Text(likeTime.timeAgo(), fontSize = 12.sp, color = Color(0x99000000)) + } + } + CustomAsyncImage( + context, + imageUrl = thumbnail, + modifier = Modifier.size(64.dp), + contentDescription = action, + ) + } + } +} + +### ui/like/LikePageViewModel.kt ### +package com.aiosman.riderpro.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.riderpro.entity.AccountLikeEntity +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.entity.LikeItemPagingSource +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.data.api.ApiClient +import com.aiosman.riderpro.data.api.UpdateNoticeRequestBody +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + + +object LikePageViewModel : ViewModel() { + private val accountService: AccountService = AccountServiceImpl() + private val _likeItemsFlow = MutableStateFlow>(PagingData.empty()) + val likeItemsFlow = _likeItemsFlow.asStateFlow() + + init { + 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) + ) + ) + } +} + +### ui/splash/splash.kt ### +package com.aiosman.riderpro.ui.splash + +import android.window.SplashScreen +import androidx.compose.foundation.Image +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.material.Scaffold +import androidx.compose.material.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 androidx.compose.ui.unit.sp +import com.aiosman.riderpro.R + +@Composable +fun SplashScreen() { + Scaffold { + 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 = "Rider Pro", + modifier = Modifier + .width(108.dp) + .height(45.dp) + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + "Connecting Riders".uppercase(), + fontSize = 28.sp, + fontWeight = FontWeight.Bold + ) + Text("Worldwide".uppercase(), fontSize = 28.sp, fontWeight = FontWeight.Bold) + } + + } + } + + } +} + +### ui/modifiers/ModifierExp.kt ### +package com.aiosman.riderpro.ui.modifiers + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed + +inline fun Modifier.noRippleClickable(crossinline onClick: () -> Unit): Modifier = composed { + this.clickable(indication = null, + interactionSource = remember { MutableInteractionSource() }) { + onClick() + } +} + +### ui/post/NewPost.kt ### +package com.aiosman.riderpro.ui.post + +import android.app.Activity +import android.content.Intent +import android.util.Log +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.heightIn +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.foundation.text.BasicTextField +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import coil.compose.AsyncImage +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.composables.RelPostCard +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.launch + + +@Preview +@Composable +fun NewPostScreen() { + val model = NewPostViewModel + val systemUiController = rememberSystemUiController() + val navController = LocalNavController.current + val context = LocalContext.current + LaunchedEffect(Unit) { + systemUiController.setNavigationBarColor(color = Color.Transparent) + model.init() + } + StatusBarMaskLayout( + darkIcons = true, + modifier = Modifier.fillMaxSize().background(Color.White) + ) { + Column( + modifier = Modifier + .fillMaxSize() + ) { + NewPostTopBar { + model.viewModelScope.launch { + model.createMoment(context = context) + navController.popBackStack() + } + } + NewPostTextField("Share your adventure…", NewPostViewModel.textContent) { + NewPostViewModel.textContent = it + } + Column ( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp) + ) { + model.relMoment?.let { + Text("Share with") + Spacer(modifier = Modifier.height(8.dp)) + Box( + modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(color = Color(0xFFEEEEEE)).padding(24.dp) + ) { + RelPostCard( + momentEntity = it, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + AddImageGrid() +// AdditionalPostItem() + } + } +} + +@Composable +fun NewPostTopBar(onSendClick: () -> Unit = {}) { + val navController = LocalNavController.current + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 10.dp) + ) { + Row( + modifier = Modifier.align(Alignment.CenterStart), + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_close), + contentDescription = "Back", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + navController.popBackStack() + } + ) + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.rider_pro_send_post), + contentDescription = "Send", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + onSendClick() + } + ) + } + } + +} + +@Composable +fun NewPostTextField(hint: String, value: String, onValueChange: (String) -> Unit) { + + Box(modifier = Modifier.fillMaxWidth()) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .heightIn(200.dp) + .padding(horizontal = 18.dp, vertical = 10.dp) + + ) + if (value.isEmpty()) { + Text( + text = hint, + color = Color.Gray, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp) + ) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun AddImageGrid() { + val navController = LocalNavController.current + val context = LocalContext.current + val model = NewPostViewModel + + val pickImagesLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris -> + if (uris.isNotEmpty()) { + model.imageUriList += uris.map { it.toString() } + } + } + + val stroke = Stroke( + width = 2f, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f) + ) + Box( + modifier = Modifier.fillMaxWidth() + ) { + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(18.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + model.imageUriList.forEach { + CustomAsyncImage( + context, + it, + contentDescription = "Image", + modifier = Modifier + .size(110.dp) + + .drawBehind { + drawRoundRect(color = Color(0xFF999999), style = stroke) + }.noRippleClickable { + navController.navigate(NavigationRoute.NewPostImageGrid.route) + }, + contentScale = ContentScale.Crop + ) + } + Box( + modifier = Modifier + .size(110.dp) + + .drawBehind { + drawRoundRect(color = Color(0xFF999999), style = stroke) + } + .noRippleClickable{ + pickImagesLauncher.launch("image/*") + }, + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_new_post_add_pic), + contentDescription = "Add Image", + modifier = Modifier + .size(48.dp) + .align(Alignment.Center) + ) + } + + } + } + +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AdditionalPostItem() { + val model = NewPostViewModel + val navController = LocalNavController.current + var isShowLocationModal by remember { mutableStateOf(false) } + fun onSelectLocationClick() { + isShowLocationModal = true + } + if (isShowLocationModal) { + ModalBottomSheet( + onDismissRequest = { + isShowLocationModal = false + }, + containerColor = Color.White + + ) { + // Sheet content + SelectLocationModal( + onClose = { + isShowLocationModal = false + } + ) { + isShowLocationModal = false + NewPostViewModel.searchPlaceAddressResult = it + } + } + } + Column( + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 24.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + onSelectLocationClick() + } + ) { + NewPostViewModel.searchPlaceAddressResult?.let { + SelectedLocation(it) { + NewPostViewModel.searchPlaceAddressResult = null + } + } ?: Row( + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_add_location), + contentDescription = "Location", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text("Add Location", color = Color(0xFF333333)) + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_next), + contentDescription = "Add Location", + modifier = Modifier.size(24.dp) + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 24.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + navController.navigate("EditModification") + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_modification), + contentDescription = "Modification List", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text("Modification List", color = Color(0xFF333333)) + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_next), + contentDescription = "Modification List", + modifier = Modifier.size(24.dp) + ) + } + + } + } +} + +@Composable +fun SelectedLocation( + searchPlaceAddressResult: SearchPlaceAddressResult, + onRemoveLocation: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp) + ) { + Text(searchPlaceAddressResult.name, fontWeight = FontWeight.Bold) + Spacer(modifier = Modifier.height(4.dp)) + Text(searchPlaceAddressResult.address, color = Color(0xFF9a9a9a)) + } + Image( + painter = painterResource(id = R.drawable.rider_pro_close), + contentDescription = "Next", + modifier = Modifier + .size(24.dp) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + onRemoveLocation() + } + ) + + } + } +} + +### ui/post/NewPostImageGrid.kt ### +package com.aiosman.riderpro.ui.post + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.Icon +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Delete +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +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.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.rememberAsyncImagePainter +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.google.accompanist.systemuicontroller.rememberSystemUiController + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun NewPostImageGridScreen() { + val model = NewPostViewModel + val imageList = model.imageUriList + val pagerState = rememberPagerState(pageCount = { imageList.size }) + val systemUiController = rememberSystemUiController() + val paddingValues = WindowInsets.systemBars.asPaddingValues() + val navController = LocalNavController.current + val title = "${pagerState.currentPage + 1}/${imageList.size}" + + LaunchedEffect(Unit) { + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = false) + } + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xFF2e2e2e)) + .padding(horizontal = 16.dp, vertical = 16.dp) + ) { + Column { + Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding())) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.AutoMirrored.Default.ArrowBack, + contentDescription = "back", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + navController.popBackStack() + }, + tint = Color.White + ) + Text( + title, + color = Color.White, + modifier = Modifier.weight(1f), + textAlign = TextAlign.Center, + fontSize = 18.sp, + ) + Icon( + Icons.Default.Delete, + contentDescription = "delete", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + model.deleteImage(pagerState.currentPage) + }, + tint = Color.White + ) + } + + } + + } + HorizontalPager( + state = pagerState, + ) { page -> + val imageUrl = imageList[page] + Image( + painter = rememberAsyncImagePainter(model = imageUrl), + contentDescription = "Image $page", + modifier = Modifier.fillMaxSize() + ) + } + } + + + } + + +} + +### ui/post/NewPostViewModel.kt ### +package com.aiosman.riderpro.ui.post + +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 com.aiosman.riderpro.data.MomentService +import com.aiosman.riderpro.entity.MomentServiceImpl +import com.aiosman.riderpro.data.UploadImage +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.ui.modification.Modification +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.FileOutputStream +import java.io.InputStream + + +object NewPostViewModel : ViewModel() { + var momentService: MomentService = MomentServiceImpl() + var textContent by mutableStateOf("") + var searchPlaceAddressResult by mutableStateOf(null) + var modificationList by mutableStateOf>(listOf()) + var imageUriList by mutableStateOf(listOf()) + var relPostId by mutableStateOf(null) + var relMoment by mutableStateOf(null) + fun asNewPost() { + textContent = "" + searchPlaceAddressResult = null + modificationList = listOf() + imageUriList = listOf() + relPostId = null + } + + suspend fun uriToFile(context: Context, uri: Uri): File { + val inputStream: InputStream? = context.contentResolver.openInputStream(uri) + val tempFile = withContext(Dispatchers.IO) { + File.createTempFile("temp", null, context.cacheDir) + } + inputStream?.use { input -> + FileOutputStream(tempFile).use { output -> + input.copyTo(output) + } + } + return tempFile + } + + suspend fun createMoment(context: Context) { + val uploadImageList = emptyList().toMutableList() + for (uri in imageUriList) { + val cursor = context.contentResolver.query(Uri.parse(uri), null, null, null, null) + cursor?.use { + if (it.moveToFirst()) { + val displayName = it.getString(it.getColumnIndex("_display_name")) + val extension = displayName.substringAfterLast(".") + Log.d("NewPost", "File name: $displayName, extension: $extension") + // read as file + val file = uriToFile(context, Uri.parse(uri)) + Log.d("NewPost", "File size: ${file.length()}") + uploadImageList += UploadImage(file, displayName, uri, extension) + } + } + } + momentService.createMoment(textContent, 1, uploadImageList, relPostId) + } + + suspend fun init() { + relPostId?.let { + val moment = momentService.getMomentById(it) + relMoment = moment + } + } + fun deleteImage(index: Int) { + imageUriList = imageUriList.toMutableList().apply { + removeAt(index) + } + } +} + +### ui/post/Post.kt ### +package com.aiosman.riderpro.ui.post + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateContentSize +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.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.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +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.Edit +import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +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.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.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.vectorResource +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.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.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.map +import com.aiosman.riderpro.LocalAnimatedContentScope +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.LocalSharedTransitionScope +import com.aiosman.riderpro.R +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.entity.CommentEntity +import com.aiosman.riderpro.entity.CommentPagingSource +import com.aiosman.riderpro.data.CommentRemoteDataSource +import com.aiosman.riderpro.data.CommentService +import com.aiosman.riderpro.data.CommentServiceImpl +import com.aiosman.riderpro.data.MomentService +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.entity.MomentServiceImpl +import com.aiosman.riderpro.data.UserServiceImpl +import com.aiosman.riderpro.data.UserService +import com.aiosman.riderpro.exp.formatPostTime +import com.aiosman.riderpro.exp.timeAgo +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.entity.MomentImageEntity +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.composables.EditCommentBottomModal +import com.aiosman.riderpro.ui.imageviewer.ImageViewerViewModel +import com.aiosman.riderpro.ui.index.tabs.moment.MomentViewModel +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +object PostViewModel : ViewModel() { + var service: MomentService = MomentServiceImpl() + var commentService: CommentService = CommentServiceImpl() + var userService: UserService = UserServiceImpl() + private var _commentsFlow = MutableStateFlow>(PagingData.empty()) + val commentsFlow = _commentsFlow.asStateFlow() + var postId: String = "" + + // 预加载的 moment + + var accountProfileEntity by mutableStateOf(null) + var moment by mutableStateOf(null) + var accountService: AccountService = AccountServiceImpl() + + /** + * 预加载,在跳转到 PostScreen 之前设置好内容 + */ + fun preTransit(momentEntity: MomentEntity?) { + this.postId = momentEntity?.id.toString() + this.moment = momentEntity + this._commentsFlow = MutableStateFlow>(PagingData.empty()) + this.accountProfileEntity = null + + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + CommentPagingSource( + CommentRemoteDataSource(commentService), + postId = postId.toInt() + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _commentsFlow.value = it + } + } + } + + suspend fun initData() { + moment = service.getMomentById(postId.toInt()) + moment?.let { + accountProfileEntity = userService.getUserProfile(it.authorId.toString()) + } + } + + suspend fun likeComment(commentId: Int) { + commentService.likeComment(commentId) + val currentPagingData = commentsFlow.value + val updatedPagingData = currentPagingData.map { comment -> + if (comment.id == commentId) { + comment.copy(liked = !comment.liked, likes = comment.likes + 1) + } else { + comment + } + } + _commentsFlow.value = updatedPagingData + } + + suspend fun unlikeComment(commentId: Int) { + commentService.dislikeComment(commentId) + val currentPagingData = commentsFlow.value + val updatedPagingData = currentPagingData.map { comment -> + if (comment.id == commentId) { + comment.copy(liked = !comment.liked, likes = comment.likes - 1) + } else { + comment + } + } + _commentsFlow.value = updatedPagingData + } + + suspend fun createComment(content: String) { + commentService.createComment(postId.toInt(), content) + MomentViewModel.updateCommentCount(postId.toInt()) + } + + suspend fun likeMoment() { + moment?.let { + service.likeMoment(it.id) + moment = moment?.copy(likeCount = moment?.likeCount?.plus(1) ?: 0, liked = true) + MomentViewModel.updateLikeCount(it.id) + } + } + + suspend fun dislikeMoment() { + moment?.let { + service.dislikeMoment(it.id) + moment = moment?.copy(likeCount = moment?.likeCount?.minus(1) ?: 0, liked = false) + // update home list + MomentViewModel.updateDislikeMomentById(it.id) + } + } + + suspend fun favoriteMoment() { + moment?.let { + service.favoriteMoment(it.id) + moment = + moment?.copy(favoriteCount = moment?.favoriteCount?.plus(1) ?: 0, isFavorite = true) + } + } + + suspend fun unfavoriteMoment() { + moment?.let { + service.unfavoriteMoment(it.id) + moment = moment?.copy( + favoriteCount = moment?.favoriteCount?.minus(1) ?: 0, + isFavorite = false + ) + } + } + + suspend fun followUser() { + accountProfileEntity?.let { + userService.followUser(it.id.toString()) + accountProfileEntity = accountProfileEntity?.copy(isFollowing = true) + } + } + + suspend fun unfollowUser() { + accountProfileEntity?.let { + userService.unFollowUser(it.id.toString()) + accountProfileEntity = accountProfileEntity?.copy(isFollowing = false) + } + } + + var avatar: String? = null + get() { + accountProfileEntity?.avatar?.let { + return it + } + moment?.avatar?.let { + return it + } + return field + } + var nickname: String? = null + get() { + accountProfileEntity?.nickName?.let { + return it + } + moment?.nickname?.let { + return it + } + return field + } + + +} + +@Composable +fun PostScreen( + id: String, +) { + val viewModel = PostViewModel + val scope = rememberCoroutineScope() + + val commentsPagging = viewModel.commentsFlow.collectAsLazyPagingItems() + var showCollapseContent by remember { mutableStateOf(true) } + val scrollState = rememberLazyListState() + val uiController = rememberSystemUiController() + LaunchedEffect(Unit) { + uiController.setNavigationBarColor(Color.Transparent) + viewModel.initData() + } + StatusBarMaskLayout { + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + BottomNavigationBar( + onLikeClick = { + scope.launch { + if (viewModel.moment?.liked == true) { + viewModel.dislikeMoment() + } else { + viewModel.likeMoment() + } + } + }, + onCreateComment = { + scope.launch { + viewModel.createComment(it) + commentsPagging.refresh() + } + }, + onFavoriteClick = { + scope.launch { + if (viewModel.moment?.isFavorite == true) { + viewModel.unfavoriteMoment() + } else { + viewModel.favoriteMoment() + } + } + }, + momentEntity = viewModel.moment + ) + } + ) { + it + Column( + modifier = Modifier + .fillMaxSize() + ) { + Header( + avatar = viewModel.avatar, + nickname = viewModel.nickname, + userId = viewModel.moment?.authorId, + isFollowing = viewModel.accountProfileEntity?.isFollowing ?: false, + onFollowClick = { + scope.launch { + if (viewModel.accountProfileEntity?.isFollowing == true) { + viewModel.unfollowUser() + } else { + viewModel.followUser() + } + } + } + ) + Column(modifier = Modifier.animateContentSize()) { + AnimatedVisibility(visible = showCollapseContent) { + // collapse content + Column( + modifier = Modifier + .fillMaxWidth() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + ) { + PostImageView( + id, + viewModel.moment?.images ?: emptyList() + ) + + } + PostDetails( + id, + viewModel.moment + ) + } + } + } + Box( + modifier = Modifier + .fillMaxWidth() + + ) { + CommentsSection( + lazyPagingItems = commentsPagging, + scrollState, + onLike = { commentEntity: CommentEntity -> + scope.launch { + if (commentEntity.liked) { + viewModel.unlikeComment(commentEntity.id) + } else { + viewModel.likeComment(commentEntity.id) + } + } + }) { + showCollapseContent = it + } + } + } + } + } +} + +@Composable +fun Header( + avatar: String?, + nickname: String?, + userId: Int?, + isFollowing: Boolean, + onFollowClick: () -> Unit +) { + val navController = LocalNavController.current + val context = LocalContext.current + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_back), // Replace with your image resource + contentDescription = "Back", + modifier = Modifier + .noRippleClickable { + navController.popBackStack() + } + .size(32.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(Color.Gray.copy(alpha = 0.1f)) + ) { + CustomAsyncImage( + context, + avatar, + contentDescription = "Profile Picture", + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .noRippleClickable { + userId?.let { + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + userId.toString() + ) + ) + } + }, + contentScale = ContentScale.Crop + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + Text(text = nickname ?: "", fontWeight = FontWeight.Bold) + Box( + modifier = Modifier + .height(20.dp) + .wrapContentWidth() + .padding(start = 6.dp) + .noRippleClickable { + onFollowClick() + }, + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier.height(18.dp), + painter = painterResource(id = R.drawable.follow_bg), + contentDescription = "" + ) + Text( + text = if (isFollowing) "Following" else "Follow", + fontSize = 12.sp, + color = Color.White, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + } + } +} + + +@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class) +@Composable +fun PostImageView( + postId: String, + images: List, +) { + val pagerState = rememberPagerState(pageCount = { images.size }) + val navController = LocalNavController.current + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedContentScope.current + val context = LocalContext.current + + Column { + HorizontalPager( + state = pagerState, + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(Color.Gray.copy(alpha = 0.1f)), + ) { page -> + val image = images[page] + with(sharedTransitionScope) { + CustomAsyncImage( + context, + image.thumbnail, + contentDescription = "Image", + contentScale = ContentScale.Crop, + modifier = Modifier + .sharedElement( + rememberSharedContentState(key = image), + animatedVisibilityScope = animatedVisibilityScope + ) + .fillMaxSize() + .noRippleClickable { + ImageViewerViewModel.asNew(images, page) + navController.navigate( + NavigationRoute.ImageViewer.route + ) + } + ) + } + } + + // Indicator container + Row( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + images.forEachIndexed { index, _ -> + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + + .background( + if (pagerState.currentPage == index) Color.Red else Color.Gray.copy( + alpha = 0.5f + ) + ) + .padding(4.dp) + + + ) + Spacer(modifier = Modifier.width(8.dp)) + } + } + } + +} + +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun PostDetails( + postId: String, + momentEntity: MomentEntity? +) { + + Column( + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + .wrapContentHeight() + ) { + + Text( + text = momentEntity?.momentTextContent ?: "", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + ) + Text(text = "${momentEntity?.time?.formatPostTime()} 发布") + Spacer(modifier = Modifier.height(8.dp)) + Text(text = "${momentEntity?.commentCount ?: 0} Comments") + + } +} + +@Composable +fun CommentsSection( + lazyPagingItems: LazyPagingItems, + scrollState: LazyListState = rememberLazyListState(), + onLike: (CommentEntity) -> Unit, + onWillCollapse: (Boolean) -> Unit +) { + LazyColumn( + state = scrollState, modifier = Modifier + .padding(start = 16.dp, end = 16.dp) + .fillMaxHeight() + ) { + items(lazyPagingItems.itemCount) { idx -> + val item = lazyPagingItems[idx] ?: return@items + CommentItem(item, onLike = { + onLike(item) + }) + } + } + + // Detect scroll direction and update showCollapseContent + val coroutineScope = rememberCoroutineScope() + LaunchedEffect(scrollState) { + coroutineScope.launch { + snapshotFlow { scrollState.firstVisibleItemScrollOffset } + .collect { offset -> + onWillCollapse(offset == 0) + } + } + } +} + + +@Composable +fun CommentItem(commentEntity: CommentEntity, onLike: () -> Unit = {}) { + val context = LocalContext.current + Column { + Row(modifier = Modifier.padding(vertical = 8.dp)) { + CustomAsyncImage( + context, + commentEntity.avatar, + contentDescription = "Comment Profile Picture", + modifier = Modifier + .size(40.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text(text = commentEntity.name, fontWeight = FontWeight.Bold) + Text(text = commentEntity.comment) + Text(text = commentEntity.date.timeAgo(), fontSize = 12.sp, color = Color.Gray) + } + Spacer(modifier = Modifier.weight(1f)) + Column(horizontalAlignment = Alignment.CenterHorizontally) { + IconButton(onClick = { + onLike() + }) { + Icon( + Icons.Filled.Favorite, + contentDescription = "Like", + tint = if (commentEntity.liked) Color.Red else Color.Gray + ) + } + Text(text = commentEntity.likes.toString()) + } + } + Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier.padding(start = 16.dp) + ) { + commentEntity.replies.forEach { reply -> + CommentItem(reply) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomNavigationBar( + onCreateComment: (String) -> Unit = {}, + onLikeClick: () -> Unit = {}, + onFavoriteClick: () -> Unit = {}, + momentEntity: MomentEntity? +) { + var showCommentModal by remember { mutableStateOf(false) } + if (showCommentModal) { + ModalBottomSheet( + onDismissRequest = { showCommentModal = false }, + containerColor = Color.White, + sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ), + dragHandle = {}, + shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), + windowInsets = WindowInsets(0) + ) { + EditCommentBottomModal() { + onCreateComment(it) + showCommentModal = false + } + } + } + Column( + modifier = Modifier.background(Color.White) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .background(Color.White) + ) { + // grey round box + Box( + modifier = Modifier + .padding(8.dp) + .clip(RoundedCornerShape(16.dp)) + .background(Color.Gray.copy(alpha = 0.1f)) + .weight(1f) + .height(31.dp) + .padding(8.dp) + .noRippleClickable { + showCommentModal = true + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Filled.Edit, contentDescription = "Send") + Spacer(modifier = Modifier.width(8.dp)) + Text(text = "说点什么", fontSize = 12.sp) + } + } + + IconButton( + onClick = { + onLikeClick() + }) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.rider_pro_like), + contentDescription = "like", + tint = if (momentEntity?.liked == true) Color.Red else Color.Gray + ) + } + Text(text = momentEntity?.likeCount.toString()) + IconButton( + onClick = { + onFavoriteClick() + } + ) { + Icon( + imageVector = ImageVector.vectorResource(id = R.drawable.rider_pro_favoriate), + contentDescription = "Favourite", + tint = if (momentEntity?.isFavorite == true) Color.Red else Color.Gray + ) + } + Text(text = momentEntity?.favoriteCount.toString()) +// IconButton( +// onClick = { /*TODO*/ }) { +// Icon(Icons.Filled.CheckCircle, contentDescription = "Send") +// } +// Text(text = "2077") + + } + BottomNavigationPlaceholder( + color = Color.White + ) + } + +} + +### ui/post/SelectLocationModal.kt ### +package com.aiosman.riderpro.ui.post + +import android.util.Log +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.riderpro.R +import com.google.android.gms.common.api.ApiException +import com.google.android.libraries.places.api.Places +import com.google.android.libraries.places.api.model.Place +import com.google.android.libraries.places.api.net.PlacesClient +import com.google.android.libraries.places.api.net.SearchByTextRequest + +data class SearchPlaceAddressResult( + val name: String, + val address: String +) + +@Composable +fun SelectLocationModal( + onClose: () -> Unit, + onSelectedLocation: (SearchPlaceAddressResult) -> Unit +) { + val context = LocalContext.current + var queryString by remember { mutableStateOf("") } + var searchPlaceAddressResults by remember { + mutableStateOf>( + emptyList() + ) + } + + fun searchAddrWithGoogleMap(query: String) { + val placesClient: PlacesClient = Places.createClient(context) + val placeFields: List = + listOf(Place.Field.ID, Place.Field.NAME, Place.Field.ADDRESS) + val request = SearchByTextRequest.newInstance(query, placeFields) + placesClient.searchByText(request) + .addOnSuccessListener { response -> + val place = response.places + searchPlaceAddressResults = place.map { + SearchPlaceAddressResult(it.name ?: "", it.address ?: "") + } + + }.addOnFailureListener { exception -> + if (exception is ApiException) { + Log.e("SelectLocationModal", "Place not found: ${exception.statusCode}") + } + } + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 10.dp) + ) { + Text( + "Check In", + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.Center), + fontSize = 16.sp + ) + Text( + "Cancel", + modifier = Modifier + .align(Alignment.CenterEnd) + .clickable { + onClose() + }, + fontSize = 16.sp + ) + } + LocationSearchTextInput(queryString, onQueryClick = { + searchAddrWithGoogleMap(queryString) + }) { + queryString = it + } + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(top = 28.dp) + ) { + item { + for (searchPlaceAddressResult in searchPlaceAddressResults) { + LocationItem(searchPlaceAddressResult) { + onSelectedLocation(searchPlaceAddressResult) + } + } + } + } + + } +} + +@Composable +fun LocationSearchTextInput( + value: String, + onQueryClick: () -> Unit, + onValueChange: (String) -> Unit +) { + val keyboardController = LocalSoftwareKeyboardController.current + + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp) + .clip(shape = RoundedCornerShape(16.dp)) + .background(Color(0xffF5F5F5)) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + + verticalAlignment = Alignment.CenterVertically + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_search_location), + contentDescription = "Search", + modifier = Modifier + .size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + if (value.isEmpty()) { + Text( + "search", + modifier = Modifier.padding(vertical = 16.dp), + color = Color(0xffA0A0A0) + ) + } + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { + onQueryClick() + // hide keyboard + keyboardController?.hide() + + } + ) + + ) + } + + } + +} + +@Composable +fun LocationItem( + searchPlaceAddressResult: SearchPlaceAddressResult, + onLocationItemClick: () -> Unit = {} +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 16.dp) + .clickable { + onLocationItemClick() + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp) + ) { + Text(searchPlaceAddressResult.name, fontWeight = FontWeight.Bold) + Text(searchPlaceAddressResult.address, color = Color(0xFF9a9a9a)) + } + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_next), + contentDescription = "Next", + modifier = Modifier.size(24.dp) + ) + + } + } +} + +### ui/composables/AnimatedCounter.kt ### +package com.aiosman.riderpro.ui.composables + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi +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.animation.with +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.sp + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun AnimatedCounter(count: Int, modifier: Modifier = Modifier, fontSize: Int = 24) { + 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) + } +} + +### ui/composables/AnimatedFavouriteButton.kt ### +package com.aiosman.riderpro.ui.composables + +import androidx.compose.animation.animateColorAsState +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.getValue +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.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +@Composable +fun AnimatedFavouriteIcon( + modifier: Modifier = Modifier, + isFavourite: Boolean = false, + onClick: (() -> Unit)? = null +) { + val animatableRotation = remember { Animatable(0f) } + val animatedColor by animateColorAsState(targetValue = if (isFavourite) Color(0xFFd83737) else Color.Black) + 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 = painterResource(id = R.drawable.rider_pro_favoriate), + contentDescription = "Like", + modifier = modifier.graphicsLayer { + rotationZ = animatableRotation.value + }, + colorFilter = ColorFilter.tint(animatedColor) + ) + } +} + +### ui/composables/AnimatedLikeButton.kt ### +package com.aiosman.riderpro.ui.composables + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +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.graphics.graphicsLayer +import androidx.compose.ui.res.painterResource +import com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +@Composable +fun AnimatedLikeIcon( + modifier: Modifier = Modifier, + liked: Boolean = false, + onClick: (() -> Unit)? = null +) { + val animatableRotation = remember { Animatable(0f) } + val animatedColor by animateColorAsState(targetValue = if (liked) Color(0xFFd83737) else Color.Black) + 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 = painterResource(id = R.drawable.rider_pro_like), + contentDescription = "Like", + modifier = modifier.graphicsLayer { + rotationZ = animatableRotation.value + }, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(animatedColor) + ) + } +} + +### ui/composables/BlurHash.kt ### +package com.aiosman.riderpro.ui.composables + +import android.content.Context +import android.graphics.drawable.BitmapDrawable +import androidx.compose.foundation.layout.BoxWithConstraints +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.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.core.graphics.drawable.toDrawable +import coil.ImageLoader +import coil.annotation.ExperimentalCoilApi +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.aiosman.riderpro.utils.BlurHashDecoder +import com.aiosman.riderpro.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 + ) +} + +### ui/composables/BottomNavigationPlaceholder.kt ### +package com.aiosman.riderpro.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) + ) +} + +### ui/composables/EditCommentBottomModal.kt ### +package com.aiosman.riderpro.ui.composables + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.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.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +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.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 com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.modifiers.noRippleClickable + +@Composable +fun EditCommentBottomModal(onSend: (String) -> Unit = {}) { + var text by remember { mutableStateOf("") } + var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xfff7f7f7)) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(20.dp)) + .background(Color(0xffe5e5e5)) + .padding(horizontal = 16.dp, vertical = 12.dp) + ) { + BasicTextField( + value = text, + onValueChange = { + text = it + }, + modifier = Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + textStyle = TextStyle( + color = Color.Black, + fontWeight = FontWeight.Normal + ), + minLines = 5 + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Image( + painter = painterResource(id = R.drawable.rider_pro_send), + contentDescription = "Send", + modifier = Modifier + .size(32.dp) + .noRippleClickable { + onSend(text) + text = "" + }, + ) + } + Spacer(modifier = Modifier.height(navBarHeight)) + } +} + +### ui/composables/Image.kt ### +package com.aiosman.riderpro.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 androidx.core.graphics.drawable.toDrawable +import coil.ImageLoader +import coil.compose.AsyncImage +import coil.request.ImageRequest +import coil.request.SuccessResult +import com.aiosman.riderpro.utils.BlurHashDecoder +import com.aiosman.riderpro.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(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, + imageUrl: String?, + contentDescription: String?, + modifier: Modifier = Modifier, + blurHash: String? = null, + @DrawableRes + placeholderRes: Int? = null, + contentScale: ContentScale = ContentScale.Crop +) { + val imageLoader = getImageLoader(context) + val blurBitmap = remember(blurHash) { + blurHash?.let { + BlurHashDecoder.decode( + blurHash = it, + width = DEFAULT_HASHED_BITMAP_WIDTH, + height = DEFAULT_HASHED_BITMAP_HEIGHT + ) + } + } + +// var bitmap by remember(imageUrl) { mutableStateOf(null) } +// +// LaunchedEffect(imageUrl) { +// if (bitmap == null) { +// val request = ImageRequest.Builder(context) +// .data(imageUrl) +// .crossfade(3000) +// .build() +// +// val result = withContext(Dispatchers.IO) { +// (imageLoader.execute(request) as? SuccessResult)?.drawable?.toBitmap() +// } +// bitmap = result +// } +// } + + AsyncImage( + model = ImageRequest.Builder(context) + .data(imageUrl) + .crossfade(200) + .apply { + if (placeholderRes != null) { + placeholder(placeholderRes) + return@apply + } + if (blurBitmap != null) { + placeholder(blurBitmap.toDrawable(context.resources)) + } + + } + .build(), + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + imageLoader = imageLoader + ) +} + +### ui/composables/RelPostCard.kt ### +package com.aiosman.riderpro.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 coil.compose.AsyncImage +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.ui.index.tabs.moment.MomentTopRowGroup + +@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 + ) + } + } + } +} + +### ui/composables/StatusBarMask.kt ### +package com.aiosman.riderpro.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.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 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(Color.White) + ) + } + } +} + +### ui/comment/CommentModal.kt ### +package com.aiosman.riderpro.ui.comment + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.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.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +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.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.riderpro.ui.post.CommentsSection +import com.aiosman.riderpro.R +import com.aiosman.riderpro.entity.CommentEntity +import com.aiosman.riderpro.entity.CommentPagingSource +import com.aiosman.riderpro.data.CommentRemoteDataSource +import com.aiosman.riderpro.data.CommentService +import com.aiosman.riderpro.data.CommentServiceImpl +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +/** + * 评论弹窗的 ViewModel + */ +class CommentModalViewModel( + val postId: Int? +) : ViewModel() { + val commentService: CommentService = CommentServiceImpl() + val commentsFlow: Flow> = Pager( + config = PagingConfig(pageSize = 20, enablePlaceholders = false), + pagingSourceFactory = { + CommentPagingSource( + CommentRemoteDataSource(commentService), + postId + ) + } + ).flow.cachedIn(viewModelScope) + + /** + * 创建评论 + */ + suspend fun createComment(content: String) { + postId?.let { + commentService.createComment(postId, content) + } + } +} + + +/** + * 评论弹窗 + * @param postId 帖子ID + * @param onCommentAdded 评论添加回调 + * @param onDismiss 关闭回调 + */ +@Composable +fun CommentModalContent( + postId: Int? = null, + onCommentAdded: () -> Unit = {}, + onDismiss: () -> Unit = {} +) { + val model = viewModel( + key = "CommentModalViewModel_$postId", + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return CommentModalViewModel(postId) as T + } + } + ) + var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + + + val scope = rememberCoroutineScope() + val comments = model.commentsFlow.collectAsLazyPagingItems() + val insets = WindowInsets + val imePadding = insets.ime.getBottom(density = LocalDensity.current) + var bottomPadding by remember { mutableStateOf(0.dp) } + LaunchedEffect(imePadding) { + bottomPadding = imePadding.dp + } + DisposableEffect(Unit) { + onDispose { + onDismiss() + } + } + var commentText by remember { mutableStateOf("") } + suspend fun sendComment() { + if (commentText.isNotEmpty()) { + model.createComment(commentText) + } + comments.refresh() + onCommentAdded() + } + Column( + modifier = Modifier + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, bottom = 16.dp, end = 16.dp) + + ) { + Text( + "评论", + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.Center) + ) + } + + HorizontalDivider( + color = Color(0xFFF7F7F7) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .weight(1f) + ) { + CommentsSection(lazyPagingItems = comments, onLike = { commentEntity: CommentEntity -> + scope.launch { + if (commentEntity.liked) { + model.commentService.dislikeComment(commentEntity.id) + } else { + model.commentService.likeComment(commentEntity.id) + } + comments.refresh() + } + }) { + } + + } + Column( + modifier = Modifier + .fillMaxWidth() + .background(Color(0xfff7f7f7)) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .height(64.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically + ) { + // rounded + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .clip(RoundedCornerShape(20.dp)) + .background(Color(0xffe5e5e5)) + .padding(horizontal = 16.dp, vertical = 12.dp) + + ) { + BasicTextField( + value = commentText, + onValueChange = { text -> commentText = text }, + modifier = Modifier + .fillMaxWidth(), + textStyle = TextStyle( + color = Color.Black, + fontWeight = FontWeight.Normal + ) + ) + + } + Spacer(modifier = Modifier.width(16.dp)) + Image( + painter = painterResource(id = R.drawable.rider_pro_send), + contentDescription = "Send", + modifier = Modifier + .size(32.dp) + .noRippleClickable { + scope.launch { + sendComment() + } + } + ) + } + + + } + Spacer(modifier = Modifier.height(navBarHeight)) + + } + + + } + +} + + + + +### ui/comment/CommentsScreen.kt ### +package com.aiosman.riderpro.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.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.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +import com.aiosman.riderpro.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 +) { + val nav = LocalNavController.current + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_back), + contentDescription = title, + modifier = Modifier.size(16.dp).clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + nav.popBackStack() + } + ) + Spacer(modifier = Modifier.size(12.dp)) + Text(title, fontWeight = FontWeight.W800, fontSize = 17.sp) + if (moreIcon) { + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = R.drawable.rider_pro_more_horizon), + contentDescription = "More", + modifier = Modifier.size(24.dp) + ) + } + + + } +} + +@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) + ) + } + } + } +} + +### ui/location/LocationDetailScreen.kt ### +package com.aiosman.riderpro.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.riderpro.LocalNavController +import com.aiosman.riderpro.R + +data class OfficialGalleryItem( + val id: Int, + val resId: Int, +) + +fun getOfficialGalleryItems(): List { + 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 { + 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)) + ) + } + } + } +} + + + +### ui/gallery/Gallery.kt ### +package com.aiosman.riderpro.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.riderpro.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() + } + + } + } +} + +### ui/gallery/OfficialGallery.kt ### +package com.aiosman.riderpro.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.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +import com.aiosman.riderpro.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_nav_back), // 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) + } + } + + } +} + +### ui/gallery/OfficialPhotographer.kt ### +package com.aiosman.riderpro.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.riderpro.R +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder + +data class ArtWork( + val id: Int, + val resId: Int, +) + +fun GenerateMockArtWorks(): List { + 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>(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_nav_back), + 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) + } + } + + } + } + + } + } + +} + +### ui/modification/AddModification.kt ### +package com.aiosman.riderpro.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.riderpro.ui.post.NewPostViewModel +import com.aiosman.riderpro.ui.comment.NoticeScreenHeader +import com.aiosman.riderpro.R +import com.aiosman.riderpro.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) + } + ), + ) + } +} + +### ui/modification/ModificationList.kt ### +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.riderpro.ui.composables.BottomNavigationPlaceholder +import com.aiosman.riderpro.ui.comment.NoticeScreenHeader +import com.aiosman.riderpro.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 { + 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) + } + } +} + + +### ui/follower/FollowerPage.kt ### +package com.aiosman.riderpro.ui.follower + +import androidx.compose.foundation.Image +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.LaunchedEffect +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.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.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.data.AccountFollow +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +import com.aiosman.riderpro.ui.comment.NoticeScreenHeader +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + +/** + * 关注消息列表 + */ +@Composable +fun FollowerScreen() { + val scope = rememberCoroutineScope() + StatusBarMaskLayout( + modifier = Modifier.padding(horizontal = 16.dp) + ) { + val model = FollowerViewModel + var dataFlow = model.followerItemsFlow + var followers = dataFlow.collectAsLazyPagingItems() + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp) + ) { + NoticeScreenHeader("FOLLOWERS") + + } + LaunchedEffect(Unit) { + model.updateNotice() + } + LazyColumn( + modifier = Modifier.weight(1f) + ) { + items(followers.itemCount) { index -> + followers[index]?.let { follower -> + FollowerItem(follower) { + scope.launch { + model.followUser(follower.userId) + } + } + } + } + } + } +} + + +@Composable +fun FollowerItem( + item: AccountFollow, + onFollow: () -> Unit = {} +) { + val context = LocalContext.current + val navController = LocalNavController.current + Box( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + CustomAsyncImage( + context = context, + imageUrl = item.avatar, + contentDescription = item.nickname, + modifier = Modifier + .size(40.dp) + .noRippleClickable { + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + item.userId.toString() + ) + ) + } + ) + Spacer(modifier = Modifier.width(12.dp)) + Column( + modifier = Modifier.weight(1f) + ) { + Text(item.nickname, fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + if (!item.isFollowing) { + Box( + modifier = Modifier.noRippleClickable { + onFollow() + } + ) { + Image( + painter = painterResource(id = R.drawable.follow_bg), + contentDescription = "Follow", + modifier = Modifier + .width(79.dp) + .height(24.dp) + ) + Text( + "FOLLOW", + fontSize = 14.sp, + color = Color(0xFFFFFFFF), + modifier = Modifier.align( + Alignment.Center + ) + ) + } + } + + + } + } +} + +### ui/follower/FollowerViewModel.kt ### +package com.aiosman.riderpro.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.riderpro.data.AccountFollow +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.entity.FollowItemPagingSource +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.data.UserServiceImpl +import com.aiosman.riderpro.data.UserService +import com.aiosman.riderpro.data.api.ApiClient +import com.aiosman.riderpro.data.api.UpdateNoticeRequestBody +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * 关注消息列表的 ViewModel + */ +object FollowerViewModel : ViewModel() { + private val accountService: AccountService = AccountServiceImpl() + private val userService: UserService = UserServiceImpl() + private val _followerItemsFlow = + MutableStateFlow>(PagingData.empty()) + val followerItemsFlow = _followerItemsFlow.asStateFlow() + + init { + 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) + ) + ) + } +} + +### ui/message/Message.kt ### +package com.aiosman.riderpro.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.riderpro.ui.index.tabs.moment.MomentListLoading +import com.aiosman.riderpro.R +import com.aiosman.riderpro.model.ChatNotificationData +import com.aiosman.riderpro.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)) + } + } + } + } +} + +### ui/search/search.kt ### +package com.aiosman.riderpro.ui.search + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +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.Modifier + +@Composable +fun SearchScreen() { + Scaffold { paddingValues -> + var tabIndex by remember { mutableStateOf(0) } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(paddingValues) + ) { + TextField( + value = "", + onValueChange = {}, + label = { Text("Search") }, + modifier = Modifier.fillMaxWidth(), + trailingIcon = { + Icon( + imageVector = Icons.Default.Search, + contentDescription = null + ) + } + ) + TabRow(selectedTabIndex = tabIndex) { + Tab(text = { Text("Post") }, + selected = tabIndex == 0, + onClick = { tabIndex = 0 } + ) + Tab(text = { Text("User") }, + selected = tabIndex == 1, + onClick = { tabIndex = 1 } + ) + } + when (tabIndex) { + 0 -> SearchPostResults() + 1 -> SearchUserResults() + } + } + } +} + +@Composable +fun SearchPostResults() { + Text("Post Results") +} +@Composable +fun SearchUserResults(){ + Text("User Results") +} + +### ui/imageviewer/ImageViewerViewModel.kt ### +package com.aiosman.riderpro.ui.imageviewer + +import com.aiosman.riderpro.entity.MomentImageEntity + +object ImageViewerViewModel { + var imageList = mutableListOf() + var initialIndex = 0 + fun asNew(images: List, index: Int = 0) { + imageList.clear() + imageList.addAll(images) + initialIndex = index + } +} + +### ui/imageviewer/imageviewer.kt ### +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +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.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import coil.compose.AsyncImage +import com.aiosman.riderpro.LocalAnimatedContentScope +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.LocalSharedTransitionScope +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +import com.aiosman.riderpro.ui.imageviewer.ImageViewerViewModel +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import net.engawapg.lib.zoomable.rememberZoomState +import net.engawapg.lib.zoomable.zoomable + + +@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class) +@Composable +fun ImageViewer() { + val model = ImageViewerViewModel + val images = model.imageList + val pagerState = rememberPagerState(pageCount = { images.size }, initialPage = model.initialIndex) + val systemUiController = rememberSystemUiController() + val navController = LocalNavController.current + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedContentScope.current + val context = LocalContext.current + LaunchedEffect(Unit) { + systemUiController.setStatusBarColor(Color.Black) + systemUiController.setNavigationBarColor(Color.Black) + } + StatusBarMaskLayout( + modifier = Modifier.background(Color.Black), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black) + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize() + ) { page -> + val zoomState = rememberZoomState() + with(sharedTransitionScope) { + CustomAsyncImage( + context, + images[page].url, + contentDescription = null, + modifier = Modifier.sharedElement( + rememberSharedContentState(key = images[page]), + animatedVisibilityScope = animatedVisibilityScope + ) + .fillMaxSize() + .zoomable( + zoomState = zoomState, + onTap = { + navController.popBackStack() + } + ), + contentScale = ContentScale.Fit, + ) + } + } + } + + } +} + +### ui/profile/AccountProfile.kt ### +package com.aiosman.riderpro.ui.profile + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +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.unit.dp +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.entity.MomentPagingSource +import com.aiosman.riderpro.entity.MomentRemoteDataSource +import com.aiosman.riderpro.entity.MomentServiceImpl +import com.aiosman.riderpro.data.UserServiceImpl +import com.aiosman.riderpro.data.UserService +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +import com.aiosman.riderpro.ui.index.tabs.profile.CarGroup +import com.aiosman.riderpro.ui.index.tabs.profile.MomentPostUnit +import com.aiosman.riderpro.ui.index.tabs.profile.RidingStyle +import com.aiosman.riderpro.ui.index.tabs.profile.UserInformation +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +@Composable +fun AccountProfile(id:String) { + val userService: UserService = UserServiceImpl() + var userProfile by remember { mutableStateOf(null) } + val momentService = MomentServiceImpl() + var momentsFlow by remember { mutableStateOf>?>(null) } + val scope = rememberCoroutineScope() + LaunchedEffect(Unit) { + userProfile = userService.getUserProfile(id) + momentsFlow = Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + MomentPagingSource( + MomentRemoteDataSource(momentService), + author = id.toInt() + ) + } + ).flow + } + val items = momentsFlow?.collectAsLazyPagingItems() + val systemUiController = rememberSystemUiController() + LaunchedEffect(Unit) { + systemUiController.setNavigationBarColor( + color = androidx.compose.ui.graphics.Color.Transparent + ) + } + StatusBarMaskLayout( + modifier = Modifier.fillMaxSize(), + useNavigationBarMask = false + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + item { + CarGroup() + userProfile?.let { + UserInformation( + isSelf = false, + accountProfileEntity = it, + onFollowClick = { + scope.launch { + if (it.isFollowing) { + userService.unFollowUser(id) + userProfile = userProfile?.copy(isFollowing = false) + } else { + userService.followUser(id) + userProfile = userProfile?.copy(isFollowing = true) + } + } + }, + ) + } +// RidingStyle() + } + if (items != null) { + items(items.itemCount) { idx -> + val momentItem = items[idx] ?: return@items + MomentPostUnit(momentItem) + } + } + } + } + +} + +### ui/profile/AccountProfileViewModel.kt ### +package com.aiosman.riderpro.ui.profile + +object AccountProfileViewModel { +} + +### ui/theme/Color.kt ### +package com.aiosman.riderpro.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) + +### ui/theme/Theme.kt ### +package com.aiosman.riderpro.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun RiderProTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} + +### ui/theme/Type.kt ### +package com.aiosman.riderpro.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) + +### ui/index/Index.kt ### +package com.aiosman.riderpro.ui.index + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +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.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 androidx.navigation.navOptions +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.index.tabs.add.AddPage +import com.aiosman.riderpro.ui.index.tabs.message.NotificationsScreen +import com.aiosman.riderpro.ui.index.tabs.moment.MomentsList +import com.aiosman.riderpro.ui.index.tabs.profile.ProfilePage +import com.aiosman.riderpro.ui.index.tabs.search.SearchScreen +import com.aiosman.riderpro.ui.index.tabs.shorts.ShortVideo +import com.aiosman.riderpro.ui.index.tabs.street.StreetPage +import com.aiosman.riderpro.ui.message.MessagePage +import com.aiosman.riderpro.ui.post.NewPostViewModel +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun IndexScreen() { + 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.Red 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 -> + HorizontalPager( + state = pagerState, + modifier = Modifier.padding(innerPadding), + beyondBoundsPageCount = 5, + userScrollEnabled = false + ) { page -> + when (page) { + 0 -> Home() + 1 -> SearchScreen() + 2 -> Add() + 3 -> Notifications() + 4 -> Profile() + } + } + } +} + +@Composable +fun Home() { + val systemUiController = rememberSystemUiController() + LaunchedEffect(Unit) { + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = true) + } + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + MomentsList() + } +} + + +@Composable +fun Street() { + val systemUiController = rememberSystemUiController() + LaunchedEffect(Unit) { + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = true) + } + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + StreetPage() + } +} + +@Composable +fun Add() { + val systemUiController = rememberSystemUiController() + LaunchedEffect(Unit) { + systemUiController.setStatusBarColor(Color.Black, darkIcons = false) + } + 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, darkIcons = true) + } + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + ProfilePage() + } +} + +@Composable +fun Notifications() { + val systemUiController = rememberSystemUiController() + LaunchedEffect(Unit) { + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = true) + } + Column( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + NotificationsScreen() + } +} + +### ui/index/IndexViewModel.kt ### +package com.aiosman.riderpro.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) +} + +### ui/index/NavigationItem.kt ### +package com.aiosman.riderpro.ui.index + +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.res.vectorResource +import com.aiosman.riderpro.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_home) }, + selectedIcon = { ImageVector.vectorResource(R.drawable.rider_pro_home_filed) } + ) + + 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_moment_add) }, + selectedIcon = { ImageVector.vectorResource(R.drawable.rider_pro_moment_add) } + ) + + 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_notification) }, + selectedIcon = { ImageVector.vectorResource(R.drawable.rider_pro_notification) } + ) + + data object Profile : NavigationItem("Profile", + icon = { ImageVector.vectorResource(R.drawable.rider_pro_profile) }, + selectedIcon = { ImageVector.vectorResource(R.drawable.rider_pro_profile_filed) } + ) + + data object Search : NavigationItem("Search", + icon = { Icons.Default.Search }, + selectedIcon = { Icons.Default.Search } + ) +} + +### ui/index/tabs/moment/Moment.kt ### +package com.aiosman.riderpro.ui.index.tabs.moment + +import androidx.annotation.DrawableRes +import androidx.compose.animation.ExperimentalSharedTransitionApi +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.defaultMinSize +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.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState +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.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.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 androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.riderpro.LocalAnimatedContentScope +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.LocalSharedTransitionScope +import com.aiosman.riderpro.R +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.entity.MomentImageEntity +import com.aiosman.riderpro.exp.timeAgo +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.comment.CommentModalContent +import com.aiosman.riderpro.ui.composables.AnimatedCounter +import com.aiosman.riderpro.ui.composables.AnimatedFavouriteIcon +import com.aiosman.riderpro.ui.composables.AnimatedLikeIcon +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.composables.RelPostCard +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.aiosman.riderpro.ui.post.NewPostViewModel +import com.aiosman.riderpro.ui.post.PostViewModel +import kotlinx.coroutines.launch + +/** + * 动态列表 + */ +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun MomentsList() { + val model = MomentViewModel + var dataFlow = model.momentsFlow + var moments = dataFlow.collectAsLazyPagingItems() + val scope = rememberCoroutineScope() + var refreshing by remember { mutableStateOf(false) } + val state = rememberPullRefreshState(refreshing, onRefresh = { + model.refreshPager() + }) + LaunchedEffect(moments.loadState) { + if (moments.loadState.refresh is LoadState.Loading) { + refreshing = true + } else { + refreshing = false + } + } + + 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) + } + } + } + ) + } + } + PullRefreshIndicator(refreshing, state, Modifier.align(Alignment.TopCenter)) + } +} + +@Composable +fun MomentCard( + momentEntity: MomentEntity, + onLikeClick: () -> Unit = {}, + onFavoriteClick: () -> Unit = {}, + onAddComment: () -> Unit = {}, + hideAction: Boolean = false +) { + val navController = LocalNavController.current + Column( + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) + ) { + MomentTopRowGroup(momentEntity = momentEntity) + } + Column( + modifier = Modifier + .fillMaxWidth() + .noRippleClickable { + PostViewModel.preTransit(momentEntity) + navController.navigate("Post/${momentEntity.id}") + } + ) { + MomentContentGroup(momentEntity = momentEntity) + } + val momentOperateBtnBoxModifier = Modifier + .fillMaxHeight() + .weight(1f) +// ModificationListHeader() + if (!hideAction) { + MomentBottomOperateRowGroup( + momentOperateBtnBoxModifier, + momentEntity = momentEntity, + onLikeClick = onLikeClick, + onAddComment = onAddComment, + onShareClick = { + NewPostViewModel.asNewPost() + NewPostViewModel.relPostId = momentEntity.id + navController.navigate(NavigationRoute.NewPost.route) + }, + onFavoriteClick = onFavoriteClick + ) + } + + } +} + +@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) { + Text( + modifier = Modifier, + textAlign = TextAlign.Start, + text = name, + color = Color(0f, 0f, 0f), + 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) { + Text( + text = location, + color = Color(0f, 0f, 0f, 0.6f), + fontSize = 12.sp + ) +} + +@Composable +fun MomentPostTime(time: String) { + Text( + modifier = Modifier.padding(start = 8.dp), + text = time, color = Color(0f, 0f, 0f, 0.6f), + fontSize = 12.sp + ) +} + +@Composable +fun MomentTopRowGroup(momentEntity: MomentEntity) { + val navController = LocalNavController.current + val context = LocalContext.current + Row( + modifier = Modifier + ) { + CustomAsyncImage( + context, + momentEntity.avatar, + contentDescription = "", + modifier = Modifier + .size(40.dp) + .noRippleClickable { + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + momentEntity.authorId.toString() + ) + ) + }, + contentScale = ContentScale.Crop + ) + Column( + modifier = Modifier + .defaultMinSize() + .padding(start = 12.dp, end = 12.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(22.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MomentName(momentEntity.nickname) +// MomentFollowBtn() + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(21.dp), + verticalAlignment = Alignment.CenterVertically + ) { + MomentPostLocation(momentEntity.location) + MomentPostTime(momentEntity.time.timeAgo()) + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class, ExperimentalSharedTransitionApi::class) +@Composable +fun PostImageView( + postId: String, + images: List, +) { + val pagerState = rememberPagerState(pageCount = { images.size }) + val navController = LocalNavController.current + val sharedTransitionScope = LocalSharedTransitionScope.current + val animatedVisibilityScope = LocalAnimatedContentScope.current + val context = LocalContext.current + + Column( + modifier = Modifier.fillMaxWidth() + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f), + ) { page -> + val image = images[page] +// AsyncBlurImage( +// imageUrl = image.url, +// blurHash = image.blurHash ?: "", +// contentDescription = "Image", +// contentScale = ContentScale.Crop, +// modifier = Modifier +// .sharedElement( +// rememberSharedContentState(key = image), +// animatedVisibilityScope = animatedVisibilityScope +// ) +// .fillMaxSize() +// ) + CustomAsyncImage( + context, + image.thumbnail, + contentDescription = "Image", + blurHash = image.blurHash, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() +// .noRippleClickable { +// ImageViewerViewModel.asNew(images, page) +// navController.navigate( +// NavigationRoute.ImageViewer.route +// ) +// } + ) + + } + + // Indicator container + if (images.size > 1) { + Row( + modifier = Modifier + .padding(8.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + images.forEachIndexed { index, _ -> + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + + .background( + if (pagerState.currentPage == index) Color.Red else Color.Gray.copy( + alpha = 0.5f + ) + ) + .padding(4.dp) + + + ) + Spacer(modifier = Modifier.width(8.dp)) + } + } + } + + } + +} + +@Composable +fun MomentContentGroup( + momentEntity: MomentEntity, +) { + if (momentEntity.momentTextContent.isNotEmpty()) { + Text( + text = momentEntity.momentTextContent, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 8.dp), + fontSize = 16.sp + ) + } + if (momentEntity.relMoment != null) { + RelPostCard( + momentEntity = momentEntity.relMoment!!, + modifier = Modifier.background(Color(0xFFF8F8F8)) + ) + } else { + Box( + modifier = Modifier.fillMaxWidth() + ) { + PostImageView( + postId = momentEntity.id.toString(), + images = momentEntity.images + ) + } + } + +} + + +@Composable +fun MomentOperateBtn(@DrawableRes icon: Int, count: String) { + Row(verticalAlignment = Alignment.CenterVertically) { + Image( + modifier = Modifier + .size(width = 24.dp, height = 24.dp), + painter = painterResource(id = icon), + contentDescription = "" + ) + Text( + text = count, + modifier = Modifier.padding(start = 7.dp), + fontSize = 12.sp, + ) + } +} + +@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) + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MomentBottomOperateRowGroup( + modifier: Modifier, + onLikeClick: () -> Unit = {}, + onAddComment: () -> Unit = {}, + onFavoriteClick: () -> Unit = {}, + onShareClick: () -> Unit = {}, + momentEntity: MomentEntity +) { + 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) + + ) { + + } + } + + ) { +// systemUiController.setNavigationBarColor(Color(0xfff7f7f7)) + CommentModalContent(postId = momentEntity.id, onCommentAdded = { + showCommentModal = false + onAddComment() + }) { +// systemUiController.setNavigationBarColor(Color.Black) + } + } + } + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + ) { + Box( + modifier = modifier, + contentAlignment = Alignment.Center + ) { + MomentOperateBtn(count = momentEntity.likeCount.toString()) { + AnimatedLikeIcon( + modifier = Modifier.size(24.dp), + liked = momentEntity.liked + ) { + onLikeClick() + } + } + } + Box( + modifier = modifier.clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { + showCommentModal = true + }, + contentAlignment = Alignment.Center + ) { + MomentOperateBtn( + icon = R.drawable.rider_pro_moment_comment, + count = momentEntity.commentCount.toString() + ) + } +// Box( +// modifier = modifier.noRippleClickable { +// onShareClick() +// }, +// contentAlignment = Alignment.Center +// ) { +// MomentOperateBtn( +// icon = R.drawable.rider_pro_share, +// count = momentEntity.shareCount.toString() +// ) +// } + Box( + modifier = modifier.noRippleClickable { + onFavoriteClick() + }, + contentAlignment = Alignment.Center + ) { + MomentOperateBtn(count = momentEntity.favoriteCount.toString()) { + AnimatedFavouriteIcon( + modifier = Modifier.size(24.dp), + isFavourite = momentEntity.isFavorite + ) { + onFavoriteClick() + } + } + } + } +} + +@Composable +fun MomentListLoading() { + CircularProgressIndicator( + modifier = + Modifier + .fillMaxWidth() + .wrapContentWidth(Alignment.CenterHorizontally), + color = Color.Red + ) +} + +### ui/index/tabs/moment/MomentViewModel.kt ### +package com.aiosman.riderpro.ui.index.tabs.moment + +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.riderpro.data.AccountService +import com.aiosman.riderpro.entity.MomentPagingSource +import com.aiosman.riderpro.entity.MomentRemoteDataSource +import com.aiosman.riderpro.data.MomentService +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.entity.MomentServiceImpl +import com.aiosman.riderpro.entity.MomentEntity +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + + +object MomentViewModel : ViewModel() { + private val momentService: MomentService = MomentServiceImpl() + private val _momentsFlow = MutableStateFlow>(PagingData.empty()) + val momentsFlow = _momentsFlow.asStateFlow() + val accountService: AccountService = AccountServiceImpl() + init { + viewModelScope.launch { + val profile = accountService.getMyAccountProfile() + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + MomentPagingSource( + MomentRemoteDataSource(momentService), + timelineId = profile.id + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _momentsFlow.value = it + } + } + } + fun refreshPager() { + viewModelScope.launch { + val profile = accountService.getMyAccountProfile() + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + MomentPagingSource( + MomentRemoteDataSource(momentService), + timelineId = profile.id + ) + } + ).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) + } +} + +### ui/index/tabs/message/MessageList.kt ### +package com.aiosman.riderpro.ui.index.tabs.message + +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.RoundedCornerShape +import androidx.compose.material3.HorizontalDivider +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.platform.LocalContext +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 androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.entity.CommentEntity +import com.aiosman.riderpro.exp.timeAgo +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.google.accompanist.systemuicontroller.rememberSystemUiController + + +/** + * 消息列表界面 + */ +@Composable +fun NotificationsScreen() { + val model = MessageListViewModel + val navController = LocalNavController.current + val systemUiController = rememberSystemUiController() + var dataFlow = MessageListViewModel.commentItemsFlow + var comments = dataFlow.collectAsLazyPagingItems() + LaunchedEffect(Unit) { + systemUiController.setNavigationBarColor(Color.Transparent) + MessageListViewModel.initData() + } + Column( + modifier = Modifier.fillMaxSize() + + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_message_title), + contentDescription = "Back", + modifier = Modifier.width(120.dp) + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + NotificationIndicator( + MessageListViewModel.likeNoticeCount, + R.drawable.rider_pro_like, + "LIKE" + ) { + navController.navigate(NavigationRoute.Likes.route) + } + NotificationIndicator( + MessageListViewModel.followNoticeCount, + R.drawable.rider_pro_followers, + "FOLLOWERS" + ) { + navController.navigate(NavigationRoute.Followers.route) + } + NotificationIndicator( + MessageListViewModel.favouriteNoticeCount, + R.drawable.rider_pro_favoriate, + "Favourites" + ) { + navController.navigate(NavigationRoute.FavouritesScreen.route) + } + } + HorizontalDivider(color = Color(0xFFEbEbEb), modifier = Modifier.padding(16.dp)) + NotificationCounterItem(MessageListViewModel.commentNoticeCount) + LazyColumn( + modifier = Modifier + .weight(1f) + .fillMaxSize() + ) { + items(comments.itemCount) { index -> + comments[index]?.let { comment -> + CommentItem(comment) { + MessageListViewModel.updateReadStatus(comment.id) + navController.navigate( + NavigationRoute.Post.route.replace( + "{id}", + comment.postId.toString() + ) + ) + } + } + } + item { + BottomNavigationPlaceholder() + } + } + } + + +} + +@Composable +fun NotificationIndicator( + notificationCount: Int, + iconRes: Int, + label: String, + onClick: () -> Unit +) { + Box( + modifier = Modifier + ) { + Box( + modifier = Modifier + .padding(16.dp) + .align(Alignment.TopCenter) + .noRippleClickable { + onClick() + } + ) { + if (notificationCount > 0) { + Box( + modifier = Modifier + .background(Color(0xFFE53935), RoundedCornerShape(8.dp)) + .padding(4.dp) + .align(Alignment.TopEnd) + ) { + Text( + text = notificationCount.toString(), + color = Color.White, + 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) + ) + } + Box( + modifier = Modifier + ) { + Text(label, modifier = Modifier.align(Alignment.Center)) + } + + } + } + } + +} + +@Composable +fun NotificationCounterItem(count: Int) { + Row( + modifier = Modifier.padding(vertical = 16.dp, horizontal = 32.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_notification), + contentDescription = "", + modifier = Modifier + .size(24.dp) + + ) + + } + Spacer(modifier = Modifier.width(24.dp)) + Text("NOTIFICATIONS", fontSize = 18.sp) + Spacer(modifier = Modifier.weight(1f)) + if (count > 0) { + Box( + modifier = Modifier + .background(Color(0xFFE53935), RoundedCornerShape(16.dp)) + .padding(horizontal = 8.dp, vertical = 2.dp) + ) { + Text( + text = count.toString(), + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(4.dp) + ) + } + } + } +} + + +@Composable +fun CommentItem( + commentItem: CommentEntity, + onPostClick: () -> Unit = {}, +) { + val navController = LocalNavController.current + val context = LocalContext.current + Row( + modifier = Modifier.padding(16.dp) + ) { + Box { + CustomAsyncImage( + context = context, + imageUrl = commentItem.avatar, + contentDescription = commentItem.name, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(4.dp)) + .noRippleClickable { + navController.navigate( + NavigationRoute.AccountProfile.route.replace( + "{id}", + commentItem.author.toString() + ) + ) + } + ) + } + Row( + modifier = Modifier + .weight(1f) + .padding(start = 16.dp) + .noRippleClickable { + onPostClick() + } + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = commentItem.name, + fontSize = 16.sp, + modifier = Modifier.padding(start = 16.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = commentItem.comment, + fontSize = 14.sp, + modifier = Modifier.padding(start = 16.dp), + maxLines = 1, + color = Color(0x99000000) + ) + } +// Spacer(modifier = Modifier.weight(1f)) + Column( + horizontalAlignment = Alignment.End + ) { + Row { + if (commentItem.unread) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFFE53935)) + .padding(4.dp) + ) { + Text( + text = "new", + color = Color.White, + fontSize = 12.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.align(Alignment.Center) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + } + Text( + text = commentItem.date.timeAgo(), + fontSize = 14.sp, + color = Color(0x66000000) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + commentItem.post?.let { + CustomAsyncImage( + context = context, + imageUrl = it.images[0].thumbnail, + contentDescription = "Post Image", + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(4.dp)), + ) + } + } + } + + } +} + +### ui/index/tabs/message/MessageListViewModel.kt ### +package com.aiosman.riderpro.ui.index.tabs.message + +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.riderpro.data.AccountNotice +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.entity.CommentEntity +import com.aiosman.riderpro.entity.CommentPagingSource +import com.aiosman.riderpro.data.CommentRemoteDataSource +import com.aiosman.riderpro.data.CommentService +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.data.CommentServiceImpl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +object MessageListViewModel : ViewModel() { + val accountService: AccountService = AccountServiceImpl() + var noticeInfo by mutableStateOf(null) + + private val commentService: CommentService = CommentServiceImpl() + private val _commentItemsFlow = MutableStateFlow>(PagingData.empty()) + val commentItemsFlow = _commentItemsFlow.asStateFlow() + + suspend fun initData() { + val info = accountService.getMyNoticeInfo() + noticeInfo = info + } + + val likeNoticeCount + get() = noticeInfo?.likeCount ?: 0 + val followNoticeCount + get() = noticeInfo?.followCount ?: 0 + val favouriteNoticeCount + get() = noticeInfo?.favoriteCount ?: 0 + val commentNoticeCount + get() = noticeInfo?.commentCount ?: 0 + + init { + viewModelScope.launch { + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + CommentPagingSource( + CommentRemoteDataSource(commentService), + selfNotice = true + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _commentItemsFlow.value = it + } + } + } + + 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) + } + } +} + +### ui/index/tabs/search/SearchScreen.kt ### +package com.aiosman.riderpro.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.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.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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +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.riderpro.LocalNavController +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.index.tabs.moment.MomentCard +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.launch + + +@OptIn(ExperimentalFoundationApi::class) +@Preview +@Composable +fun SearchScreen() { + val model = SearchViewModel + val categories = listOf("Moment", "User") + val coroutineScope = rememberCoroutineScope() + val pagerState = rememberPagerState(pageCount = { categories.size }) + val selectedTabIndex = remember { derivedStateOf { pagerState.currentPage } } + val keyboardController = LocalSoftwareKeyboardController.current + val systemUiController = rememberSystemUiController() + LaunchedEffect(Unit) { + systemUiController.setStatusBarColor(Color.Transparent, darkIcons = true) + } + Column( + modifier = Modifier + .background(Color.White) + .fillMaxSize() + + ) { + SearchInput( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp, start = 24.dp, end = 24.dp), + text = model.searchText, + onTextChange = { + model.searchText = it + }, + onSearch = { + model.search() + // hide ime + keyboardController?.hide() // Hide the keyboard + } + ) + if (model.showResult) { + Spacer(modifier = Modifier.padding(8.dp)) + + TabRow( + selectedTabIndex = selectedTabIndex.value, + backgroundColor = Color.White, + ) { + categories.forEachIndexed { index, category -> + Tab( + selected = selectedTabIndex.value == index, + onClick = { + coroutineScope.launch { + pagerState.animateScrollToPage(index) + } + }, + text = { Text(category) } + ) + } + } + + SearchPager( + pagerState = pagerState + ) + } + + + } +} + +@Composable +fun SearchInput( + modifier: Modifier = Modifier, + text: String = "", + onTextChange: (String) -> Unit = {}, + onSearch: () -> Unit = {} +) { + Box( + modifier = modifier + .clip(shape = RoundedCornerShape(8.dp)) + + .background(Color(0xFFEEEEEE)) + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Search, + contentDescription = null + ) + Box { + if (text.isEmpty()) { + Text( + text = "Search", + modifier = Modifier.padding(start = 8.dp), + color = Color(0xFF9E9E9E), + fontSize = 18.sp + ) + } + BasicTextField( + value = text, + onValueChange = { + onTextChange(it) + }, + modifier = Modifier + .padding(start = 8.dp) + .fillMaxWidth(), + singleLine = true, + textStyle = TextStyle( + fontSize = 18.sp + ), + keyboardOptions = KeyboardOptions.Default.copy( + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { + onSearch() + } + ) + ) + } + } + + + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SearchPager( + pagerState: PagerState, +) { + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + + ) { page -> + when (page) { + 0 -> MomentResultTab() + 1 -> UserResultTab() + } + } +} + +@Composable +fun MomentResultTab() { + val model = SearchViewModel + var dataFlow = model.momentsFlow + var moments = dataFlow.collectAsLazyPagingItems() + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(moments.itemCount) { idx -> + val momentItem = moments[idx] ?: return@items + Spacer(modifier = Modifier.padding(8.dp)) + MomentCard(momentEntity = momentItem, hideAction = true) + } + } + } +} + +@Composable +fun UserResultTab() { + val model = SearchViewModel + val users = model.usersFlow.collectAsLazyPagingItems() + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + items(users.itemCount) { idx -> + val userItem = users[idx] ?: return@items + UserItem(userItem) + } + } + } +} + +@Composable +fun UserItem(accountProfile: AccountProfileEntity) { + val context = LocalContext.current + val navController = LocalNavController.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(64.dp) + .clip(CircleShape), + contentDescription = null + ) + Spacer(modifier = Modifier.padding(16.dp)) + Text(text = accountProfile.nickName, fontSize = 18.sp, fontWeight = FontWeight.Bold) + } +} + +### ui/index/tabs/search/SearchViewModel.kt ### +package com.aiosman.riderpro.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 com.aiosman.riderpro.entity.AccountPagingSource +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.entity.MomentPagingSource +import com.aiosman.riderpro.entity.MomentRemoteDataSource +import com.aiosman.riderpro.data.MomentService +import com.aiosman.riderpro.entity.MomentServiceImpl +import com.aiosman.riderpro.data.UserServiceImpl +import com.aiosman.riderpro.entity.MomentEntity +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.empty()) + val momentsFlow = _momentsFlow.asStateFlow() + + private val userService = UserServiceImpl() + private val _usersFlow = MutableStateFlow>(PagingData.empty()) + val usersFlow = _usersFlow.asStateFlow() + var showResult by mutableStateOf(false) + fun search() { + 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 + } +} + +### ui/index/tabs/add/AddPage.kt ### +package com.aiosman.riderpro.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.riderpro.LocalNavController +import com.aiosman.riderpro.ui.post.NewPostViewModel +import com.aiosman.riderpro.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 = "Rider Share") { + 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)) + } +} + +### ui/index/tabs/profile/MyProfileViewModel.kt ### +package com.aiosman.riderpro.ui.index.tabs.profile + +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.riderpro.AppStore +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.data.MomentService +import com.aiosman.riderpro.data.UserServiceImpl +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.entity.MomentPagingSource +import com.aiosman.riderpro.entity.MomentRemoteDataSource +import com.aiosman.riderpro.entity.MomentServiceImpl +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +object MyProfileViewModel : ViewModel() { + val accountService: AccountService = AccountServiceImpl() + val momentService: MomentService = MomentServiceImpl() + val userService = UserServiceImpl() + var profile by mutableStateOf(null) + private var _momentsFlow = MutableStateFlow>(PagingData.empty()) + var momentsFlow = _momentsFlow.asStateFlow() + fun loadProfile(){ + +// momentsFlow = Pager( +// config = PagingConfig(pageSize = 5, enablePlaceholders = false), +// pagingSourceFactory = { +// MomentPagingSource( +// MomentRemoteDataSource(MomentServiceImpl()), +// author = profile?.id ?: 0, +// +// ) +// } +// ).flow.cachedIn(viewModelScope).collectLatest { +// MomentViewModel._momentsFlow.value = it +// } + viewModelScope.launch { + profile = accountService.getMyAccountProfile() + val profile = accountService.getMyAccountProfile() + Pager( + config = PagingConfig(pageSize = 5, enablePlaceholders = false), + pagingSourceFactory = { + MomentPagingSource( + MomentRemoteDataSource(momentService), + author = profile.id + ) + } + ).flow.cachedIn(viewModelScope).collectLatest { + _momentsFlow.value = it + } + } + } + + suspend fun logout() { + accountService.getMyAccountProfile() + AppStore.apply { + token = null + rememberMe = false + saveData() + } + + } + + val followerCount get() = profile?.followerCount ?: 0 + val followingCount get() = profile?.followingCount ?: 0 + val bio get() = profile?.bio ?: "" + val nickName get() = profile?.nickName ?: "" + val avatar get() = profile?.avatar + + +} + +### ui/index/tabs/profile/Profile.kt ### +package com.aiosman.riderpro.ui.index.tabs.profile + +import android.util.Log +import androidx.annotation.DrawableRes +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.Image +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.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.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.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.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 androidx.navigation.NavController +import androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.riderpro.LocalAnimatedContentScope +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.LocalSharedTransitionScope +import com.aiosman.riderpro.R +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.exp.formatPostTime +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.index.tabs.moment.MomentCard +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.aiosman.riderpro.ui.post.PostViewModel +import kotlinx.coroutines.launch + + +@Composable +fun ProfilePage() { + val model = MyProfileViewModel + var expanded by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + Log.d("ProfilePage", "loadProfile") + model.loadProfile() + } + val moments = model.momentsFlow.collectAsLazyPagingItems() + val navController: NavController = LocalNavController.current + val scope = rememberCoroutineScope() + + LazyColumn( + modifier = Modifier + .fillMaxSize(), + ) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) { + Box( + modifier = Modifier.align(Alignment.TopEnd) + ) { + Icon( + painter = painterResource(id = R.drawable.rider_pro_more_horizon), + contentDescription = "", + modifier = Modifier.noRippleClickable { + expanded = true + } + ) + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false } + ) { + DropdownMenuItem(onClick = { + scope.launch { + model.logout() + navController.navigate(NavigationRoute.Login.route) { + popUpTo(NavigationRoute.Index.route) { + inclusive = true + } + } + } + }, text = { + Text("Logout") + }) + DropdownMenuItem(onClick = { + scope.launch { + navController.navigate(NavigationRoute.AccountEdit.route) + } + }, text = { + Text("Edit") + }) + DropdownMenuItem(onClick = { + scope.launch { + navController.navigate(NavigationRoute.ChangePasswordScreen.route) + } + }, text = { + Text("Change password") + }) + } + } + + } + CarGroup() + model.profile?.let { + UserInformation(accountProfileEntity = it) + } +// RidingStyle() + } + + items(moments.itemCount) { idx -> + val momentItem = moments[idx] ?: return@items + MomentPostUnit(momentItem) +// MomentCard(momentItem) + } + + + } + + +} + +@Composable +fun CarGroup() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 54.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + CarTopInformation() + CarTopPicture() + } +} + +@Composable +fun CarTopInformation() { + Row { + Text( + text = "BMW", + color = Color.Black, + fontSize = 12.sp, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + Text( + modifier = Modifier.padding(start = 4.dp), + text = "/", + color = Color.Gray, + fontSize = 12.sp + ) + Text( + modifier = Modifier.padding(start = 4.dp), + text = "M1000RR", + color = Color.Gray, + fontSize = 12.sp + ) + } +} + +@Composable +fun CarTopPicture() { + Image( + modifier = Modifier + .size(width = 336.dp, height = 224.dp) + .padding(top = 42.dp), + painter = painterResource(id = R.drawable.default_profile_moto), contentDescription = "" + ) +} + +@Composable +fun UserInformation( + isSelf: Boolean = true, + accountProfileEntity: AccountProfileEntity, + onFollowClick: () -> Unit = {} +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp, start = 33.dp, end = 33.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Row(modifier = Modifier.fillMaxWidth()) { + val userInfoModifier = Modifier.weight(1f) + UserInformationFollowers(userInfoModifier, accountProfileEntity) + UserInformationBasic(userInfoModifier, accountProfileEntity) + UserInformationFollowing(userInfoModifier, accountProfileEntity) + } + UserInformationSlogan() + CommunicationOperatorGroup( + isSelf = isSelf, + isFollowing = accountProfileEntity.isFollowing, + onFollowClick = onFollowClick + ) + } +} + +@Composable +fun UserInformationFollowers(modifier: Modifier, accountProfileEntity: AccountProfileEntity) { + Column(modifier = modifier.padding(top = 31.dp)) { + Text( + modifier = Modifier.padding(bottom = 5.dp), + text = accountProfileEntity.followerCount.toString(), + fontSize = 24.sp, + color = Color.Black, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + Spacer( + modifier = Modifier + .size(width = 88.83.dp, height = 1.dp) + .border(width = 1.dp, color = Color.Gray) + .padding(top = 5.dp, bottom = 5.dp) + ) + Text( + modifier = Modifier.padding(top = 5.dp), + text = "FOLLOWERS", + fontSize = 12.sp, + color = Color.Black, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + } +} + +@Composable +fun UserInformationBasic(modifier: Modifier, accountProfileEntity: AccountProfileEntity) { + val context = LocalContext.current + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier.size(width = 112.dp, height = 112.dp), + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier.fillMaxSize(), + painter = painterResource(id = R.drawable.avatar_bold), contentDescription = "" + ) + CustomAsyncImage( + context, + accountProfileEntity.avatar, + modifier = Modifier + .size(width = 88.dp, height = 88.dp) + .clip( + RoundedCornerShape(88.dp) + ), + contentDescription = "", + contentScale = ContentScale.Crop + ) + + } + Text( + modifier = Modifier + .widthIn(max = 220.dp) + .padding(top = 8.dp), + text = accountProfileEntity.nickName, + fontSize = 32.sp, + color = Color.Black, + style = TextStyle(fontWeight = FontWeight.Bold), + textAlign = TextAlign.Center + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = accountProfileEntity.country, + fontSize = 12.sp, + color = Color.Gray + ) +// Text( +// modifier = Modifier.padding(top = 4.dp), +// text = "Member since Jun 4.2019", +// fontSize = 12.sp, +// color = Color.Gray +// ) + } +} + +@Composable +fun UserInformationFollowing(modifier: Modifier, accountProfileEntity: AccountProfileEntity) { + Column( + modifier = modifier.padding(top = 6.dp), + horizontalAlignment = Alignment.End + ) { + Text( + modifier = Modifier.padding(bottom = 5.dp), + text = accountProfileEntity.followingCount.toString(), + fontSize = 24.sp, + color = Color.Black, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + Box( + modifier = Modifier + .size(width = 88.83.dp, height = 1.dp) + .border(width = 1.dp, color = Color.Gray) + + ) + Text( + modifier = Modifier.padding(top = 5.dp), + text = "FOLLOWING", + fontSize = 12.sp, + color = Color.Black, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + } +} + +@Composable +fun UserInformationSlogan() { + val model = MyProfileViewModel + Text( + modifier = Modifier.padding(top = 23.dp), + text = model.bio, + fontSize = 13.sp, + color = Color.Black, + style = TextStyle(fontWeight = FontWeight.Bold) + ) +} + +@Composable +fun CommunicationOperatorGroup( + isSelf: Boolean = true, + isFollowing: Boolean = false, + onFollowClick: () -> Unit +) { + val navController = LocalNavController.current + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp), horizontalArrangement = Arrangement.Center + ) { + if (!isSelf) { + Box( + modifier = Modifier + .size(width = 142.dp, height = 40.dp) + .noRippleClickable { + onFollowClick() + }, + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier.fillMaxSize(), + painter = painterResource(id = R.drawable.rider_pro_profile_follow), + contentDescription = "" + ) + Text( + text = if (isFollowing) "FOLLOWING" else "FOLLOW", + fontSize = 16.sp, + color = Color.White, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + } + } + + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun RidingStyle() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 40.dp, end = 24.dp), + horizontalAlignment = Alignment.Start + ) { + Text( + text = "RIDING STYLES", + fontSize = 18.sp, + color = Color.Black, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + Image( + modifier = Modifier + .padding(top = 4.dp) + .height(8.dp), + painter = painterResource(id = R.drawable.rider_pro_profile_line), + contentDescription = "" + ) + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) { + RidingStyleItem(styleContent = "Cruiser") + RidingStyleItem(styleContent = "Bobber") + RidingStyleItem(styleContent = "Cafe") + RidingStyleItem(styleContent = "Chopper") + RidingStyleItem(styleContent = "Sport") + RidingStyleItem(styleContent = "Vintage") + RidingStyleItem(styleContent = "Trike") + RidingStyleItem(styleContent = "Touring") + } + } +} + +@Composable +fun RidingStyleItem(styleContent: String) { + Box( + modifier = Modifier.padding(bottom = 8.dp, end = 8.dp), + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier.shadow( + ambientColor = Color.Gray, + spotColor = Color(0f, 0f, 0f, 0.2f), + elevation = 20.dp, + ), + painter = painterResource(id = R.drawable.rider_pro_style_wrapper), + contentDescription = "" + ) + Text( + modifier = Modifier.padding(start = 5.dp, end = 5.dp), + text = styleContent, + fontSize = 12.sp, + color = Color.Gray, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + } +} + +@Composable +fun UserMoment(scope: LazyListScope) { + + +// LazyColumn( +// modifier = Modifier +// .fillMaxWidth() +// .weight(1f) +// ){ +// +// +// } +} + +@Composable +fun MomentPostUnit(momentEntity: MomentEntity) { + TimeGroup(momentEntity.time.formatPostTime()) + 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") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 24.dp, top = 40.dp, end = 24.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier.padding(end = 12.dp), + painter = painterResource(id = R.drawable.rider_pro_moment_time_flag), + contentDescription = "" + ) + Text( + text = time, + fontSize = 22.sp, + color = Color.Black, + style = TextStyle(fontWeight = FontWeight.Bold) + ) + } +} + +@Composable +fun ProfileMomentCard( + content: String, + imageUrl: String, + like: String, + comment: String, + momentEntity: MomentEntity +) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 48.dp, top = 18.dp, end = 24.dp) + .border(width = 1.dp, color = Color(0f, 0f, 0f, 0.1f), shape = RoundedCornerShape(6.dp)) + ) { + MomentCardTopContent(content) + MomentCardPicture(imageUrl, momentEntity = momentEntity) + MomentCardOperation(like, comment) + } +} + +@Composable +fun MomentCardTopContent(content: String) { + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.padding(16.dp), + text = content, fontSize = 16.sp, color = Color.Black + ) + } +} + +@Composable +fun MomentCardPicture(imageUrl: String, momentEntity: MomentEntity) { + val navController = LocalNavController.current + val context = LocalContext.current + CustomAsyncImage( + context, + imageUrl, + modifier = Modifier + .fillMaxSize() + .aspectRatio(1f) + .padding(16.dp) + .noRippleClickable { + PostViewModel.preTransit(momentEntity) + navController.navigate( + NavigationRoute.Post.route.replace( + "{id}", + momentEntity.id.toString() + ) + ) + }, + contentDescription = "", + contentScale = ContentScale.Crop + ) + + +} + +@Composable +fun MomentCardOperation(like: String, comment: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically + ) { + Spacer(modifier = Modifier.weight(1f)) + MomentCardOperationItem( + drawable = R.drawable.rider_pro_like, + number = like, + modifier = Modifier.weight(1f) + ) + MomentCardOperationItem( + drawable = R.drawable.rider_pro_moment_comment, + number = comment, + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +fun MomentCardOperationItem(@DrawableRes drawable: Int, number: String, modifier: Modifier) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically + ) { + Image( + modifier = Modifier.padding(start = 16.dp, end = 8.dp), + painter = painterResource(id = drawable), contentDescription = "" + ) + Text(text = number) + } +} + +### ui/index/tabs/shorts/Pager.kt ### +package com.aiosman.riderpro.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 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 +} + + + + + + + + +### ui/index/tabs/shorts/ShortVideo.kt ### +package com.aiosman.riderpro.ui.index.tabs.shorts + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import com.aiosman.riderpro.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 + ) + } + } +} + + + +### ui/index/tabs/shorts/ShortViewCompose.kt ### +@file:kotlin.OptIn(ExperimentalMaterial3Api::class) + +package com.aiosman.riderpro.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.riderpro.R +import com.aiosman.riderpro.ui.comment.CommentModalContent +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Composable +fun ShortViewCompose( + videoItemsUrl: List, + 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, + pauseIconVisibleState: MutableState, + 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, +) { + 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(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) + ) + } +} + + + + + + + + + + + +### ui/index/tabs/street/Street.kt ### +package com.aiosman.riderpro.ui.index.tabs.street + +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.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.LocalContext +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.core.content.ContextCompat +import androidx.navigation.NavOptions +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.test.countries +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationServices +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 + val context = LocalContext.current + val fusedLocationClient: FusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(context) + var currentLocation by remember { mutableStateOf(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("") } + val permissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { isGranted -> + hasLocationPermission = isGranted + if (isGranted) { + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + if (location != null) { + currentLocation = LatLng(location.latitude, location.longitude) + } + } + } + } + ) + + LaunchedEffect(Unit) { + when (PackageManager.PERMISSION_GRANTED) { + ContextCompat.checkSelfPermission( + context, + android.Manifest.permission.ACCESS_FINE_LOCATION + ) -> { + fusedLocationClient.lastLocation.addOnSuccessListener { location -> + if (location != null) { +// currentLocation = LatLng(location.latitude, location.longitude) + } + } + hasLocationPermission = true + } + + else -> { + permissionLauncher.launch(android.Manifest.permission.ACCESS_FINE_LOCATION) + } + } + } + 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 + ) + ) + } + + } + + } + } + } + + } + + +} + +### ui/favourite/FavouritePageViewModel.kt ### +package com.aiosman.riderpro.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.riderpro.entity.AccountFavouriteEntity +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.entity.FavoriteItemPagingSource +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.data.api.ApiClient +import com.aiosman.riderpro.data.api.UpdateNoticeRequestBody +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +/** + * 收藏消息列表的 ViewModel + */ +object FavouritePageViewModel : ViewModel() { + private val accountService: AccountService = AccountServiceImpl() + private val _favouriteItemsFlow = + MutableStateFlow>(PagingData.empty()) + val favouriteItemsFlow = _favouriteItemsFlow.asStateFlow() + + init { + 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) + ) + ) + } +} + +### ui/favourite/FavouriteScreen.kt ### +package com.aiosman.riderpro.ui.favourite + +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.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Color +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 androidx.paging.compose.collectAsLazyPagingItems +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.comment.NoticeScreenHeader +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder +import com.aiosman.riderpro.ui.like.ActionNoticeItem +import com.aiosman.riderpro.ui.like.LikePageViewModel + +/** + * 收藏消息界面 + */ +@Composable +fun FavouriteScreen() { + val model = FavouritePageViewModel + val listState = rememberLazyListState() + var dataFlow = model.favouriteItemsFlow + var favourites = dataFlow.collectAsLazyPagingItems() + LaunchedEffect(Unit) { + model.updateNotice() + } + StatusBarMaskLayout( + darkIcons = true, + 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( + "FAVOURITE", + moreIcon = false + ) + } + LazyColumn( + modifier = Modifier.weight(1f), + state = listState, + ) { + items(favourites.itemCount) { + val favouriteItem = favourites[it] + if (favouriteItem != null) { + ActionNoticeItem( + 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() + } + } + + + } + } + +} + +### ui/account/changepassword.kt ### +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.data.AccountServiceImpl +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 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 + Scaffold { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + verticalArrangement = Arrangement.Center + ) { + TextField( + value = currentPassword, + onValueChange = { currentPassword = it }, + label = { Text("Current Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + TextField( + value = newPassword, + onValueChange = { newPassword = it }, + label = { Text("New Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + TextField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + label = { Text("Confirm New Password") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + if (errorMessage.isNotEmpty()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 16.dp) + ) + } + Button( + onClick = { + if (newPassword == confirmPassword) { + scope.launch { + viewModel.changePassword(currentPassword, newPassword) + navController.popBackStack() + } + } else { + errorMessage = "Passwords do not match" + } + }, + modifier = Modifier.fillMaxWidth() + ) { + Text("Change Password") + } + } + } + +} + +### ui/account/edit.kt ### +package com.aiosman.riderpro.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.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.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.data.UserServiceImpl +import com.aiosman.riderpro.data.UploadImage +import com.aiosman.riderpro.data.UserService +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.aiosman.riderpro.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(null) } + var profile by remember { + mutableStateOf( + null + ) + } + 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 + } + val newName = if (name == profile?.nickName) null else name + + accountService.updateProfile(newAvatar, newName, bio) + reloadProfile() + } + } + + val pickImageLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == Activity.RESULT_OK) { + val uri = result.data?.data + uri?.let { + imageUrl = it + } + } + } + + 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)) + 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() + ) + } + } + } + + +} + +### ui/login/emailsignup.kt ### +package com.aiosman.riderpro.ui.login + +import android.widget.Toast +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.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +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.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aiosman.riderpro.AppStore +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.data.ServiceException +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.comment.NoticeScreenHeader +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +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() + fun validateForm(): Boolean { + if (email.isEmpty()) { + Toast.makeText(context, "Email is required", Toast.LENGTH_SHORT).show() + return false + } + if (password.isEmpty()) { + Toast.makeText(context, "Password is required", Toast.LENGTH_SHORT).show() + return false + } + if (confirmPassword.isEmpty()) { + Toast.makeText(context, "Confirm password is required", Toast.LENGTH_SHORT).show() + return false + } + if (password != confirmPassword) { + Toast.makeText(context, "Password does not match", Toast.LENGTH_SHORT).show() + return false + } + if (!acceptTerms) { + Toast.makeText(context, "You must accept terms", Toast.LENGTH_SHORT).show() + return false + } + if (!acceptPromotions) { + Toast.makeText(context, "You must accept promotions", Toast.LENGTH_SHORT).show() + return false + } + return true + } + + suspend fun registerUser() { + if (!validateForm()) return + // 注册 + try { + accountService.registerUserWithPassword(email, password) + } catch (e: ServiceException) { + scope.launch(Dispatchers.Main) { + Toast.makeText(context, "Failed to register", Toast.LENGTH_SHORT).show() + } + } + // 获取 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 { + accountService.getMyAccount() + } 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 } + } + } + + + + } + + StatusBarMaskLayout { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) { + NoticeScreenHeader("SIGNUP", moreIcon = false) + } + + Spacer(modifier = Modifier.padding(68.dp)) + TextInputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = email, + onValueChange = { + email = it + }, + label = "What's your email?", + hint = "Enter your email" + ) + Spacer(modifier = Modifier.padding(16.dp)) + TextInputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = password, + onValueChange = { + password = it + }, + password = true, + label = "What's your password?", + hint = "Enter your password" + ) + Spacer(modifier = Modifier.padding(16.dp)) + TextInputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = confirmPassword, + onValueChange = { + confirmPassword = it + }, + password = true, + label = "Confirm password", + hint = "Enter your password" + ) + Spacer(modifier = Modifier.height(32.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.Start, + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = rememberMe, + onCheckedChange = { + rememberMe = it + }, + modifier = Modifier.padding(start = 16.dp), + colors = CheckboxDefaults.colors( + checkedColor = Color.Black + ), + ) + Text("Remember me", modifier = Modifier.padding(start = 4.dp)) + } + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = acceptTerms, + onCheckedChange = { + acceptTerms = it + }, + modifier = Modifier.padding(start = 16.dp), + colors = CheckboxDefaults.colors( + checkedColor = Color.Black + ), + ) + Text( + "Yes, I have read and agree to RiderPro’s Terms of Service.", + modifier = Modifier.padding(start = 4.dp), + fontSize = 12.sp + ) + } + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = acceptPromotions, + onCheckedChange = { + acceptPromotions = it + }, + modifier = Modifier.padding(start = 16.dp), + colors = CheckboxDefaults.colors( + checkedColor = Color.Black + ), + ) + Text( + "Yes, Send me news and promotional content from RiderPro.", + modifier = Modifier.padding(start = 4.dp), + fontSize = 12.sp + ) + } + } + + Spacer(modifier = Modifier.height(64.dp)) + ActionButton( + modifier = Modifier + .width(345.dp) + .height(48.dp), + text = "LET'S RIDE".uppercase(), + backgroundImage = R.mipmap.rider_pro_signup_red_bg + ) { + scope.launch(Dispatchers.IO) { + registerUser() + } + } + } + } +} + +### ui/login/login.kt ### +package com.aiosman.riderpro.ui.login + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +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.width +import androidx.compose.material3.Scaffold +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.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.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavOptions +import androidx.navigation.navOptions +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.modifiers.noRippleClickable + +@Composable +fun LoginPage() { + val navController = LocalNavController.current + LaunchedEffect(Unit) { +// navController.navigate(NavigationRoute.Index.route) + } + Scaffold { + 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 = "Rider Pro", + modifier = Modifier + .width(108.dp) + .height(45.dp) + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + "Connecting Riders".uppercase(), + fontSize = 28.sp, + fontWeight = FontWeight.Bold + ) + Text("Worldwide".uppercase(), fontSize = 28.sp, fontWeight = FontWeight.Bold) + } + + } + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 82.dp + 162.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + ActionButton( + modifier = Modifier + .width(162.dp) + .height(48.dp), + text = "Login in".uppercase(), + backgroundImage = R.mipmap.rider_pro_grey_bg_big + ) { + navController.navigate( + NavigationRoute.UserAuth.route, + ) + } + ActionButton( + modifier = Modifier + .width(162.dp) + .height(48.dp), + text = "Sign In".uppercase(), + backgroundImage = R.mipmap.rider_pro_red_bg_big + ){ + navController.navigate( + NavigationRoute.SignUp.route, + ) + } + } + } + } + + } +} + +@Composable +fun ActionButton( + modifier: Modifier, + text: String, + color: Color = Color.White, + @DrawableRes backgroundImage: Int, + leading: @Composable (() -> Unit)? = null, + click: () -> Unit = {} +) { + Box( + modifier = modifier.noRippleClickable { + click() + } + ) { + Image( + painter = painterResource(id = backgroundImage), + contentDescription = "Rider Pro", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.FillBounds + ) + Row( + modifier = Modifier.align(Alignment.Center), + verticalAlignment = Alignment.CenterVertically + ) { + leading?.invoke() + Text(text, fontSize = 16.sp, color = color, fontWeight = FontWeight.W400) + } + + } +} + +### ui/login/signup.kt ### +package com.aiosman.riderpro.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.foundation.shape.RoundedCornerShape +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.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.credentials.Credential +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import com.aiosman.riderpro.AppStore +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.data.ServiceException +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.aiosman.riderpro.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() + + 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 { + accountService.getMyAccount() + } 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(Color(0xFFECECEC)) + ) { + 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 = "Rider Pro", + modifier = Modifier + .width(108.dp) + .height(45.dp) + ) + Spacer(modifier = Modifier.height(32.dp)) + Text( + "Connecting Riders".uppercase(), + fontSize = 28.sp, + fontWeight = FontWeight.Bold + ) + Text("Worldwide".uppercase(), fontSize = 28.sp, fontWeight = FontWeight.Bold) + } + + } + 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 = "CONTINUE WITH EMAIL".uppercase(), + backgroundImage = R.mipmap.rider_pro_signup_red_bg, + 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 = "CONTINUE WITH GOOGLE".uppercase(), + backgroundImage = R.mipmap.rider_pro_signup_white_bg + ) { + 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.popBackStack() + } + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_nav_back), + contentDescription = "Back", + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + "BACK", + color = Color.Black, + fontSize = 16.sp, + fontWeight = FontWeight.Bold + ) + } + } + } + } + + } +} + +### ui/login/userauth.kt ### +package com.aiosman.riderpro.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.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.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.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CheckboxDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +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.graphics.Color +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.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 androidx.credentials.Credential +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import androidx.credentials.exceptions.GetCredentialProviderConfigurationException +import androidx.credentials.exceptions.NoCredentialException +import com.aiosman.riderpro.AppStore +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.data.ServiceException +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.ui.NavigationRoute +import com.aiosman.riderpro.ui.comment.NoticeScreenHeader +import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.aiosman.riderpro.utils.GoogleLogin +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UserAuthScreen() { + var email by remember { mutableStateOf("") } + var password by remember { mutableStateOf("") } + var rememberMe by remember { mutableStateOf(false) } + var accountService: AccountService = AccountServiceImpl() + val scope = rememberCoroutineScope() + val navController = LocalNavController.current + val context = LocalContext.current + val credentialManager = CredentialManager.create(context) + fun validateForm(): Boolean { + if (email.isEmpty()) { + Toast.makeText(context, "Email is required", Toast.LENGTH_SHORT).show() + return false + } + if (password.isEmpty()) { + Toast.makeText(context, "Password is required", Toast.LENGTH_SHORT).show() + return false + } + return true + } + + fun onLogin() { + if (!validateForm()) { + return + } + scope.launch { + try { + val authResp = accountService.loginUserWithPassword(email, password) + 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 + Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show() + } + } + + } + + + 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() + } + + } + + } + StatusBarMaskLayout { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + ) { + NoticeScreenHeader("LOGIN", moreIcon = false) + } + + Spacer(modifier = Modifier.padding(68.dp)) + TextInputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = email, + onValueChange = { + email = it + }, + label = "What's your email?", + hint = "Enter your email" + ) + Spacer(modifier = Modifier.padding(16.dp)) + TextInputField( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp), + text = password, + onValueChange = { + password = it + }, + password = true, + label = "What's your password?", + hint = "Enter your password" + ) + Spacer(modifier = Modifier.height(32.dp)) + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = rememberMe, + onCheckedChange = { + rememberMe = it + }, + modifier = Modifier.padding(start = 24.dp), + colors = CheckboxDefaults.colors( + checkedColor = Color.Black + ), + ) + Text("Remember me", modifier = Modifier.padding(start = 4.dp)) + Spacer(modifier = Modifier.weight(1f)) + Text("Forgot password?", modifier = Modifier.padding(end = 24.dp)) + } + Spacer(modifier = Modifier.height(64.dp)) + ActionButton( + modifier = Modifier + .width(345.dp) + .height(48.dp), + text = "LET'S RIDE".uppercase(), + backgroundImage = R.mipmap.rider_pro_signup_red_bg + ) { + onLogin() + } + + Spacer(modifier = Modifier.height(121.dp)) + Text("or login with", color = Color(0xFF999999)) + Spacer(modifier = Modifier.height(16.dp)) + Row { + Box( + modifier = Modifier + .size(96.dp) + .padding(16.dp) + .border(2.dp, Color(0xFFEBEBEB)) + .noRippleClickable { + // login with facebook + googleLogin() + } + ) { + Image( + painter = painterResource(id = R.drawable.rider_pro_google), + contentDescription = "Google", + modifier = Modifier.fillMaxSize() + ) + } + } + } + } + +} + +@Composable +fun TextInputField( + modifier: Modifier = Modifier, + text: String, + onValueChange: (String) -> Unit, + password: Boolean = false, + label: String? = null, + hint: String? = null + +) { + var showPassword by remember { mutableStateOf(!password) } + Column(modifier = modifier) { + label?.let { + Text(it, color = Color(0xFFCCCCCC)) + Spacer(modifier = Modifier.height(16.dp)) + } + Box( + contentAlignment = Alignment.CenterStart + ) { + Row { + BasicTextField( + value = text, + onValueChange = onValueChange, + modifier = Modifier.weight(1f), + textStyle = TextStyle( + fontSize = 16.sp, + fontWeight = FontWeight.W500 + ), + keyboardOptions = KeyboardOptions( + keyboardType = if (password) KeyboardType.Password else KeyboardType.Text + ), + visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + singleLine = true + ) + if (password) { + Image( + painter = painterResource(id = R.drawable.rider_pro_eye), + contentDescription = "Password", + modifier = Modifier + .size(24.dp) + .noRippleClickable { + showPassword = !showPassword + }, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(Color.Black) + ) + } + } + + if (text.isEmpty()) { + hint?.let { + Text(it, color = Color(0xFFCCCCCC), fontWeight = FontWeight.W600) + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color(0xFFF5F5F5)) + ) + } +} + +### test/TestStreetMap.kt ### +package com.aiosman.riderpro.test + +data class StreetPosition( + val name:String, + val lat:Double, + val lng:Double +) +val countries = listOf( + StreetPosition("哈龙湾, 越南",16.5000, 107.1000), + StreetPosition("芽庄, 越南",12.2500, 109.0833), + StreetPosition("岘港, 越南",16.0667, 108.2167), + StreetPosition("美奈, 越南",11.9333, 108.9833), + StreetPosition("富国岛, 越南",10.0000, 104.0000), + StreetPosition("金三角, 泰国, 缅甸, 老挝",20.2500, 99.7500), + StreetPosition("普吉岛, 泰国",7.9444, 98.3000), + StreetPosition("苏梅岛, 泰国",9.5333, 99.9333), + StreetPosition("曼谷, 泰国",13.7500, 100.5000), + StreetPosition("马六甲, 马来西亚",2.2000, 102.2500), + StreetPosition("兰卡威群岛, 马来西亚",6.3000, 99.9000), + StreetPosition("沙巴, 马来西亚",6.0833, 116.0833), + StreetPosition("巴厘岛, 印度尼西亚",8.3333, 115.1000), + StreetPosition("龙目岛, 印度尼西亚",8.3333, 116.4000), + StreetPosition("婆罗洲, 印度尼西亚",3.0000, 114.0000), + StreetPosition("宿务, 菲律宾",10.3167, 123.8833), + StreetPosition("长滩岛, 菲律宾",11.5833, 121.9167), + StreetPosition("保和岛, 菲律宾",10.3000, 123.3333), + StreetPosition("科隆岛, 菲律宾",5.1167, 119.3333) +) + + +### entity/Account.kt ### +package com.aiosman.riderpro.entity + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.aiosman.riderpro.data.AccountFollow +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.data.Image +import com.aiosman.riderpro.data.api.ApiClient +import java.io.IOException +import java.util.Date + +/** + * 用户点赞 + */ +data class AccountLikeEntity( + // 动态 + val post: NoticePostEntity, + // 点赞用户 + val user: NoticeUserEntity, + // 点赞时间 + val likeTime: Date, +) + +/** + * 用户收藏 + */ +data class AccountFavouriteEntity( + // 动态 + val post: NoticePostEntity, + // 收藏用户 + val user: NoticeUserEntity, + // 收藏时间 + val favoriteTime: Date, +) + +/** + * 用户信息 + */ +data class AccountProfileEntity( + // 用户ID + val id: Int, + // 粉丝数 + val followerCount: Int, + // 关注数 + val followingCount: Int, + // 昵称 + val nickName: String, + // 头像 + val avatar: String, + // 个人简介 + val bio: String, + // 国家 + val country: String, + // 是否关注,针对当前登录用户 + val isFollowing: Boolean +) + +/** + * 消息关联的动态 + */ +data class NoticePostEntity( + // 动态ID + val id: Int, + // 动态内容 + val textContent: String, + // 动态图片 + val images: List, + // 时间 + val time: Date, +) + +/** + * 消息关联的用户 + */ +data class NoticeUserEntity( + // 用户ID + val id: Int, + // 昵称 + val nickName: String, + // 头像 + val avatar: String, +) + +/** + * 用户点赞消息分页数据加载器 + */ +class LikeItemPagingSource( + private val accountService: AccountService, +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + return try { + val currentPage = params.key ?: 1 + val likes = accountService.getMyLikeNotice( + page = currentPage, + pageSize = 20, + ) + + LoadResult.Page( + data = likes.list.map { + it.toAccountLikeEntity() + }, + prevKey = if (currentPage == 1) null else currentPage - 1, + nextKey = if (likes.list.isEmpty()) null else likes.page + 1 + ) + } catch (exception: IOException) { + return LoadResult.Error(exception) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition + } +} + +/** + * 用户收藏消息分页数据加载器 + */ +class FavoriteItemPagingSource( + private val accountService: AccountService, +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + return try { + val currentPage = params.key ?: 1 + val favouriteListContainer = accountService.getMyFavouriteNotice( + page = currentPage, + pageSize = 20, + ) + LoadResult.Page( + data = favouriteListContainer.list.map { + it.toAccountFavouriteEntity() + }, + prevKey = if (currentPage == 1) null else currentPage - 1, + nextKey = if (favouriteListContainer.list.isEmpty()) null else favouriteListContainer.page + 1 + ) + } catch (exception: IOException) { + return LoadResult.Error(exception) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition + } +} + +/** + * 用户关注消息分页数据加载器 + */ +class FollowItemPagingSource( + private val accountService: AccountService, +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + return try { + val currentPage = params.key ?: 1 + val followListContainer = accountService.getMyFollowNotice( + page = currentPage, + pageSize = 20, + ) + + LoadResult.Page( + data = followListContainer.list.map { + it.copy( + avatar = "${ApiClient.BASE_SERVER}${it.avatar}", + ) + }, + prevKey = if (currentPage == 1) null else currentPage - 1, + nextKey = if (followListContainer.list.isEmpty()) null else followListContainer.page + 1 + ) + } catch (exception: IOException) { + return LoadResult.Error(exception) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition + } +} + +### entity/Comment.kt ### +package com.aiosman.riderpro.entity + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.aiosman.riderpro.data.CommentRemoteDataSource +import com.aiosman.riderpro.data.NoticePost +import java.io.IOException +import java.util.Date + +data class CommentEntity( + val id: Int, + val name: String, + val comment: String, + val date: Date, + val likes: Int, + val replies: List, + val postId: Int = 0, + val avatar: String, + val author: Long, + var liked: Boolean, + var unread: Boolean = false, + var post: NoticePost? +) + +class CommentPagingSource( + private val remoteDataSource: CommentRemoteDataSource, + private val postId: Int? = null, + private val postUser: Int? = null, + private val selfNotice: Boolean? = null +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + return try { + val currentPage = params.key ?: 1 + val comments = remoteDataSource.getComments( + pageNumber = currentPage, + postId = postId, + postUser = postUser, + selfNotice = selfNotice + ) + LoadResult.Page( + data = comments.list, + prevKey = if (currentPage == 1) null else currentPage - 1, + nextKey = if (comments.list.isEmpty()) null else comments.page + 1 + ) + } catch (exception: IOException) { + return LoadResult.Error(exception) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition + } + +} + +### entity/Moment.kt ### +package com.aiosman.riderpro.entity + +import androidx.annotation.DrawableRes +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.aiosman.riderpro.data.ListContainer +import com.aiosman.riderpro.data.MomentService +import com.aiosman.riderpro.data.ServiceException +import com.aiosman.riderpro.data.UploadImage +import com.aiosman.riderpro.data.api.ApiClient +import com.aiosman.riderpro.entity.MomentEntity +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File +import java.io.IOException +import java.util.Date + +/** + * 动态分页加载器 + */ +class MomentPagingSource( + private val remoteDataSource: MomentRemoteDataSource, + private val author: Int? = null, + private val timelineId: Int? = null, + private val contentSearch: String? = null +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + return try { + val currentPage = params.key ?: 1 + val moments = remoteDataSource.getMoments( + pageNumber = currentPage, + author = author, + timelineId = timelineId, + contentSearch = contentSearch + ) + + LoadResult.Page( + data = moments.list, + prevKey = if (currentPage == 1) null else currentPage - 1, + nextKey = if (moments.list.isEmpty()) null else moments.page + 1 + ) + } catch (exception: IOException) { + return LoadResult.Error(exception) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition + } + +} + +class MomentRemoteDataSource( + private val momentService: MomentService, +) { + suspend fun getMoments( + pageNumber: Int, + author: Int?, + timelineId: Int?, + contentSearch: String? + ): ListContainer { + return momentService.getMoments(pageNumber, author, timelineId, contentSearch) + } +} + +class MomentServiceImpl() : MomentService { + val momentBackend = MomentBackend() + + override suspend fun getMoments( + pageNumber: Int, + author: Int?, + timelineId: Int?, + contentSearch: String? + ): ListContainer { + return momentBackend.fetchMomentItems(pageNumber, author, timelineId, contentSearch) + } + + override suspend fun getMomentById(id: Int): MomentEntity { + return momentBackend.getMomentById(id) + } + + + override suspend fun likeMoment(id: Int) { + momentBackend.likeMoment(id) + } + + override suspend fun dislikeMoment(id: Int) { + momentBackend.dislikeMoment(id) + } + + override suspend fun createMoment( + content: String, + authorId: Int, + images: List, + relPostId: Int? + ): MomentEntity { + return momentBackend.createMoment(content, authorId, images, relPostId) + } + + override suspend fun favoriteMoment(id: Int) { + momentBackend.favoriteMoment(id) + } + + override suspend fun unfavoriteMoment(id: Int) { + momentBackend.unfavoriteMoment(id) + } + +} + +class MomentBackend { + val DataBatchSize = 20 + suspend fun fetchMomentItems( + pageNumber: Int, + author: Int? = null, + timelineId: Int?, + contentSearch: String? + ): ListContainer { + val resp = ApiClient.api.getPosts( + pageSize = DataBatchSize, + page = pageNumber, + timelineId = timelineId, + authorId = author, + contentSearch = contentSearch + ) + val body = resp.body() ?: throw ServiceException("Failed to get moments") + return ListContainer( + total = body.total, + page = pageNumber, + pageSize = DataBatchSize, + list = body.list.map { it.toMomentItem() } + ) + } + + suspend fun getMomentById(id: Int): MomentEntity { + var resp = ApiClient.api.getPost(id) + var body = resp.body()?.data ?: throw ServiceException("Failed to get moment") + return body.toMomentItem() + } + + suspend fun likeMoment(id: Int) { + ApiClient.api.likePost(id) + } + + suspend fun dislikeMoment(id: Int) { + ApiClient.api.dislikePost(id) + } + + fun createMultipartBody(file: File, name: String): MultipartBody.Part { + val requestFile = RequestBody.create("image/*".toMediaTypeOrNull(), file) + return MultipartBody.Part.createFormData(name, file.name, requestFile) + } + + suspend fun createMoment( + content: String, + authorId: Int, + imageUriList: List, + relPostId: Int? + ): MomentEntity { + val textContent = content.toRequestBody("text/plain".toMediaTypeOrNull()) + val imageList = imageUriList.map { item -> + val file = item.file + createMultipartBody(file, "image") + } + val response = ApiClient.api.createPost(imageList, textContent = textContent) + val body = response.body()?.data ?: throw ServiceException("Failed to create moment") + return body.toMomentItem() + + } + + suspend fun favoriteMoment(id: Int) { + ApiClient.api.favoritePost(id) + } + + suspend fun unfavoriteMoment(id: Int) { + ApiClient.api.unfavoritePost(id) + } + +} + +/** + * 动态图片 + */ +data class MomentImageEntity( + // 图片ID + val id: Long, + // 图片URL + val url: String, + // 缩略图URL + val thumbnail: String, + // 图片BlurHash + val blurHash: String? = null +) + +/** + * 动态 + */ +data class MomentEntity( + // 动态ID + val id: Int, + // 作者头像 + val avatar: String, + // 作者昵称 + val nickname: String, + // 区域 + val location: String, + // 动态时间 + val time: Date, + // 是否关注 + val followStatus: Boolean, + // 动态内容 + val momentTextContent: String, + // 动态图片 + @DrawableRes val momentPicture: Int, + // 点赞数 + val likeCount: Int, + // 评论数 + val commentCount: Int, + // 分享数 + val shareCount: Int, + // 收藏数 + val favoriteCount: Int, + // 动态图片列表 + val images: List = emptyList(), + // 作者ID + val authorId: Int = 0, + // 是否点赞 + var liked: Boolean = false, + // 关联动态ID + var relPostId: Int? = null, + // 关联动态 + var relMoment: MomentEntity? = null, + // 是否收藏 + var isFavorite: Boolean = false +) + +### entity/User.kt ### +package com.aiosman.riderpro.entity + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.aiosman.riderpro.data.UserService +import java.io.IOException + +/** + * 用户信息分页加载器 + */ +class AccountPagingSource( + private val userService: UserService, + private val nickname: String? = null +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + return try { + val currentPage = params.key ?: 1 + val users = userService.getUsers( + page = currentPage, + nickname = nickname + ) + LoadResult.Page( + data = users.list, + prevKey = if (currentPage == 1) null else currentPage - 1, + nextKey = if (users.list.isEmpty()) null else users.page + 1 + ) + } catch (exception: IOException) { + return LoadResult.Error(exception) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition + } + +} + +### utils/BlurHashDecoder.kt ### +package com.aiosman.riderpro.utils + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.collection.SparseArrayCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +internal object BlurHashDecoder { + + // cache Math.cos() calculations to improve performance. + // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps + // the cache is enabled by default, it is recommended to disable it only when just a few images are displayed + private val cacheCosinesX = SparseArrayCompat() + private val cacheCosinesY = SparseArrayCompat() + + /** + * Clear calculations stored in memory cache. + * The cache is not big, but will increase when many image sizes are used, + * if the app needs memory it is recommended to clear it. + */ + private fun clearCache() { + cacheCosinesX.clear() + cacheCosinesY.clear() + } + + /** + * Decode a blur hash into a new bitmap. + * + * @param useCache use in memory cache for the calculated math, reused by images with same size. + * if the cache does not exist yet it will be created and populated with new calculations. + * By default it is true. + */ + @Suppress("ReturnCount") + internal fun decode( + blurHash: String?, + width: Int, + height: Int, + punch: Float = 1f, + useCache: Boolean = true + ): Bitmap? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = Array(numCompX * numCompY) { i -> + if (i == 0) { + val colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc) + } else { + val from = 4 + i * 2 + val colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch) + } + } + return composeBitmap(width, height, numCompX, numCompY, colors, useCache) + } + + private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { + var result = 0 + for (i in from until to) { + val index = charMap[str[i]] ?: -1 + if (index != -1) { + result = result * 83 + index + } + } + return result + } + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) { + (v / 12.92f) + } else { + ((v + 0.055f) / 1.055f).pow(2.4f) + } + } + + private fun decodeAc(value: Int, maxAc: Float): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc + ) + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeBitmap( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array, + useCache: Boolean + ): Bitmap { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) + val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) + val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) + val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + runBlocking { + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + val tasks = ArrayList>() + tasks.add( + async { + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val cosX = + cosinesX.getCos(calculateCosX, i, numCompX, x, width) + val cosY = + cosinesY.getCos(calculateCosY, j, numCompY, y, height) + val basis = (cosX * cosY).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + imageArray[x + width * y] = + Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + } + } + return@async + } + ) + tasks.forEach { it.await() } + }.join() + } + + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when { + calculate -> { + DoubleArray(height * numCompY).also { + cacheCosinesY.put(height * numCompY, it) + } + } + + else -> { + cacheCosinesY.get(height * numCompY)!! + } + } + + private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when { + calculate -> { + DoubleArray(width * numCompX).also { + cacheCosinesX.put(width * numCompX, it) + } + } + + else -> cacheCosinesX.get(width * numCompX)!! + } + + private fun DoubleArray.getCos( + calculate: Boolean, + x: Int, + numComp: Int, + y: Int, + size: Int + ): Double { + if (calculate) { + this[x + numComp * y] = cos(Math.PI * y * x / size) + } + return this[x + numComp * y] + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } + + private val charMap = listOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', + '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' + ) + .mapIndexed { i, c -> c to i } + .toMap() +} + +### utils/GoogleLogin.kt ### +package com.aiosman.riderpro.utils + +import android.content.Context +import androidx.credentials.Credential +import androidx.credentials.CredentialManager +import androidx.credentials.CustomCredential +import androidx.credentials.GetCredentialRequest +import androidx.credentials.GetCredentialResponse +import com.google.android.libraries.identity.googleid.GetGoogleIdOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential + + +fun handleGoogleSignIn(result: GetCredentialResponse, onLoginWithGoogle: (String) -> Unit) { + val credential: Credential = result.credential + + if (credential is CustomCredential) { + if (GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL.equals(credential.type)) { + val googleIdTokenCredential: GoogleIdTokenCredential = + GoogleIdTokenCredential.createFrom(credential.data) + onLoginWithGoogle(googleIdTokenCredential.idToken) + } + } +} + +suspend fun GoogleLogin(context: Context, onLoginWithGoogle: (String) -> Unit) { + val credentialManager = CredentialManager.create(context) + val googleIdOption: GetGoogleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(true) + .setServerClientId("754277015802-pnua6tg8ibnjq69lv8qdcmsdhbe97ag9.apps.googleusercontent.com") + .build() + val request = GetCredentialRequest.Builder().addCredentialOption(googleIdOption) + .build() + + credentialManager.getCredential(context, request).let { + handleGoogleSignIn(it, onLoginWithGoogle) + } +} + +### utils/Utils.kt ### +package com.aiosman.riderpro.utils + +import android.content.Context +import coil.ImageLoader +import coil.disk.DiskCache +import coil.memory.MemoryCache +import coil.request.CachePolicy +import com.aiosman.riderpro.data.api.getUnsafeOkHttpClient +import java.util.Date +import java.util.concurrent.TimeUnit + +object Utils { + fun generateRandomString(length: Int): String { + val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9') + return (1..length) + .map { allowedChars.random() } + .joinToString("") + } + + fun getImageLoader(context: Context): ImageLoader { + val okHttpClient = getUnsafeOkHttpClient() + return ImageLoader.Builder(context) + .okHttpClient(okHttpClient) + .memoryCache { + MemoryCache.Builder(context) + .maxSizePercent(0.25) // 设置内存缓存大小为可用内存的 25% + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("image_cache")) + .maxSizePercent(0.02) // 设置磁盘缓存大小为可用存储空间的 2% + .build() + } + .build() + } + + fun getTimeAgo(date: Date): String { + val now = Date() + val diffInMillis = now.time - date.time + + val seconds = TimeUnit.MILLISECONDS.toSeconds(diffInMillis) + val minutes = TimeUnit.MILLISECONDS.toMinutes(diffInMillis) + val hours = TimeUnit.MILLISECONDS.toHours(diffInMillis) + val days = TimeUnit.MILLISECONDS.toDays(diffInMillis) + val years = days / 365 + + return when { + seconds < 60 -> "$seconds seconds ago" + minutes < 60 -> "$minutes minutes ago" + hours < 24 -> "$hours hours ago" + days < 365 -> "$days days ago" + else -> "$years years ago" + } + } +} + +### model/ChatNotificationData.kt ### +package com.aiosman.riderpro.model + +import androidx.annotation.DrawableRes + +data class ChatNotificationData( + @DrawableRes val avatar: Int, + val name: String, + val message: String, + val time: String, + val unread: Int +) + + +### model/ChatNotificationUtils.kt ### +package com.aiosman.riderpro.model + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import kotlin.math.ceil +import kotlinx.coroutines.delay + +internal class TestChatBackend( + private val backendDataList: List, + private val loadDelay: Long = 500, +) { + val DataBatchSize = 1 + class DesiredLoadResultPageResponse(val data: List) + /** Returns [DataBatchSize] items for a key */ + fun searchItemsByKey(key: Int): DesiredLoadResultPageResponse { + val maxKey = ceil(backendDataList.size.toFloat() / DataBatchSize).toInt() + if (key >= maxKey) { + return DesiredLoadResultPageResponse(emptyList()) + } + val from = key * DataBatchSize + val to = minOf((key + 1) * DataBatchSize, backendDataList.size) + val currentSublist = backendDataList.subList(from, to) + return DesiredLoadResultPageResponse(currentSublist) + } + fun getAllData() = TestChatPagingSource(this, loadDelay) +} +internal class TestChatPagingSource( + private val backend: TestChatBackend, + private val loadDelay: Long, +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + // Simulate latency + delay(loadDelay) + val pageNumber = params.key ?: 0 + val response = backend.searchItemsByKey(pageNumber) + // Since 0 is the lowest page number, return null to signify no more pages should + // be loaded before it. + val prevKey = if (pageNumber > 0) pageNumber - 1 else null + // This API defines that it's out of data when a page returns empty. When out of + // data, we return `null` to signify no more pages should be loaded + val nextKey = if (response.data.isNotEmpty()) pageNumber + 1 else null + return LoadResult.Page(data = response.data, prevKey = prevKey, nextKey = nextKey) + } + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { + state.closestPageToPosition(it)?.prevKey?.plus(1) + ?: state.closestPageToPosition(it)?.nextKey?.minus(1) + } + } +} + +### exp/Date.kt ### +package com.aiosman.riderpro.exp + +import android.icu.text.SimpleDateFormat +import android.icu.util.Calendar +import com.aiosman.riderpro.data.api.ApiClient +import java.util.Date +import java.util.Locale + +/** + * 格式化时间为 xx 前 + */ +fun Date.timeAgo(): String { + val now = Date() + val diffInMillis = now.time - this.time + + val seconds = diffInMillis / 1000 + val minutes = seconds / 60 + val hours = minutes / 60 + val days = hours / 24 + val years = days / 365 + + return when { + seconds < 60 -> "$seconds seconds ago" + minutes < 60 -> "$minutes minutes ago" + hours < 24 -> "$hours hours ago" + days < 365 -> "$days days ago" + else -> "$years years ago" + } +} + +/** + * 格式化时间为 xx-xx + */ +fun Date.formatPostTime(): String { + val now = Calendar.getInstance() + val calendar = Calendar.getInstance() + calendar.time = this + val year = calendar.get(Calendar.YEAR) + var nowYear = now.get(Calendar.YEAR) + val dateFormat = if (year == nowYear) { + SimpleDateFormat("MM-dd", Locale.getDefault()) + } else { + SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) + } + return dateFormat.format(this) +} + +### exp/StatusBarExp.kt ### +package com.aiosman.riderpro.exp + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.Context +import android.content.res.Resources +import android.os.Build +import android.util.TypedValue +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import androidx.annotation.ColorInt +//import androidx.appcompat.app.AppCompatActivity + +private const val COLOR_TRANSPARENT = 0 + +@SuppressLint("ObsoleteSdkInt") +@JvmOverloads +fun Activity.immersive(@ColorInt color: Int = COLOR_TRANSPARENT, darkMode: Boolean? = null) { + when { + Build.VERSION.SDK_INT >= 21 -> { + when (color) { + COLOR_TRANSPARENT -> { + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + var systemUiVisibility = window.decorView.systemUiVisibility + systemUiVisibility = systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + systemUiVisibility = systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_STABLE + window.decorView.systemUiVisibility = systemUiVisibility + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + window.statusBarColor = color + } + else -> { + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + var systemUiVisibility = window.decorView.systemUiVisibility + systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + systemUiVisibility = systemUiVisibility and View.SYSTEM_UI_FLAG_LAYOUT_STABLE + window.decorView.systemUiVisibility = systemUiVisibility + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + window.statusBarColor = color + } + } + } + Build.VERSION.SDK_INT >= 19 -> { + window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + if (color != COLOR_TRANSPARENT) { + setTranslucentView(window.decorView as ViewGroup, color) + } + } + } + if (darkMode != null) { + darkMode(darkMode) + } +} + +@JvmOverloads +fun Activity.darkMode(darkMode: Boolean = true) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + var systemUiVisibility = window.decorView.systemUiVisibility + systemUiVisibility = if (darkMode) { + systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR + } else { + systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv() + } + window.decorView.systemUiVisibility = systemUiVisibility + } +} + +private fun Context.setTranslucentView(container: ViewGroup, color: Int) { + if (Build.VERSION.SDK_INT >= 19) { + var simulateStatusBar: View? = container.findViewById(android.R.id.custom) + if (simulateStatusBar == null && color != 0) { + simulateStatusBar = View(container.context) + simulateStatusBar.id = android.R.id.custom + val lp = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, statusBarHeight) + container.addView(simulateStatusBar, lp) + } + simulateStatusBar?.setBackgroundColor(color) + } +} + +val Context?.statusBarHeight: Int + get() { + this ?: return 0 + var result = 24 + val resId = resources.getIdentifier("status_bar_height", "dimen", "android") + result = if (resId > 0) { + resources.getDimensionPixelSize(resId) + } else { + TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + result.toFloat(), Resources.getSystem().displayMetrics + ).toInt() + } + return result + } + +### data/AccountService.kt ### +package com.aiosman.riderpro.data + +import com.aiosman.riderpro.data.api.ApiClient +import com.aiosman.riderpro.data.api.ChangePasswordRequestBody +import com.aiosman.riderpro.data.api.GoogleRegisterRequestBody +import com.aiosman.riderpro.data.api.LoginUserRequestBody +import com.aiosman.riderpro.data.api.RegisterRequestBody +import com.aiosman.riderpro.data.api.UpdateNoticeRequestBody +import com.aiosman.riderpro.entity.AccountFavouriteEntity +import com.aiosman.riderpro.entity.AccountLikeEntity +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.entity.NoticePostEntity +import com.aiosman.riderpro.entity.NoticeUserEntity +import com.google.gson.annotations.SerializedName +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File + +/** + * 用户资料 + */ +data class AccountProfile( + // 用户ID + val id: Int, + // 用户名 + val username: String, + // 昵称 + val nickname: String, + // 头像 + val avatar: String, + // 关注数 + val followingCount: Int, + // 粉丝数 + val followerCount: Int, + // 是否关注 + val isFollowing: Boolean +) { + /** + * 转换为Entity + */ + fun toAccountProfileEntity(): AccountProfileEntity { + return AccountProfileEntity( + id = id, + followerCount = followerCount, + followingCount = followingCount, + nickName = nickname, + avatar = "${ApiClient.BASE_SERVER}$avatar", + bio = "", + country = "Worldwide", + isFollowing = isFollowing + ) + } +} + +/** + * 消息关联资料 + */ +data class NoticePost( + // 动态ID + @SerializedName("id") + val id: Int, + // 动态内容 + @SerializedName("textContent") + // 动态图片 + val textContent: String, + // 动态图片 + @SerializedName("images") + val images: List, + // 动态时间 + @SerializedName("time") + val time: String, +) { + /** + * 转换为Entity + */ + fun toNoticePostEntity(): NoticePostEntity { + return NoticePostEntity( + id = id, + textContent = textContent, + images = images.map { + it.copy( + url = "${ApiClient.BASE_SERVER}${it.url}", + thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}", + ) + }, + time = ApiClient.dateFromApiString(time) + ) + } +} + +/** + * 消息关联用户 + */ +data class NoticeUser( + // 用户ID + @SerializedName("id") + val id: Int, + // 昵称 + @SerializedName("nickName") + val nickName: String, + // 头像 + @SerializedName("avatar") + val avatar: String, +) { + /** + * 转换为Entity + */ + fun toNoticeUserEntity(): NoticeUserEntity { + return NoticeUserEntity( + id = id, + nickName = nickName, + avatar = "${ApiClient.BASE_SERVER}$avatar", + ) + } +} + +/** + * 点赞消息通知 + */ +data class AccountLike( + // 是否未读 + @SerializedName("isUnread") + val isUnread: Boolean, + // 动态 + @SerializedName("post") + val post: NoticePost, + // 点赞用户 + @SerializedName("user") + val user: NoticeUser, + // 点赞时间 + @SerializedName("likeTime") + val likeTime: String, +) { + fun toAccountLikeEntity(): AccountLikeEntity { + return AccountLikeEntity( + post = post.toNoticePostEntity(), + user = user.toNoticeUserEntity(), + likeTime = ApiClient.dateFromApiString(likeTime) + ) + } +} + +data class AccountFavourite( + @SerializedName("isUnread") + val isUnread: Boolean, + @SerializedName("post") + val post: NoticePost, + @SerializedName("user") + val user: NoticeUser, + @SerializedName("favoriteTime") + val favouriteTime: String, +) { + fun toAccountFavouriteEntity(): AccountFavouriteEntity { + return AccountFavouriteEntity( + post = post.toNoticePostEntity(), + user = user.toNoticeUserEntity(), + favoriteTime = ApiClient.dateFromApiString(favouriteTime) + ) + } +} + +data class AccountFollow( + @SerializedName("id") + val id: Int, + @SerializedName("username") + val username: String, + @SerializedName("nickname") + val nickname: String, + @SerializedName("avatar") + val avatar: String, + @SerializedName("isUnread") + val isUnread: Boolean, + @SerializedName("userId") + val userId: Int, + @SerializedName("isFollowing") + val isFollowing: Boolean, +) + +//{ +// "likeCount": 0, +// "followCount": 0, +// "favoriteCount": 0 +//} +data class AccountNotice( + @SerializedName("likeCount") + val likeCount: Int, + @SerializedName("followCount") + val followCount: Int, + @SerializedName("favoriteCount") + val favoriteCount: Int, + @SerializedName("commentCount") + val commentCount: Int, +) + + +interface AccountService { + /** + * 获取登录当前用户的资料 + */ + suspend fun getMyAccountProfile(): AccountProfileEntity + + /** + * 获取登录的用户认证信息 + */ + suspend fun getMyAccount(): UserAuth + + /** + * 使用用户名密码登录 + * @param loginName 用户名 + * @param password 密码 + */ + suspend fun loginUserWithPassword(loginName: String, password: String): UserAuth + + /** + * 使用google登录 + * @param googleId googleId + */ + suspend fun loginUserWithGoogle(googleId: String): UserAuth + + /** + * 退出登录 + */ + suspend fun logout() + + /** + * 更新用户资料 + * @param avatar 头像 + * @param nickName 昵称 + * @param bio 简介 + */ + suspend fun updateProfile(avatar: UploadImage?, nickName: String?, bio: String?) + + /** + * 注册用户 + * @param loginName 用户名 + * @param password 密码 + */ + suspend fun registerUserWithPassword(loginName: String, password: String) + + /** + * 使用google账号注册 + * @param idToken googleIdToken + */ + suspend fun regiterUserWithGoogleAccount(idToken: String) + + /** + * 修改密码 + * @param oldPassword 旧密码 + * @param newPassword 新密码 + */ + suspend fun changeAccountPassword(oldPassword: String, newPassword: String) + + /** + * 获取我的点赞通知 + * @param page 页码 + * @param pageSize 每页数量 + */ + suspend fun getMyLikeNotice(page: Int, pageSize: Int): ListContainer + + /** + * 获取我的关注通知 + * @param page 页码 + * @param pageSize 每页数量 + */ + suspend fun getMyFollowNotice(page: Int, pageSize: Int): ListContainer + + /** + * 获取我的收藏通知 + * @param page 页码 + * @param pageSize 每页数量 + */ + suspend fun getMyFavouriteNotice(page: Int, pageSize: Int): ListContainer + + /** + * 获取我的通知信息 + */ + suspend fun getMyNoticeInfo(): AccountNotice + + /** + * 更新通知信息,更新最后一次查看时间 + * @param payload 通知信息 + */ + suspend fun updateNotice(payload: UpdateNoticeRequestBody) +} + +class AccountServiceImpl : AccountService { + override suspend fun getMyAccountProfile(): AccountProfileEntity { + val resp = ApiClient.api.getMyAccount() + val body = resp.body() ?: throw ServiceException("Failed to get account") + return body.data.toAccountProfileEntity() + } + + override suspend fun getMyAccount(): UserAuth { + val resp = ApiClient.api.checkToken() + val body = resp.body() ?: throw ServiceException("Failed to get account") + return UserAuth(body.id) + } + + override suspend fun loginUserWithPassword(loginName: String, password: String): UserAuth { + val resp = ApiClient.api.login(LoginUserRequestBody(loginName, password)) + val body = resp.body() ?: throw ServiceException("Failed to login") + return UserAuth(0, body.token) + } + + override suspend fun loginUserWithGoogle(googleId: String): UserAuth { + val resp = ApiClient.api.login(LoginUserRequestBody(googleId = googleId)) + val body = resp.body() ?: throw ServiceException("Failed to login") + return UserAuth(0, body.token) + } + + override suspend fun regiterUserWithGoogleAccount(idToken: String) { + val resp = ApiClient.api.registerWithGoogle(GoogleRegisterRequestBody(idToken)) + if (resp.code() != 200) { + throw ServiceException("Failed to register") + } + } + + override suspend fun logout() { + // do nothing + } + + + fun createMultipartBody(file: File, filename: String, name: String): MultipartBody.Part { + val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull()) + return MultipartBody.Part.createFormData(name, filename, requestFile) + } + + override suspend fun updateProfile(avatar: UploadImage?, nickName: String?, bio: String?) { + val nicknameField: RequestBody? = nickName?.toRequestBody("text/plain".toMediaTypeOrNull()) + val avatarField: MultipartBody.Part? = avatar?.let { + createMultipartBody(it.file, it.filename, "avatar") + } + ApiClient.api.updateProfile(avatarField, nicknameField) + } + + override suspend fun registerUserWithPassword(loginName: String, password: String) { + ApiClient.api.register(RegisterRequestBody(loginName, password)) + } + + override suspend fun changeAccountPassword(oldPassword: String, newPassword: String) { + ApiClient.api.changePassword(ChangePasswordRequestBody(oldPassword, newPassword)) + } + + override suspend fun getMyLikeNotice(page: Int, pageSize: Int): ListContainer { + val resp = ApiClient.api.getMyLikeNotices(page, pageSize) + val body = resp.body() ?: throw ServiceException("Failed to get account") + return body + } + + override suspend fun getMyFollowNotice(page: Int, pageSize: Int): ListContainer { + val resp = ApiClient.api.getMyFollowNotices(page, pageSize) + val body = resp.body() ?: throw ServiceException("Failed to get account") + return body + } + + override suspend fun getMyFavouriteNotice( + page: Int, + pageSize: Int + ): ListContainer { + val resp = ApiClient.api.getMyFavouriteNotices(page, pageSize) + val body = resp.body() ?: throw ServiceException("Failed to get account") + return body + } + + override suspend fun getMyNoticeInfo(): AccountNotice { + val resp = ApiClient.api.getMyNoticeInfo() + val body = resp.body() ?: throw ServiceException("Failed to get account") + return body.data + } + + override suspend fun updateNotice(payload: UpdateNoticeRequestBody) { + ApiClient.api.updateNoticeInfo(payload) + } + +} + +### data/CommentService.kt ### +package com.aiosman.riderpro.data + +import com.aiosman.riderpro.data.api.ApiClient +import com.aiosman.riderpro.data.api.CommentRequestBody +import com.aiosman.riderpro.entity.CommentEntity +import com.google.gson.annotations.SerializedName + +/** + * 评论相关 Service + */ +interface CommentService { + /** + * 获取动态 + * @param pageNumber 页码 + * @param postId 动态ID,过滤条件 + * @param postUser 动态作者ID,获取某个用户所有动态下的评论 + * @param selfNotice 是否是自己的通知 + * @return 评论列表 + */ + suspend fun getComments( + pageNumber: Int, + postId: Int? = null, + postUser: Int? = null, + selfNotice: Boolean? = null + ): ListContainer + + /** + * 创建评论 + * @param postId 动态ID + * @param content 评论内容 + */ + suspend fun createComment(postId: Int, content: String) + + /** + * 点赞评论 + * @param commentId 评论ID + */ + suspend fun likeComment(commentId: Int) + + /** + * 取消点赞评论 + * @param commentId 评论ID + */ + suspend fun dislikeComment(commentId: Int) + + /** + * 更新评论已读状态 + * @param commentId 评论ID + */ + suspend fun updateReadStatus(commentId: Int) +} + +/** + * 评论 + */ +data class Comment( + // 评论ID + @SerializedName("id") + val id: Int, + // 评论内容 + @SerializedName("content") + val content: String, + // 评论用户 + @SerializedName("user") + val user: User, + // 点赞数 + @SerializedName("likeCount") + val likeCount: Int, + // 是否点赞 + @SerializedName("isLiked") + val isLiked: Boolean, + // 创建时间 + @SerializedName("createdAt") + val createdAt: String, + // 动态ID + @SerializedName("postId") + val postId: Int, + // 动态 + @SerializedName("post") + val post: NoticePost?, + // 是否未读 + @SerializedName("isUnread") + val isUnread: Boolean +) { + /** + * 转换为Entity + */ + fun toCommentEntity(): CommentEntity { + return CommentEntity( + id = id, + name = user.nickName, + comment = content, + date = ApiClient.dateFromApiString(createdAt), + likes = likeCount, + replies = emptyList(), + postId = postId, + avatar = "${ApiClient.BASE_SERVER}${user.avatar}", + author = user.id, + liked = isLiked, + unread = isUnread, + post = post?.let { + it.copy( + images = it.images.map { + it.copy( + url = "${ApiClient.BASE_SERVER}${it.url}", + thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}" + ) + } + ) + } + ) + } +} + +class CommentRemoteDataSource( + private val commentService: CommentService, +) { + suspend fun getComments( + pageNumber: Int, + postId: Int?, + postUser: Int?, + selfNotice: Boolean? + ): ListContainer { + return commentService.getComments( + pageNumber, + postId, + postUser = postUser, + selfNotice = selfNotice + ) + } +} + + +class CommentServiceImpl : CommentService { + override suspend fun getComments( + pageNumber: Int, + postId: Int?, + postUser: Int?, + selfNotice: Boolean? + ): ListContainer { + val resp = ApiClient.api.getComments( + pageNumber, + postId, + postUser = postUser, + selfNotice = selfNotice?.let { + if (it) 1 else 0 + } + ) + val body = resp.body() ?: throw ServiceException("Failed to get comments") + return ListContainer( + list = body.list.map { it.toCommentEntity() }, + page = body.page, + total = body.total, + pageSize = body.pageSize + ) + } + + override suspend fun createComment(postId: Int, content: String) { + val resp = ApiClient.api.createComment(postId, CommentRequestBody(content)) + return + } + + override suspend fun likeComment(commentId: Int) { + val resp = ApiClient.api.likeComment(commentId) + return + } + + override suspend fun dislikeComment(commentId: Int) { + val resp = ApiClient.api.dislikeComment(commentId) + return + } + + override suspend fun updateReadStatus(commentId: Int) { + val resp = ApiClient.api.updateReadStatus(commentId) + return + } + +} + +### data/DataContainer.kt ### +package com.aiosman.riderpro.data + +/** + * 通用接口返回数据 + */ +data class DataContainer( + val data: T +) + + +### data/Exception.kt ### +package com.aiosman.riderpro.data + +/** + * 错误返回 + */ +class ServiceException( + override val message: String, + val code: Int = 0, + val data: Any? = null +) : Exception( + message +) + +### data/ListContainer.kt ### +package com.aiosman.riderpro.data + +import com.google.gson.annotations.SerializedName + + +/** + * 通用列表接口返回 + */ +data class ListContainer( + // 总数 + @SerializedName("total") + val total: Int, + // 当前页 + @SerializedName("page") + val page: Int, + // 每页数量 + @SerializedName("pageSize") + val pageSize: Int, + // 列表 + @SerializedName("list") + val list: List +) + +### data/MomentService.kt ### +package com.aiosman.riderpro.data + +import com.aiosman.riderpro.R +import com.aiosman.riderpro.data.api.ApiClient +import com.aiosman.riderpro.entity.MomentEntity +import com.aiosman.riderpro.entity.MomentImageEntity +import com.google.gson.annotations.SerializedName +import java.io.File + +data class Moment( + @SerializedName("id") + val id: Long, + @SerializedName("textContent") + val textContent: String, + @SerializedName("images") + val images: List, + @SerializedName("user") + val user: User, + @SerializedName("likeCount") + val likeCount: Long, + @SerializedName("isLiked") + val isLiked: Boolean, + @SerializedName("favoriteCount") + val favoriteCount: Long, + @SerializedName("isFavorite") + val isFavorite: Boolean, + @SerializedName("shareCount") + val isCommented: Boolean, + @SerializedName("commentCount") + val commentCount: Long, + @SerializedName("time") + val time: String +) { + fun toMomentItem(): MomentEntity { + return MomentEntity( + id = id.toInt(), + avatar = "${ApiClient.BASE_SERVER}${user.avatar}", + nickname = user.nickName, + location = "Worldwide", + time = ApiClient.dateFromApiString(time), + followStatus = false, + momentTextContent = textContent, + momentPicture = R.drawable.default_moment_img, + likeCount = likeCount.toInt(), + commentCount = commentCount.toInt(), + shareCount = 0, + favoriteCount = favoriteCount.toInt(), + images = images.map { + MomentImageEntity( + url = "${ApiClient.BASE_SERVER}${it.url}", + thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}", + id = it.id, + blurHash = it.blurHash + ) + }, + authorId = user.id.toInt(), + liked = isLiked, + isFavorite = isFavorite + ) + } +} + +data class Image( + @SerializedName("id") + val id: Long, + @SerializedName("url") + val url: String, + @SerializedName("thumbnail") + val thumbnail: String, + @SerializedName("blurHash") + val blurHash: String? +) + +data class User( + @SerializedName("id") + val id: Long, + @SerializedName("nickName") + val nickName: String, + @SerializedName("avatar") + val avatar: String +) + +data class UploadImage( + val file: File, + val filename: String, + val url: String, + val ext: String +) + +interface MomentService { + /** + * 获取动态详情 + * @param id 动态ID + */ + suspend fun getMomentById(id: Int): MomentEntity + + /** + * 点赞动态 + * @param id 动态ID + */ + suspend fun likeMoment(id: Int) + + /** + * 取消点赞动态 + * @param id 动态ID + */ + suspend fun dislikeMoment(id: Int) + + /** + * 获取动态列表 + * @param pageNumber 页码 + * @param author 作者ID,过滤条件 + * @param timelineId 用户时间线ID,指定用户 ID 的时间线 + * @param contentSearch 内容搜索,过滤条件 + * @return 动态列表 + */ + suspend fun getMoments( + pageNumber: Int, + author: Int? = null, + timelineId: Int? = null, + contentSearch: String? = null + ): ListContainer + + /** + * 创建动态 + * @param content 动态内容 + * @param authorId 作者ID + * @param images 图片列表 + * @param relPostId 关联动态ID + */ + suspend fun createMoment( + content: String, + authorId: Int, + images: List, + relPostId: Int? = null + ): MomentEntity + + /** + * 收藏动态 + * @param id 动态ID + */ + suspend fun favoriteMoment(id: Int) + + /** + * 取消收藏动态 + * @param id 动态ID + */ + suspend fun unfavoriteMoment(id: Int) +} + + + + +### data/UserService.kt ### +package com.aiosman.riderpro.data + +import com.aiosman.riderpro.data.api.ApiClient +import com.aiosman.riderpro.entity.AccountProfileEntity + +data class UserAuth( + val id: Int, + val token: String? = null +) + +/** + * 用户相关 Service + */ +interface UserService { + /** + * 获取用户信息 + * @param id 用户ID + * @return 用户信息 + */ + suspend fun getUserProfile(id: String): AccountProfileEntity + + /** + * 关注用户 + * @param id 用户ID + */ + suspend fun followUser(id: String) + + /** + * 取消关注用户 + * @param id 用户ID + */ + suspend fun unFollowUser(id: String) + + /** + * 获取用户列表 + * @param pageSize 分页大小 + * @param page 页码 + * @param nickname 昵称搜索 + * @return 用户列表 + */ + suspend fun getUsers( + pageSize: Int = 20, + page: Int = 1, + nickname: String? = null + ): ListContainer + +} + +class UserServiceImpl : UserService { + override suspend fun getUserProfile(id: String): AccountProfileEntity { + val resp = ApiClient.api.getAccountProfileById(id.toInt()) + val body = resp.body() ?: throw ServiceException("Failed to get account") + return body.data.toAccountProfileEntity() + } + + override suspend fun followUser(id: String) { + val resp = ApiClient.api.followUser(id.toInt()) + return + } + + override suspend fun unFollowUser(id: String) { + val resp = ApiClient.api.unfollowUser(id.toInt()) + return + } + + override suspend fun getUsers( + pageSize: Int, + page: Int, + nickname: String? + ): ListContainer { + val resp = ApiClient.api.getUsers(page, pageSize, nickname) + val body = resp.body() ?: throw ServiceException("Failed to get account") + return ListContainer( + list = body.list.map { it.toAccountProfileEntity() }, + page = body.page, + total = body.total, + pageSize = body.pageSize + ) + } +} + +### data/api/ApiClient.kt ### +package com.aiosman.riderpro.data.api + +import android.icu.text.SimpleDateFormat +import android.icu.util.TimeZone +import com.aiosman.riderpro.AppStore +import com.aiosman.riderpro.ConstVars +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Response +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.security.cert.CertificateException +import java.util.Date +import java.util.Locale +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +fun getUnsafeOkHttpClient(): OkHttpClient { + return try { + // Create a trust manager that does not validate certificate chains + val trustAllCerts = arrayOf(object : X509TrustManager { + @Throws(CertificateException::class) + override fun checkClientTrusted( + chain: Array, + authType: String + ) { + } + + @Throws(CertificateException::class) + override fun checkServerTrusted( + chain: Array, + authType: String + ) { + } + + override fun getAcceptedIssuers(): Array = arrayOf() + }) + + // Install the all-trusting trust manager + val sslContext = SSLContext.getInstance("SSL") + sslContext.init(null, trustAllCerts, java.security.SecureRandom()) + // Create an ssl socket factory with our all-trusting manager + val sslSocketFactory = sslContext.socketFactory + + OkHttpClient.Builder() + .sslSocketFactory(sslSocketFactory, trustAllCerts[0] as X509TrustManager) + .hostnameVerifier { _, _ -> true } + .addInterceptor(AuthInterceptor()) + .build() + } catch (e: Exception) { + throw RuntimeException(e) + } +} + +class AuthInterceptor() : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val requestBuilder = chain.request().newBuilder() + requestBuilder.addHeader("Authorization", "Bearer ${AppStore.token}") + return chain.proceed(requestBuilder.build()) + } +} + +object ApiClient { + const val BASE_SERVER = ConstVars.BASE_SERVER + const val BASE_API_URL = "${BASE_SERVER}/api/v1" + const val RETROFIT_URL = "${BASE_API_URL}/" + const val TIME_FORMAT = "yyyy-MM-dd HH:mm:ss" + private val okHttpClient: OkHttpClient by lazy { + getUnsafeOkHttpClient() + } + private val retrofit: Retrofit by lazy { + Retrofit.Builder() + .baseUrl(RETROFIT_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + val api: RiderProAPI by lazy { + retrofit.create(RiderProAPI::class.java) + } + + fun formatTime(date: Date): String { + val dateFormat = SimpleDateFormat(TIME_FORMAT, Locale.getDefault()) + return dateFormat.format(date) + } + + fun dateFromApiString(apiString: String): Date { + val timeFormat = TIME_FORMAT + val simpleDateFormat = SimpleDateFormat(timeFormat, Locale.getDefault()) + simpleDateFormat.timeZone = TimeZone.getTimeZone("UTC") + val date = simpleDateFormat.parse(apiString) + + simpleDateFormat.timeZone = TimeZone.getDefault() + val localDateString = simpleDateFormat.format(date) + return simpleDateFormat.parse(localDateString) + } +} + +### data/api/RiderProAPI.kt ### +package com.aiosman.riderpro.data.api + +import com.aiosman.riderpro.data.AccountFavourite +import com.aiosman.riderpro.data.AccountFollow +import com.aiosman.riderpro.data.AccountLike +import com.aiosman.riderpro.data.AccountNotice +import com.aiosman.riderpro.data.AccountProfile +import com.aiosman.riderpro.data.Comment +import com.aiosman.riderpro.data.DataContainer +import com.aiosman.riderpro.data.ListContainer +import com.aiosman.riderpro.data.Moment +import com.google.gson.annotations.SerializedName +import okhttp3.MultipartBody +import okhttp3.RequestBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +data class RegisterRequestBody( + @SerializedName("username") + val username: String, + @SerializedName("password") + val password: String, + + ) + +data class LoginUserRequestBody( + @SerializedName("username") + val username: String? = null, + @SerializedName("password") + val password: String? = null, + @SerializedName("googleId") + val googleId: String? = null, +) + +data class GoogleRegisterRequestBody( + @SerializedName("idToken") + val idToken: String +) + +data class AuthResult( + @SerializedName("code") + val code: Int, + @SerializedName("expire") + val expire: String, + @SerializedName("token") + val token: String +) + +data class ValidateTokenResult( + @SerializedName("id") + val id: Int, +) + +data class CommentRequestBody( + @SerializedName("content") + val content: String +) + +data class ChangePasswordRequestBody( + @SerializedName("currentPassword") + val oldPassword: String = "", + @SerializedName("newPassword") + val newPassword: String = "" +) + +data class UpdateNoticeRequestBody( + @SerializedName("lastLookLikeTime") + val lastLookLikeTime: String? = null, + @SerializedName("lastLookFollowTime") + val lastLookFollowTime: String? = null, + @SerializedName("lastLookFavoriteTime") + val lastLookFavouriteTime: String? = null +) + +interface RiderProAPI { + @POST("register") + suspend fun register(@Body body: RegisterRequestBody): Response + + @POST("login") + suspend fun login(@Body body: LoginUserRequestBody): Response + + @GET("auth/token") + suspend fun checkToken(): Response + + @GET("posts") + suspend fun getPosts( + @Query("page") page: Int = 1, + @Query("pageSize") pageSize: Int = 20, + @Query("timelineId") timelineId: Int? = null, + @Query("authorId") authorId: Int? = null, + @Query("contentSearch") contentSearch: String? = null, + @Query("postUser") postUser: Int? = null, + ): Response> + + @Multipart + @POST("posts") + suspend fun createPost( + @Part image: List, + @Part("textContent") textContent: RequestBody, + ): Response> + + @GET("post/{id}") + suspend fun getPost( + @Path("id") id: Int + ): Response> + + @POST("post/{id}/like") + suspend fun likePost( + @Path("id") id: Int + ): Response + + @POST("post/{id}/dislike") + suspend fun dislikePost( + @Path("id") id: Int + ): Response + + @POST("post/{id}/favorite") + suspend fun favoritePost( + @Path("id") id: Int + ): Response + + @POST("post/{id}/unfavorite") + suspend fun unfavoritePost( + @Path("id") id: Int + ): Response + + @POST("post/{id}/comment") + suspend fun createComment( + @Path("id") id: Int, + @Body body: CommentRequestBody + ): Response + + @POST("comment/{id}/like") + suspend fun likeComment( + @Path("id") id: Int + ): Response + + @POST("comment/{id}/dislike") + suspend fun dislikeComment( + @Path("id") id: Int + ): Response + + @POST("comment/{id}/read") + suspend fun updateReadStatus( + @Path("id") id: Int + ): Response + + + @GET("comments") + suspend fun getComments( + @Query("page") page: Int = 1, + @Query("postId") postId: Int? = null, + @Query("pageSize") pageSize: Int = 20, + @Query("postUser") postUser: Int? = null, + @Query("selfNotice") selfNotice: Int? = 0, + ): Response> + + @GET("account/my") + suspend fun getMyAccount(): Response> + + @Multipart + @PATCH("account/my/profile") + suspend fun updateProfile( + @Part avatar: MultipartBody.Part?, + @Part("nickname") nickname: RequestBody?, + ): Response + + @POST("account/my/password") + suspend fun changePassword( + @Body body: ChangePasswordRequestBody + ): Response + + @GET("account/my/notice/like") + suspend fun getMyLikeNotices( + @Query("page") page: Int = 1, + @Query("pageSize") pageSize: Int = 20, + ): Response> + + @GET("account/my/notice/follow") + suspend fun getMyFollowNotices( + @Query("page") page: Int = 1, + @Query("pageSize") pageSize: Int = 20, + ): Response> + + @GET("account/my/notice/favourite") + suspend fun getMyFavouriteNotices( + @Query("page") page: Int = 1, + @Query("pageSize") pageSize: Int = 20, + ): Response> + + @GET("account/my/notice") + suspend fun getMyNoticeInfo(): Response> + + @POST("account/my/notice") + suspend fun updateNoticeInfo( + @Body body: UpdateNoticeRequestBody + ): Response + + + @GET("profile/{id}") + suspend fun getAccountProfileById( + @Path("id") id: Int + ): Response> + + @POST("user/{id}/follow") + suspend fun followUser( + @Path("id") id: Int + ): Response + + @POST("user/{id}/unfollow") + suspend fun unfollowUser( + @Path("id") id: Int + ): Response + + @GET("users") + suspend fun getUsers( + @Query("page") page: Int = 1, + @Query("pageSize") pageSize: Int = 20, + @Query("nickname") search: String? = null, + ): Response> + + @POST("register/google") + suspend fun registerWithGoogle(@Body body: GoogleRegisterRequestBody): Response + +} + diff --git a/app/src/main/java/com/aiosman/riderpro/ui/post/NewPost.kt b/app/src/main/java/com/aiosman/riderpro/ui/post/NewPost.kt index e61dabe..64f0fdc 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/post/NewPost.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/post/NewPost.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.util.Log import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -25,6 +26,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.LinearProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Text @@ -80,10 +82,13 @@ fun NewPostScreen() { .fillMaxSize() ) { NewPostTopBar { - model.viewModelScope.launch { - model.createMoment(context = context) - navController.popBackStack() - } +// model.viewModelScope.launch { +// model.createMoment(context = context){ progress -> +// // 更新进度条 +// uploadProgress = progress +// } +// navController.popBackStack() +// } } NewPostTextField("Share your adventure…", NewPostViewModel.textContent) { NewPostViewModel.textContent = it @@ -114,6 +119,11 @@ fun NewPostScreen() { @Composable fun NewPostTopBar(onSendClick: () -> Unit = {}) { val navController = LocalNavController.current + val context = LocalContext.current + val model = NewPostViewModel + var showProgressBar by remember { mutableStateOf(false) } + var uploadProgress by remember { mutableStateOf(0f) } + Box( modifier = Modifier .fillMaxWidth() @@ -138,14 +148,40 @@ fun NewPostTopBar(onSendClick: () -> Unit = {}) { modifier = Modifier .size(24.dp) .noRippleClickable { - onSendClick() + // 点击发送按钮后立即导航回主页 + navController.popBackStack() + // 显示进度条 + showProgressBar = true + + // 在后台启动协程处理上传逻辑 + model.viewModelScope.launch { + model.createMoment(context = context) { progress -> + // 更新进度条 + uploadProgress = progress + } + // 上传完成后隐藏进度条 + showProgressBar = false + } } ) } + + // 在底部添加一个进度条 + AnimatedVisibility( + visible = showProgressBar, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + ) { + LinearProgressIndicator( + progress = uploadProgress, + modifier = Modifier + .fillMaxWidth() + .height(4.dp) + ) + } } - } - @Composable fun NewPostTextField(hint: String, value: String, onValueChange: (String) -> Unit) { diff --git a/app/src/main/java/com/aiosman/riderpro/ui/post/NewPostViewModel.kt b/app/src/main/java/com/aiosman/riderpro/ui/post/NewPostViewModel.kt index 1df5a7f..6f38383 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/post/NewPostViewModel.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/post/NewPostViewModel.kt @@ -48,8 +48,9 @@ object NewPostViewModel : ViewModel() { return tempFile } - suspend fun createMoment(context: Context) { + suspend fun createMoment(context: Context, onUploadProgress: (Float) -> Unit) { val uploadImageList = emptyList().toMutableList() + var index = 0 for (uri in imageUriList) { val cursor = context.contentResolver.query(Uri.parse(uri), null, null, null, null) cursor?.use { @@ -63,6 +64,9 @@ object NewPostViewModel : ViewModel() { uploadImageList += UploadImage(file, displayName, uri, extension) } } + // 在上传过程中调用 onUploadProgress 更新进度 + onUploadProgress(((index / imageUriList.size).toFloat())) // progressValue 是当前上传进度,例如 0.5 表示 50% + index += 1 } momentService.createMoment(textContent, 1, uploadImageList, relPostId) }