diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 45fab86..4627f78 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -110,5 +110,7 @@ dependencies { implementation("com.google.firebase:firebase-perf") implementation("com.google.firebase:firebase-messaging-ktx") implementation ("cn.jiguang.sdk:jpush-google:5.4.0") + api ("com.tencent.imsdk:imsdk-plus:8.1.6116") + } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb43..668bb19 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -18,4 +18,6 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +-keep class com.tencent.imsdk.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6ef18a4..b3eeeb9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,6 @@ @@ -7,7 +8,7 @@ diff --git a/app/src/main/java/com/aiosman/riderpro/AppState.kt b/app/src/main/java/com/aiosman/riderpro/AppState.kt index 91b3866..9b634b1 100644 --- a/app/src/main/java/com/aiosman/riderpro/AppState.kt +++ b/app/src/main/java/com/aiosman/riderpro/AppState.kt @@ -1,5 +1,11 @@ package com.aiosman.riderpro +import android.content.Context +import android.icu.util.Calendar +import android.icu.util.TimeZone +import android.util.Log +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.data.AccountServiceImpl import com.aiosman.riderpro.ui.favourite.FavouriteListViewModel import com.aiosman.riderpro.ui.favourite.FavouriteNoticeViewModel import com.aiosman.riderpro.ui.follower.FollowerNoticeViewModel @@ -11,10 +17,68 @@ import com.aiosman.riderpro.ui.index.tabs.profile.MyProfileViewModel import com.aiosman.riderpro.ui.index.tabs.search.DiscoverViewModel import com.aiosman.riderpro.ui.index.tabs.search.SearchViewModel import com.aiosman.riderpro.ui.like.LikeNoticeViewModel +import com.aiosman.riderpro.utils.Utils +import com.tencent.imsdk.v2.V2TIMCallback +import com.tencent.imsdk.v2.V2TIMLogListener +import com.tencent.imsdk.v2.V2TIMManager +import com.tencent.imsdk.v2.V2TIMSDKConfig +import kotlinx.coroutines.CoroutineScope object AppState { var UserId: Int? = null + suspend fun initWithAccount(scope: CoroutineScope, context: Context) { + val accountService: AccountService = AccountServiceImpl() + // 获取用户认证信息 + val resp = accountService.getMyAccount() + // 更新必要的用户信息 + val calendar: Calendar = Calendar.getInstance() + val tz: TimeZone = calendar.timeZone + val offsetInMillis: Int = tz.rawOffset + accountService.updateUserExtra( + Utils.getCurrentLanguage(), + // 时区偏移量单位是秒 + offsetInMillis / 1000, + tz.displayName + ) + // 设置当前登录用户 ID + UserId = resp.id + + // 注册 JPush + Messaging.RegistDevice(scope, context) + // 注册 Trtc + val config = V2TIMSDKConfig() + + config.logLevel = V2TIMSDKConfig.V2TIM_LOG_INFO + + config.logListener = object : V2TIMLogListener() { + override fun onLog(logLevel: Int, logContent: String) { + Log.d("V2TIMLogListener", logContent) + } + } + val appConfig = accountService.getAppConfig() + V2TIMManager.getInstance().initSDK(context, appConfig.trtcAppId, config) + try { + val sign = accountService.getMyTrtcSign() + V2TIMManager.getInstance().login( + sign.userId, + sign.sig, + object : V2TIMCallback { + override fun onError(code: Int, desc: String?) { + Log.e("V2TIMManager", "login failed: $code, $desc") + } + + override fun onSuccess() { + Log.d("V2TIMManager", "login success") + } + } + ) + } catch (e: Exception) { + + } + } + + fun ReloadAppState() { // 重置动态列表页面 MomentViewModel.ResetModel() diff --git a/app/src/main/java/com/aiosman/riderpro/MainActivity.kt b/app/src/main/java/com/aiosman/riderpro/MainActivity.kt index 8ff22c1..612d963 100644 --- a/app/src/main/java/com/aiosman/riderpro/MainActivity.kt +++ b/app/src/main/java/com/aiosman/riderpro/MainActivity.kt @@ -34,10 +34,15 @@ import com.aiosman.riderpro.ui.navigateToPost import com.aiosman.riderpro.ui.post.NewPostViewModel import com.aiosman.riderpro.utils.Utils import com.google.firebase.analytics.FirebaseAnalytics +import com.tencent.imsdk.v2.V2TIMCallback +import com.tencent.imsdk.v2.V2TIMLogListener +import com.tencent.imsdk.v2.V2TIMManager +import com.tencent.imsdk.v2.V2TIMSDKConfig import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch + class MainActivity : ComponentActivity() { // Firebase Analytics private lateinit var analytics: FirebaseAnalytics @@ -50,7 +55,7 @@ class MainActivity : ComponentActivity() { if (isGranted) { // FCM SDK (and your app) can post notifications. } else { - // TODO: Inform user that that your app will not show notifications. + } } @@ -61,17 +66,6 @@ class MainActivity : ComponentActivity() { val accountService: AccountService = AccountServiceImpl() try { val resp = accountService.getMyAccount() - val calendar: Calendar = Calendar.getInstance() - val tz: TimeZone = calendar.timeZone - val offsetInMillis: Int = tz.rawOffset - accountService.updateUserExtra( - Utils.getCurrentLanguage(), - // 时区偏移量单位是秒 - offsetInMillis / 1000, - tz.displayName - ) - // 设置当前登录用户 ID - AppState.UserId = resp.id return true } catch (e: Exception) { return false @@ -102,11 +96,9 @@ class MainActivity : ComponentActivity() { JPushInterface.init(this) -// JPushInterface.setAlias(this, 0, "myTest") - -// Log.d("MainActivity", "pushId: $pushId") enableEdgeToEdge() + // 初始化腾讯云通信 SDK scope.launch { @@ -115,9 +107,10 @@ class MainActivity : ComponentActivity() { var startDestination = NavigationRoute.Login.route // 如果有登录态,且记住登录状态,且账号有效,则初始化 FCM,下一步进入首页 if (AppStore.token != null && AppStore.rememberMe && isAccountValidate) { - Messaging.RegistDevice(scope, this@MainActivity) + AppState.initWithAccount(scope, this@MainActivity) startDestination = NavigationRoute.Index.route } + setContent { Navigation(startDestination) { navController -> // 处理带有 postId 的通知点击 diff --git a/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt b/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt index c354f24..22126c3 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt @@ -2,12 +2,14 @@ package com.aiosman.riderpro.data import com.aiosman.riderpro.AppState import com.aiosman.riderpro.data.api.ApiClient +import com.aiosman.riderpro.data.api.AppConfig 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.RegisterMessageChannelRequestBody import com.aiosman.riderpro.data.api.RegisterRequestBody import com.aiosman.riderpro.data.api.ResetPasswordRequestBody +import com.aiosman.riderpro.data.api.TrtcSignResponseBody import com.aiosman.riderpro.data.api.UpdateNoticeRequestBody import com.aiosman.riderpro.data.api.UpdateUserLangRequestBody import com.aiosman.riderpro.entity.AccountFavouriteEntity @@ -46,6 +48,8 @@ data class AccountProfile( val bio: String, // 主页背景图 val banner: String?, + // trtcUserId + val trtcUserId: String, ) { /** * 转换为Entity @@ -65,7 +69,8 @@ data class AccountProfile( return@let "${ApiClient.BASE_SERVER}$it" } null - } + }, + trtcUserId = trtcUserId ) } } @@ -355,6 +360,13 @@ interface AccountService { * 更新用户额外信息 */ suspend fun updateUserExtra(language: String, timeOffset: Int, timezone: String) + + /** + * 获取腾讯云TRTC签名 + */ + suspend fun getMyTrtcSign(): TrtcSignResponseBody + + suspend fun getAppConfig(): AppConfig } class AccountServiceImpl : AccountService { @@ -490,4 +502,15 @@ class AccountServiceImpl : AccountService { ApiClient.api.updateUserExtra(UpdateUserLangRequestBody(language, timeOffset, timezone)) } + override suspend fun getMyTrtcSign(): TrtcSignResponseBody { + val resp = ApiClient.api.getChatSign() + val body = resp.body() ?: throw ServiceException("Failed to get trtc sign") + return body.data + } + + override suspend fun getAppConfig(): AppConfig { + val resp = ApiClient.api.getAppConfig() + val body = resp.body() ?: throw ServiceException("Failed to get app config") + return body.data + } } \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt b/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt index ba99fed..543d96c 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/api/RiderProAPI.kt @@ -107,6 +107,18 @@ data class UpdateUserLangRequestBody( val timezone: String, ) +data class TrtcSignResponseBody( + @SerializedName("sig") + val sig: String, + @SerializedName("userId") + val userId: String, +) + +data class AppConfig( + @SerializedName("trtcAppId") + val trtcAppId: Int, +) + interface RiderProAPI { @POST("register") suspend fun register(@Body body: RegisterRequestBody): Response @@ -300,4 +312,9 @@ interface RiderProAPI { @Body body: UpdateUserLangRequestBody ): Response + @GET("account/my/chat/sign") + suspend fun getChatSign(): Response> + + @GET("app/info") + suspend fun getAppConfig(): Response> } \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/entity/Account.kt b/app/src/main/java/com/aiosman/riderpro/entity/Account.kt index d71aa40..bf72fdf 100644 --- a/app/src/main/java/com/aiosman/riderpro/entity/Account.kt +++ b/app/src/main/java/com/aiosman/riderpro/entity/Account.kt @@ -59,6 +59,8 @@ data class AccountProfileEntity( val isFollowing: Boolean, // 主页背景图 val banner: String?, + // trtcUserId + val trtcUserId: String, ) /** diff --git a/app/src/main/java/com/aiosman/riderpro/exp/Date.kt b/app/src/main/java/com/aiosman/riderpro/exp/Date.kt index 4cfc624..6dd0086 100644 --- a/app/src/main/java/com/aiosman/riderpro/exp/Date.kt +++ b/app/src/main/java/com/aiosman/riderpro/exp/Date.kt @@ -8,6 +8,7 @@ import com.aiosman.riderpro.R import com.aiosman.riderpro.data.api.ApiClient import java.util.Date import java.util.Locale +import java.util.concurrent.TimeUnit /** * 格式化时间为 xx 前 @@ -60,4 +61,23 @@ fun Date.formatPostTime2(): String { val hour = calendar.get(Calendar.HOUR_OF_DAY) val minute = calendar.get(Calendar.MINUTE) return "$year.$month.$day $hour:$minute" +} + +fun Date.formatChatTime(context: Context): String { + val now = Date() + val diffInMillis = now.time - this.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 -> context.getString(R.string.seconds_ago, seconds) + minutes < 60 -> context.getString(R.string.minutes_ago, minutes) + hours < 24 -> SimpleDateFormat("HH:mm", Locale.getDefault()).format(this) + days < 365 -> SimpleDateFormat("MM-dd HH:mm", Locale.getDefault()).format(this) + else -> SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(this) + } } \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt b/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt index 18ac639..4db61a4 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/Navi.kt @@ -29,6 +29,7 @@ import com.aiosman.riderpro.LocalNavController import com.aiosman.riderpro.LocalSharedTransitionScope import com.aiosman.riderpro.ui.account.AccountEditScreen2 import com.aiosman.riderpro.ui.account.ResetPasswordScreen +import com.aiosman.riderpro.ui.chat.ChatScreen import com.aiosman.riderpro.ui.comment.CommentsScreen import com.aiosman.riderpro.ui.favourite.FavouriteListPage import com.aiosman.riderpro.ui.favourite.FavouriteNoticeScreen @@ -84,6 +85,7 @@ sealed class NavigationRoute( data object FollowingList : NavigationRoute("FollowingList/{id}") data object ResetPassword : NavigationRoute("ResetPassword") data object FavouriteList : NavigationRoute("FavouriteList") + data object Chat : NavigationRoute("Chat/{id}") } @@ -340,6 +342,16 @@ fun NavigationController( FavouriteListPage() } } + composable( + route = NavigationRoute.Chat.route, + arguments = listOf(navArgument("id") { type = NavType.StringType }) + ) { + CompositionLocalProvider( + LocalAnimatedContentScope provides this, + ) { + ChatScreen(it.arguments?.getString("id")!!) + } + } } @@ -381,4 +393,11 @@ fun NavHostController.navigateToPost( .replace("{highlightCommentId}", highlightCommentId.toString()) .replace("{initImagePagerIndex}", initImagePagerIndex.toString()) ) +} + +fun NavHostController.navigateToChat(id: String) { + navigate( + route = NavigationRoute.Chat.route + .replace("{id}", id) + ) } \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/chat/ChatScreen.kt b/app/src/main/java/com/aiosman/riderpro/ui/chat/ChatScreen.kt new file mode 100644 index 0000000..79ffaf5 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/chat/ChatScreen.kt @@ -0,0 +1,373 @@ +package com.aiosman.riderpro.ui.chat + +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +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.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.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.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import com.aiosman.riderpro.LocalNavController +import com.aiosman.riderpro.R +import com.aiosman.riderpro.ui.composables.CustomAsyncImage +import com.aiosman.riderpro.ui.composables.StatusBarSpacer +import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import kotlinx.coroutines.launch + + +@Composable +fun ChatScreen(userId: String) { + val navController = LocalNavController.current + val context = LocalNavController.current.context + val viewModel = viewModel( + key = "ChatViewModel_$userId", + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return ChatViewModel(userId) as T + } + } + ) + LaunchedEffect(Unit) { + viewModel.init(context = context) + } + DisposableEffect(Unit) { + onDispose { + viewModel.UnRegistListener() + } + } + val listState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + val navigationBarHeight = with(LocalDensity.current) { + WindowInsets.navigationBars.getBottom(this).toDp() + } + LaunchedEffect(listState) { + snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index } + .collect { index -> + if (index == listState.layoutInfo.totalItemsCount - 1) { + coroutineScope.launch { + viewModel.onLoadMore(context) + } + } + } + } + Scaffold( + modifier = Modifier + .fillMaxSize(), + topBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .border(1.dp, Color(0xffe5e5e5)) + .background(Color.White) + ) { + StatusBarSpacer() + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Image( + painter = painterResource(R.drawable.rider_pro_nav_back), + modifier = Modifier + .size(36.dp) + .noRippleClickable { + navController.popBackStack() + }, + contentDescription = null + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = viewModel.userProfile?.nickName ?: "", + modifier = Modifier.weight(1f), + style = TextStyle( + color = Color.Black, + fontSize = 18.sp, + fontWeight = androidx.compose.ui.text.font.FontWeight.Bold + ) + ) + } + } + }, + bottomBar = { + Column( + modifier = Modifier + .fillMaxWidth() + .imePadding() + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .background(Color(0xfff7f7f7)) + ) + Spacer(modifier = Modifier.height(8.dp)) + ChatInput() { + viewModel.sendMessage(it, context) + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(navigationBarHeight) + ) + } + } + ) { paddingValues -> + LazyColumn( + state = listState, + modifier = Modifier + .padding(paddingValues) + .background(Color(0xfff7f7f7)) + .fillMaxSize(), + reverseLayout = true, + verticalArrangement = Arrangement.Top + ) { + val chatList = viewModel.getDisplayChatList() + items(chatList.size) { index -> + Box( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + ChatItem(item = chatList[index], viewModel.myProfile?.trtcUserId!!) + } + } + } + } +} + +@Composable +fun ChatSelfItem(item: ChatItem) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth() + ) { + Column( + horizontalAlignment = androidx.compose.ui.Alignment.End, + ) { + Row() { + Text( + text = item.time, + style = TextStyle( + color = Color.Gray, + fontSize = 14.sp + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = item.nickname, + style = TextStyle( + color = Color.Black, + fontSize = 14.sp + ) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xFF000000)) + .padding(vertical = 8.dp, horizontal = 16.dp), + ) { + Text( + text = item.message, + style = TextStyle( + color = Color.White, + fontSize = 16.sp, + ), + textAlign = TextAlign.End, + + ) + } + + } + Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(16.dp)) + ) { + CustomAsyncImage( + imageUrl = item.avatar, + modifier = Modifier.fillMaxSize(), + contentDescription = "avatar" + ) + } + } + } +} + +@Composable +fun ChatOtherItem(item: ChatItem) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + ) { + Row( + horizontalArrangement = Arrangement.Start, + modifier = Modifier.fillMaxWidth() + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(16.dp)) + ) { + CustomAsyncImage( + imageUrl = item.avatar, + modifier = Modifier.fillMaxSize(), + contentDescription = "avatar" + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Column { + Row() { + Text( + text = item.nickname, + style = TextStyle( + color = Color.Black, + fontSize = 14.sp + ) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = item.time, + style = TextStyle( + color = Color.Gray, + fontSize = 14.sp + ) + ) + } + Spacer(modifier = Modifier.height(4.dp)) + Box( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(Color(0xffFFFFFF)) + .padding(vertical = 8.dp, horizontal = 16.dp), + ) { + Text( + text = item.message, + style = TextStyle( + color = Color.Black, + fontSize = 16.sp + ) + ) + } + + } + } + + } +} + +@Composable +fun ChatItem(item: ChatItem, currentUserId: String) { + val isCurrentUser = item.userId == currentUserId + if (isCurrentUser) { + ChatSelfItem(item) + } else { + ChatOtherItem(item) + } +} + +@Composable +fun ChatInput( + onSend: (String) -> Unit = {} +) { + var text by remember { mutableStateOf("") } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp, horizontal = 16.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(16.dp)) + .background(Color(0xffe5e5e5)) + .padding(horizontal = 16.dp), + contentAlignment = androidx.compose.ui.Alignment.CenterStart + + ) { + BasicTextField( + value = text, + onValueChange = { + text = it + }, + textStyle = TextStyle( + color = Color.Black, + fontSize = 16.sp + ), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Crossfade(targetState = text.isNotEmpty(), animationSpec = tween(500)) { isNotEmpty -> + Image( + painter = rememberUpdatedState( + if (isNotEmpty) painterResource(id = R.drawable.rider_pro_send) else painterResource( + id = R.drawable.rider_pro_send_disable + ) + ).value, + contentDescription = "Send", + modifier = Modifier + .size(32.dp) + .noRippleClickable { + if (text.isNotEmpty()) { + onSend(text) + text = "" + } + }, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/chat/ChatViewModel.kt b/app/src/main/java/com/aiosman/riderpro/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..d309814 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/chat/ChatViewModel.kt @@ -0,0 +1,173 @@ +package com.aiosman.riderpro.ui.chat + +import android.content.Context +import android.icu.util.Calendar +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.aiosman.riderpro.data.AccountService +import com.aiosman.riderpro.data.AccountServiceImpl +import com.aiosman.riderpro.data.UserService +import com.aiosman.riderpro.data.UserServiceImpl +import com.aiosman.riderpro.entity.AccountProfileEntity +import com.aiosman.riderpro.exp.formatChatTime +import com.tencent.imsdk.v2.V2TIMAdvancedMsgListener +import com.tencent.imsdk.v2.V2TIMManager +import com.tencent.imsdk.v2.V2TIMMessage +import com.tencent.imsdk.v2.V2TIMSendCallback +import com.tencent.imsdk.v2.V2TIMValueCallback +import kotlinx.coroutines.launch + +data class ChatItem( + val message: String, + val avatar: String, + val time: String, + val userId: String, + val nickname: String +) + +class ChatViewModel( + val userId: String, +) : ViewModel() { + var chatData by mutableStateOf>(emptyList()) + var userProfile by mutableStateOf(null) + var myProfile by mutableStateOf(null) + val userService: UserService = UserServiceImpl() + val accountService: AccountService = AccountServiceImpl() + var textMessageListener: V2TIMAdvancedMsgListener? = null + var hasMore by mutableStateOf(true) + var isLoading by mutableStateOf(false) + var lastMessage : V2TIMMessage? = null + fun init(context: Context) { + // 获取用户信息 + viewModelScope.launch { + val resp = userService.getUserProfile(userId) + userProfile = resp + myProfile = accountService.getMyAccountProfile() + + RegistListener(context) + fetchHistoryMessage(context) + } + } + + fun RegistListener(context: Context) { + textMessageListener = object : V2TIMAdvancedMsgListener() { + override fun onRecvNewMessage(msg: V2TIMMessage?) { + super.onRecvNewMessage(msg) + chatData = listOf(convertToChatItem(msg!!, context)) + chatData + } + } + V2TIMManager.getMessageManager().addAdvancedMsgListener(textMessageListener); + } + + fun UnRegistListener() { + V2TIMManager.getMessageManager().removeAdvancedMsgListener(textMessageListener); + } + + + fun convertToChatItem(message: V2TIMMessage, context: Context): ChatItem { + val avatar = if (message.sender == userProfile?.trtcUserId) { + userProfile?.avatar ?: "" + } else { + myProfile?.avatar ?: "" + } + val nickname = if (message.sender == userProfile?.trtcUserId) { + userProfile?.nickName ?: "" + } else { + myProfile?.nickName ?: "" + } + val timestamp = message.timestamp + val calendar = Calendar.getInstance() + calendar.timeInMillis = timestamp * 1000 + + return ChatItem( + message = message.textElem.text, + avatar = avatar, + time = calendar.time.formatChatTime(context), + userId = message.sender, + nickname = nickname + ) + } + + fun onLoadMore(context: Context) { + if (!hasMore || isLoading) { + return + } + isLoading = true + viewModelScope.launch { + V2TIMManager.getMessageManager().getC2CHistoryMessageList( + userProfile?.trtcUserId!!, + 20, + lastMessage, + object : V2TIMValueCallback> { + override fun onSuccess(p0: List?) { + chatData = chatData + (p0 ?: emptyList()).map { + convertToChatItem(it, context) + } + if ((p0?.size ?: 0) < 20) { + hasMore = false + } + lastMessage = p0?.lastOrNull() + isLoading = false + Log.d("ChatViewModel", "fetch history message success") + } + override fun onError(p0: Int, p1: String?) { + Log.e("ChatViewModel", "fetch history message error: $p1") + isLoading = false + } + } + ) + } + } + + fun sendMessage(message: String, context: Context) { + V2TIMManager.getInstance().sendC2CTextMessage( + message, + userProfile?.trtcUserId!!, + object : V2TIMSendCallback { + override fun onProgress(p0: Int) { + + } + override fun onError(p0: Int, p1: String?) { + Log.e("ChatViewModel", "send message error: $p1") + } + + override fun onSuccess(p0: V2TIMMessage?) { + Log.d("ChatViewModel", "send message success") + chatData = listOf(convertToChatItem(p0!!, context)) + chatData + } + } + ) + } + + fun fetchHistoryMessage(context: Context) { + V2TIMManager.getMessageManager().getC2CHistoryMessageList( + userProfile?.trtcUserId!!, + 20, + null, + object : V2TIMValueCallback> { + override fun onSuccess(p0: List?) { + chatData = (p0 ?: emptyList()).map { + convertToChatItem(it, context) + } + if ((p0?.size ?: 0) < 20) { + hasMore = false + } + lastMessage = p0?.lastOrNull() + Log.d("ChatViewModel", "fetch history message success") + } + + override fun onError(p0: Int, p1: String?) { + Log.e("ChatViewModel", "fetch history message error: $p1") + } + } + ) + } + + fun getDisplayChatList(): List { + return chatData + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profile.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profile.kt index db2be7a..42a43f0 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profile.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/profile/Profile.kt @@ -30,6 +30,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -78,6 +79,7 @@ import com.aiosman.riderpro.ui.NavigationRoute import com.aiosman.riderpro.ui.composables.CustomAsyncImage import com.aiosman.riderpro.ui.composables.MenuItem import com.aiosman.riderpro.ui.modifiers.noRippleClickable +import com.aiosman.riderpro.ui.navigateToChat import com.aiosman.riderpro.ui.navigateToPost import com.aiosman.riderpro.ui.post.NewPostViewModel import com.aiosman.riderpro.ui.post.PostViewModel @@ -526,6 +528,28 @@ fun CommunicationOperatorGroup( style = TextStyle(fontWeight = FontWeight.W600, fontStyle = FontStyle.Italic), ) } + Spacer(modifier = Modifier.width(16.dp)) + Box( + modifier = Modifier + .size(width = 142.dp, height = 40.dp) + .noRippleClickable { + navController.navigateToChat(accountProfileEntity.id.toString()) + }, + contentAlignment = Alignment.Center + ) { + Image( + modifier = Modifier.fillMaxSize(), + painter = painterResource(id = R.mipmap.rider_pro_btn_bg_grey), + contentDescription = "" + ) + Text( + text = "Chat", + fontSize = 14.sp, + color = Color.Black, + fontWeight = FontWeight.Bold, + style = TextStyle(fontWeight = FontWeight.Bold, fontStyle = FontStyle.Italic) + ) + } } if (isSelf) { diff --git a/app/src/main/java/com/aiosman/riderpro/ui/login/emailsignup.kt b/app/src/main/java/com/aiosman/riderpro/ui/login/emailsignup.kt index c5c90df..ea21a36 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/login/emailsignup.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/login/emailsignup.kt @@ -156,17 +156,7 @@ fun EmailSignupScreen() { } // 获取token 信息 try { - val resp = accountService.getMyAccount() - AppState.UserId = resp.id - val calendar: Calendar = Calendar.getInstance() - val tz: TimeZone = calendar.timeZone - val offsetInMillis: Int = tz.rawOffset - accountService.updateUserExtra( - Utils.getCurrentLanguage(), - offsetInMillis / 1000, - tz.displayName - ) - Messaging.RegistDevice(scope, context) + AppState.initWithAccount(scope, context) } catch (e: ServiceException) { scope.launch(Dispatchers.Main) { Toast.makeText(context, "Failed to get account", Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/com/aiosman/riderpro/ui/login/signup.kt b/app/src/main/java/com/aiosman/riderpro/ui/login/signup.kt index 32aa272..dae560c 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/login/signup.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/login/signup.kt @@ -86,17 +86,9 @@ fun SignupScreen() { } // 获取token 信息 try { - val resp = accountService.getMyAccount() - val calendar: Calendar = Calendar.getInstance() - val tz: TimeZone = calendar.timeZone - val offsetInMillis: Int = tz.rawOffset - accountService.updateUserExtra( - Utils.getCurrentLanguage(), - offsetInMillis / 1000, - tz.displayName - ) - AppState.UserId = resp.id - Messaging.RegistDevice(coroutineScope, context) + AppState.initWithAccount(coroutineScope, context) + } catch (e: Exception) { + Log.e(TAG, "Failed to init with account", e) } catch (e: ServiceException) { coroutineScope.launch(Dispatchers.Main) { Toast.makeText(context, "Failed to get account", Toast.LENGTH_SHORT) diff --git a/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt b/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt index d73c003..ac25526 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/login/userauth.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.aiosman.riderpro.AppState import com.aiosman.riderpro.AppStore import com.aiosman.riderpro.LocalNavController import com.aiosman.riderpro.Messaging @@ -83,16 +84,7 @@ fun UserAuthScreen() { this.rememberMe = rememberMe saveData() } - accountService.getMyAccount() - val calendar: Calendar = Calendar.getInstance() - val tz: TimeZone = calendar.timeZone - val offsetInMillis: Int = tz.rawOffset - accountService.updateUserExtra( - Utils.getCurrentLanguage(), - offsetInMillis / 1000, - tz.displayName - ) - Messaging.RegistDevice(scope, context) + AppState.initWithAccount(scope, context) navController.navigate(NavigationRoute.Index.route) { popUpTo(NavigationRoute.Login.route) { inclusive = true } } diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 8de99af..f06b62b 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -70,4 +70,6 @@ 找回 邮件已发送!请查收您的邮箱,按照邮件中的指示重置密码。 邮件发送失败,请检查您的网络连接或稍后重试。 + %1d秒前 + %1d分钟前 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 29f2fe3..47c59c3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -69,4 +69,6 @@ Recover An email has been sent to your registered email address. Please check your inbox and follow the instructions to reset your password. Failed to send email. Please check your network connection or try again later. + %1d seconds ago + %1d minutes ago \ No newline at end of file