改包名com.aiosman.ravenow

This commit is contained in:
2024-11-17 20:07:42 +08:00
parent 914cfca6be
commit 074244c0f8
168 changed files with 897 additions and 970 deletions

View File

@@ -0,0 +1,163 @@
package com.aiosman.ravenow
import android.content.Context
import android.content.Intent
import android.icu.util.Calendar
import android.icu.util.TimeZone
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel
import com.aiosman.ravenow.ui.favourite.FavouriteNoticeViewModel
import com.aiosman.ravenow.ui.follower.FollowerNoticeViewModel
import com.aiosman.ravenow.ui.follower.FollowingListViewModel
import com.aiosman.ravenow.ui.index.IndexViewModel
import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel
import com.aiosman.ravenow.ui.index.tabs.moment.tabs.timeline.TimelineMomentViewModel
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
import com.aiosman.ravenow.ui.index.tabs.search.DiscoverViewModel
import com.aiosman.ravenow.ui.index.tabs.search.SearchViewModel
import com.aiosman.ravenow.ui.like.LikeNoticeViewModel
import com.aiosman.ravenow.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 com.tencent.imsdk.v2.V2TIMUserFullInfo
import kotlinx.coroutines.CoroutineScope
import kotlin.coroutines.suspendCoroutine
object AppState {
var UserId: Int? = null
var profile :AccountProfileEntity? = null
var darkMode = false
var appTheme by mutableStateOf<AppThemeData>(LightThemeColors())
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
var profileResult = accountService.getMyAccountProfile()
profile = profileResult
// 获取当前用户资料
// 注册 JPush
Messaging.registerDevice(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()
loginToTrtc(sign.userId, sign.sig)
updateTrtcUserProfile()
// 登录成功后启动TrtcService
context.startService(
Intent(context, TrtcService::class.java)
)
} catch (e: Exception) {
e.printStackTrace()
}
}
suspend fun loginToTrtc(userId: String, userSig: String): Boolean {
return suspendCoroutine { continuation ->
V2TIMManager.getInstance().login(userId, userSig, object : V2TIMCallback {
override fun onError(code: Int, desc: String?) {
continuation.resumeWith(Result.failure(Exception("Login failed: $code, $desc")))
}
override fun onSuccess() {
continuation.resumeWith(Result.success(true))
}
})
}
}
suspend fun updateTrtcUserProfile() {
val accountService: AccountService = AccountServiceImpl()
val profile = accountService.getMyAccountProfile()
val info = V2TIMUserFullInfo()
info.setNickname(profile.nickName)
info.faceUrl = profile.avatar
info.selfSignature = profile.bio
return suspendCoroutine { continuation ->
V2TIMManager.getInstance().setSelfInfo(info, object : V2TIMCallback {
override fun onError(code: Int, desc: String?) {
continuation.resumeWith(Result.failure(Exception("Update user profile failed: $code, $desc")))
}
override fun onSuccess() {
continuation.resumeWith(Result.success(Unit))
}
})
}
}
fun switchTheme() {
darkMode = !darkMode
appTheme = if (darkMode) {
DarkThemeColors()
} else {
LightThemeColors()
}
AppStore.saveDarkMode(darkMode)
}
fun ReloadAppState(context: Context) {
// 重置动态列表页面
TimelineMomentViewModel.ResetModel()
// 重置我的页面
MyProfileViewModel.ResetModel()
// 重置发现页面
DiscoverViewModel.ResetModel()
// 重置搜索页面
SearchViewModel.ResetModel()
// 重置消息页面
MessageListViewModel.ResetModel()
// 重置点赞通知页面
LikeNoticeViewModel.ResetModel()
// 重置收藏页面
FavouriteListViewModel.ResetModel()
// 重置收藏通知页面
FavouriteNoticeViewModel.ResetModel()
// 重置粉丝通知页面
FollowerNoticeViewModel.ResetModel()
// 重置关注列表页面
FollowingListViewModel.ResetModel()
// 重置关注通知页面
IndexViewModel.ResetModel()
UserId = null
// 关闭 TrtcService
val trtcService = Intent(
context,
TrtcService::class.java
)
context.stopService(trtcService)
}
}

View File

@@ -0,0 +1,40 @@
package com.aiosman.ravenow
import com.aiosman.ravenow.data.ChatService
import com.aiosman.ravenow.data.ChatServiceImpl
import com.aiosman.ravenow.entity.ChatNotification
/**
* 保存一些关于聊天的状态
*/
object ChatState {
val chatService: ChatService = ChatServiceImpl()
var chatNotificationList = mutableListOf<ChatNotification>()
suspend fun getStrategyByTargetTrtcId(targetTrtcId: String): ChatNotification? {
// 先从缓存中查找
if (chatNotificationList.isNotEmpty()) {
chatNotificationList.forEach {
if (it.targetTrtcId == targetTrtcId) {
return it
}
}
}
// 缓存中没有再从网络获取
chatService.getChatNotifications(targetTrtcId)?.let {
chatNotificationList.add(it)
return it
}
// 存在未设置策略的情况
return null
}
suspend fun updateChatNotification(targetUserId: Int, strategy: String): ChatNotification {
val updatedData = chatService.updateChatNotification(targetUserId, strategy)
chatNotificationList = chatNotificationList.filter {
it.targetUserId != targetUserId
}.toMutableList().apply {
add(updatedData)
}
return updatedData
}
}

View File

@@ -0,0 +1,73 @@
package com.aiosman.ravenow
import androidx.compose.ui.graphics.Color
//var AppColors = LightThemeColors()
//var AppColors = if (AppState.darkMode) DarkThemeColors() else LightThemeColors()
open class AppThemeData(
var main: Color,
var mainText: Color,
var basicMain: Color,
var nonActive: Color,
var text: Color,
var nonActiveText: Color,
var secondaryText: Color,
var loadingMain: Color,
var loadingText: Color,
var disabledBackground: Color,
var background: Color,
var decentBackground: Color,
var divider: Color,
var inputBackground: Color,
var inputHint: Color,
var error: Color,
var checkedBackground: Color,
var checkedText: Color,
var chatActionColor: Color,
)
class LightThemeColors : AppThemeData(
main = Color(0xffda3832),
mainText = Color(0xffffffff),
basicMain = Color(0xfff0f0f0),
nonActive = Color(0xfff5f5f5),
text = Color(0xff333333),
nonActiveText = Color(0xff333333),
secondaryText = Color(0x99000000),
loadingMain = Color(0xFFD95757),
loadingText = Color(0xffffffff),
disabledBackground = Color(0xFFD0D0D0),
background = Color(0xFFFFFFFF),
divider = Color(0xFFEbEbEb),
inputBackground = Color(0xFFF7f7f7),
inputHint = Color(0xffdadada),
error = Color(0xffFF0000),
checkedBackground = Color(0xff000000),
checkedText = Color(0xffFFFFFF),
decentBackground = Color(0xfff5f5f5),
chatActionColor = Color(0xffe0e0e0)
)
class DarkThemeColors : AppThemeData(
main = Color(0xffda3832),
mainText = Color(0xffffffff),
basicMain = Color(0xFF1C1C1C),
nonActive = Color(0xff1f1f1f),
text = Color(0xffffffff),
nonActiveText = Color(0xff888888),
secondaryText = Color(0x99ffffff),
loadingMain = Color(0xFFD95757),
loadingText = Color(0xff000000),
disabledBackground = Color(0xFF3A3A3A),
background = Color(0xFF121212),
divider = Color(0xFF282828),
inputBackground = Color(0xFF1C1C1C),
inputHint = Color(0xff888888),
error = Color(0xffFF0000),
checkedBackground = Color(0xffffffff),
checkedText = Color(0xff000000),
decentBackground = Color(0xFF171717),
chatActionColor = Color(0xFF3D3D3D)
)

View File

@@ -0,0 +1,35 @@
package com.aiosman.ravenow
object ConstVars {
// api 地址
// const val BASE_SERVER = "http://192.168.31.131:8088"
// const val BASE_SERVER = "http://192.168.142.141:8088"
// const val BASE_SERVER = "https://8.137.22.101:8088"
const val BASE_SERVER = "https://rider-pro.aiosman.com/beta_api"
const val MOMENT_LIKE_CHANNEL_ID = "moment_like"
const val MOMENT_LIKE_CHANNEL_NAME = "Moment Like"
/**
* 上传头像图片大小限制
* 10M
*/
const val AVATAR_FILE_SIZE_LIMIT = 1024 * 1024 * 10
/**
* 上传头像图片压缩时最大的尺寸
* 512
*/
const val AVATAR_IMAGE_MAX_SIZE = 512
/**
* 上传 banner 图片大小限制
*/
const val BANNER_IMAGE_MAX_SIZE = 2048
// 用户协议地址
const val DICT_KEY_PRIVATE_POLICY_URL = "private_policy"
// 重置邮箱间隔
const val DIC_KEY_RESET_EMAIL_INTERVAL = "send_reset_password_timeout"
}

View File

@@ -0,0 +1,67 @@
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<ImageItem>) {
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()
}

View File

@@ -0,0 +1,49 @@
package com.aiosman.ravenow
import android.content.Context
import android.content.Intent
import android.util.Log
import cn.jpush.android.api.NotificationMessage
import cn.jpush.android.service.JPushMessageReceiver
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
data class ActionExtra(
@SerializedName("action")
val action: String,
@SerializedName("postId")
val postId: String?,
@SerializedName("commentId")
val commentId: String?
)
class JpushReciver : JPushMessageReceiver() {
val gson = Gson()
override fun onInAppMessageClick(p0: Context?, p1: NotificationMessage?) {
super.onInAppMessageClick(p0, p1)
// 打开自定义的页面
Log.d("JpushReciver", "onInAppMessageClick")
}
override fun onNotifyMessageOpened(context: Context?, message: NotificationMessage) {
super.onNotifyMessageOpened(context, message)
// 打开自定义的页面
Log.d("JpushReciver", "onNotifyMessageOpened")
val actionExtra = message.notificationExtras?.let {
gson.fromJson(it, ActionExtra::class.java)
}
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
actionExtra?.postId?.let {
intent.putExtra("POST_ID", it)
}
actionExtra?.commentId?.let {
intent.putExtra("COMMENT_ID", it)
}
actionExtra?.action?.let {
intent.putExtra("ACTION", it)
}
context?.startActivity(intent)
}
}

View File

@@ -0,0 +1,8 @@
package com.aiosman.ravenow
import cn.jpush.android.service.JCommonService
class JpushService : JCommonService() {
}

View File

@@ -0,0 +1,239 @@
package com.aiosman.ravenow
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.lifecycle.ProcessLifecycleOwner
import androidx.navigation.NavHostController
import cn.jiguang.api.utils.JCollectionAuth
import cn.jpush.android.api.JPushInterface
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.aiosman.ravenow.data.UserService
import com.aiosman.ravenow.data.UserServiceImpl
import com.aiosman.ravenow.ui.Navigation
import com.aiosman.ravenow.ui.NavigationRoute
import com.aiosman.ravenow.ui.dialogs.CheckUpdateDialog
import com.aiosman.ravenow.ui.navigateToPost
import com.aiosman.ravenow.ui.post.NewPostViewModel
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity() {
// Firebase Analytics
private lateinit var analytics: FirebaseAnalytics
private val scope = CoroutineScope(Dispatchers.Main)
val context = this
// 请求通知权限
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) { isGranted: Boolean ->
if (isGranted) {
// FCM SDK (and your app) can post notifications.
} else {
}
}
/**
* 获取账号信息
*/
private suspend fun getAccount(): Boolean {
val accountService: AccountService = AccountServiceImpl()
try {
val resp = accountService.getMyAccount()
return true
} catch (e: Exception) {
return false
}
}
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.P)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 监听应用生命周期
ProcessLifecycleOwner.get().lifecycle.addObserver(MainActivityLifecycleObserver())
// 创建通知渠道
createNotificationChannel()
// 沉浸式状态栏
WindowCompat.setDecorFitsSystemWindows(window, false)
// 初始化 Places SDK
// 初始化 Firebase Analytics
analytics = Firebase.analytics
// 请求通知权限
askNotificationPermission()
// 加载一些本地化的配置
AppStore.init(this)
JPushInterface.setDebugMode(true);
// 调整点一初始化代码前增加setAuth调用
JCollectionAuth.setAuth(this, true)
JPushInterface.init(this)
if (AppState.darkMode) {
window.decorView.setBackgroundColor(android.graphics.Color.BLACK)
}
enableEdgeToEdge()
scope.launch {
// 检查是否有登录态
val isAccountValidate = getAccount()
var startDestination = NavigationRoute.Login.route
// 如果有登录态,且记住登录状态,且账号有效,则初始化 FCM下一步进入首页
if (AppStore.token != null && AppStore.rememberMe && isAccountValidate) {
AppState.initWithAccount(scope, this@MainActivity)
startDestination = NavigationRoute.Index.route
}
setContent {
CompositionLocalProvider(
LocalAppTheme provides AppState.appTheme
) {
CheckUpdateDialog()
Navigation(startDestination) { navController ->
// 处理带有 postId 的通知点击
val postId = intent.getStringExtra("POST_ID")
var commentId = intent.getStringExtra("COMMENT_ID")
val action = intent.getStringExtra("ACTION")
if (action == "newFollow") {
navController.navigate(NavigationRoute.Followers.route)
return@Navigation
}
if (action == "followCount") {
navController.navigate(NavigationRoute.Followers.route)
return@Navigation
}
if (action == "TRTC_NEW_MESSAGE") {
val userService:UserService = UserServiceImpl()
val sender = intent.getStringExtra("SENDER")
sender?.let {
scope.launch {
try {
val profile = userService.getUserProfileByTrtcUserId(it)
navController.navigate(NavigationRoute.Chat.route.replace(
"{id}",
profile.id.toString()
))
}catch (e:Exception){
e.printStackTrace()
}
}
}
return@Navigation
}
if (commentId == null) {
commentId = "0"
}
if (postId != null) {
Log.d("MainActivity", "Navigation to Post$postId")
navController.navigateToPost(
id = postId.toInt(),
highlightCommentId = commentId.toInt(),
initImagePagerIndex = 0
)
}
// 处理分享过来的图片
if (intent?.action == Intent.ACTION_SEND || intent?.action == Intent.ACTION_SEND_MULTIPLE) {
val imageUris: List<Uri>? = if (intent.action == Intent.ACTION_SEND) {
listOf(intent.getParcelableExtra(Intent.EXTRA_STREAM)!!)
} else {
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
}
NewPostViewModel.asNewPostWithImageUris(imageUris!!.map { it.toString() })
navController.navigate(NavigationRoute.NewPost.route)
}
}
}
}
}
}
/**
* 请求通知权限
*/
private fun askNotificationPermission() {
// This is only necessary for API level >= 33 (TIRAMISU)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
) {
// FCM SDK (and your app) can post notifications.
} else if (shouldShowRequestPermissionRationale(android.Manifest.permission.POST_NOTIFICATIONS)) {
} else {
// Directly ask for the permission
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
/**
* 创建通知渠道
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = ConstVars.MOMENT_LIKE_CHANNEL_ID
val channelName = ConstVars.MOMENT_LIKE_CHANNEL_NAME
val channel =
NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_DEFAULT)
val notificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
}
val LocalNavController = compositionLocalOf<NavHostController> {
error("NavController not provided")
}
@OptIn(ExperimentalSharedTransitionApi::class)
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope> {
error("SharedTransitionScope not provided")
}
val LocalAnimatedContentScope = compositionLocalOf<AnimatedContentScope> {
error("AnimatedContentScope not provided")
}
val LocalAppTheme = compositionLocalOf<AppThemeData> {
error("AppThemeData not provided")
}

View File

@@ -0,0 +1,18 @@
package com.aiosman.ravenow
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
object MainActivityLifecycle {
var isForeground = false
}
class MainActivityLifecycleObserver : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
MainActivityLifecycle.isForeground = true
}
override fun onPause(owner: LifecycleOwner) {
MainActivityLifecycle.isForeground = false
}
}

View File

@@ -0,0 +1,58 @@
package com.aiosman.ravenow
import android.content.Context
import android.util.Log
import cn.jpush.android.api.JPushInterface
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.AccountServiceImpl
import com.google.android.gms.tasks.OnCompleteListener
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
object Messaging {
fun registerDevice(scope: CoroutineScope, context: Context) {
registerJpush(scope, context)
// registerFCM(scope)
}
suspend fun unregisterDevice(context: Context) {
unregisterJpush(context)
}
fun registerJpush(scope: CoroutineScope, context: Context) {
val accountService: AccountService = AccountServiceImpl()
val regId = JPushInterface.getRegistrationID(context)
scope.launch {
accountService.registerMessageChannel(client = "jpush", identifier = regId)
}
}
private suspend fun unregisterJpush(context: Context) {
val accountService: AccountService = AccountServiceImpl()
val regId = JPushInterface.getRegistrationID(context)
accountService.unregisterMessageChannel(client = "jpush", identifier = regId)
}
fun registerFCM(scope: CoroutineScope) {
val accountService: AccountService = AccountServiceImpl()
FirebaseMessaging.getInstance().token.addOnCompleteListener(OnCompleteListener { task ->
if (!task.isSuccessful) {
Log.w("Pushing", "Fetching FCM registration token failed", task.exception)
return@OnCompleteListener
}
// Get new FCM registration token
val token = task.result
// Log and toast
Log.d("Pushing", token)
scope.launch {
accountService.registerMessageChannel(client = "fcm", token)
}
})
}
}

View File

@@ -0,0 +1,94 @@
package com.aiosman.ravenow
import android.Manifest
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
val MessageTypeLike = "like"
fun showLikeNotification(context: Context, title: String, message: String, postId: Int) {
val channelId = ConstVars.MOMENT_LIKE_CHANNEL_ID
// Create an Intent to open the specific activity
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("POST_ID", postId.toString())
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
// Create a PendingIntent to wrap the Intent
val pendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notificationBuilder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.rider_pro_favoriate)
.setContentTitle(title)
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
with(NotificationManagerCompat.from(context)) {
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
// 用户没有授权,不显示通知
return
}
notify(System.currentTimeMillis().toInt(), notificationBuilder.build())
}
}
class MyFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
Log.d("Pushing", "Refreshed token: $token")
super.onNewToken(token)
}
override fun onMessageReceived(remoteMessage: RemoteMessage) {
if (remoteMessage.data.containsKey("action")) {
when (remoteMessage.data["action"]) {
MessageTypeLike -> {
val postId = remoteMessage.data["postId"]?.toInt() ?: return
showLikeNotification(
applicationContext,
remoteMessage.data["title"] ?: "FCM Message",
remoteMessage.data["body"] ?: "No message body",
postId
)
}
else -> {
Log.w("Pushing", "Unknown message type: ${remoteMessage.data["messageType"]}")
}
}
}
super.onMessageReceived(remoteMessage)
}
fun prepareIntent(clickAction: String?): Intent {
val intent: Intent
val isAppInBackground: Boolean = !MainActivityLifecycle.isForeground
intent = if (isAppInBackground) {
Intent(this, MainActivity::class.java)
} else {
Intent(clickAction)
}
return intent
}
}

View File

@@ -0,0 +1,131 @@
package com.aiosman.ravenow
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.IBinder
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.aiosman.ravenow.entity.ChatItem
import com.aiosman.ravenow.ui.index.tabs.message.MessageListViewModel
import com.tencent.imsdk.v2.V2TIMAdvancedMsgListener
import com.tencent.imsdk.v2.V2TIMManager
import com.tencent.imsdk.v2.V2TIMMessage
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class TrtcService : Service() {
private var trtcMessageListener: V2TIMAdvancedMsgListener? = null
private val channelId = "chat_notification"
override fun onBind(intent: Intent?): IBinder? {
return null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d("TrtcService", "onStartCommand")
createNotificationChannel()
CoroutineScope(Dispatchers.IO).launch {
registerMessageListener(applicationContext)
}
return START_STICKY;
}
override fun onDestroy() {
super.onDestroy()
Log.d("TrtcService", "onDestroy")
CoroutineScope(Dispatchers.IO).launch {
unRegisterMessageListener()
}
}
private fun registerMessageListener(context: Context) {
val scope = CoroutineScope(Dispatchers.IO)
trtcMessageListener = object : V2TIMAdvancedMsgListener() {
override fun onRecvNewMessage(msg: V2TIMMessage?) {
super.onRecvNewMessage(msg)
msg?.let {
MessageListViewModel.refreshConversation(context, it.sender)
if (MainActivityLifecycle.isForeground) {
return
}
scope.launch {
// 先获取通知策略
val notiStrategy = ChatState.getStrategyByTargetTrtcId(it.sender)
if (notiStrategy == null) {
// 未设置策略, 默认通知
sendNotification(context, it)
return@launch
}
if (notiStrategy.strategy != "mute") {
sendNotification(context, it)
}
}
}
}
}
V2TIMManager.getMessageManager().addAdvancedMsgListener(trtcMessageListener)
}
private fun unRegisterMessageListener() {
V2TIMManager.getMessageManager().removeAdvancedMsgListener(trtcMessageListener)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "Chat Notification"
val descriptionText = "Notification for chat message"
val importance = NotificationManager.IMPORTANCE_DEFAULT
val channel = NotificationChannel(channelId, name, importance).apply {
description = descriptionText
}
val notificationManager: NotificationManager =
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
private fun sendNotification(context: Context, message: V2TIMMessage) {
val intent = Intent(context, MainActivity::class.java).apply {
putExtra("ACTION", "TRTC_NEW_MESSAGE")
putExtra("SENDER", message.sender)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val chatItem = ChatItem.convertToChatItem(message, context) ?: return
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.mipmap.rider_pro_log_round)
.setContentTitle(chatItem.nickname)
.setContentText(chatItem.textDisplay)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
with(NotificationManagerCompat.from(context)) {
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
notify(message.msgID.hashCode(), builder.build())
}
}
}

View File

@@ -0,0 +1,556 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.AppConfig
import com.aiosman.ravenow.data.api.CaptchaInfo
import com.aiosman.ravenow.data.api.ChangePasswordRequestBody
import com.aiosman.ravenow.data.api.GoogleRegisterRequestBody
import com.aiosman.ravenow.data.api.LoginUserRequestBody
import com.aiosman.ravenow.data.api.RegisterMessageChannelRequestBody
import com.aiosman.ravenow.data.api.RegisterRequestBody
import com.aiosman.ravenow.data.api.ResetPasswordRequestBody
import com.aiosman.ravenow.data.api.TrtcSignResponseBody
import com.aiosman.ravenow.data.api.UnRegisterMessageChannelRequestBody
import com.aiosman.ravenow.data.api.UpdateNoticeRequestBody
import com.aiosman.ravenow.data.api.UpdateUserLangRequestBody
import com.aiosman.ravenow.entity.AccountFavouriteEntity
import com.aiosman.ravenow.entity.AccountLikeEntity
import com.aiosman.ravenow.entity.AccountProfileEntity
import com.aiosman.ravenow.entity.NoticeCommentEntity
import com.aiosman.ravenow.entity.NoticePostEntity
import com.aiosman.ravenow.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,
// 个人简介
val bio: String,
// 主页背景图
val banner: String?,
// trtcUserId
val trtcUserId: String,
) {
/**
* 转换为Entity
*/
fun toAccountProfileEntity(): AccountProfileEntity {
return AccountProfileEntity(
id = id,
followerCount = followerCount,
followingCount = followingCount,
nickName = nickname,
avatar = "${ApiClient.BASE_SERVER}$avatar",
bio = bio,
country = "Worldwide",
isFollowing = isFollowing,
banner = banner.let {
if (!it.isNullOrEmpty()) {
return@let "${ApiClient.BASE_SERVER}$it"
}
null
},
trtcUserId = trtcUserId
)
}
}
/**
* 消息关联资料
*/
data class NoticePost(
// 动态ID
@SerializedName("id")
val id: Int,
// 动态内容
@SerializedName("textContent")
// 动态图片
val textContent: String,
// 动态图片
@SerializedName("images")
val images: List<Image>,
// 动态时间
@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}",
blurHash = it.blurHash,
width = it.width,
height = it.height
)
},
time = ApiClient.dateFromApiString(time)
)
}
}
//"comment": {
// "id": 103,
// "content": "ppp",
// "time": "2024-09-08 15:31:37"
//}
data class NoticeComment(
@SerializedName("id")
val id: Int,
@SerializedName("content")
val content: String,
@SerializedName("time")
val time: String,
@SerializedName("replyComment")
val replyComment: NoticeComment?,
@SerializedName("postId")
val postId: Int,
@SerializedName("post")
val post: NoticePost?,
) {
fun toNoticeCommentEntity(): NoticeCommentEntity {
return NoticeCommentEntity(
id = id,
content = content,
postId = postId,
time = ApiClient.dateFromApiString(time),
replyComment = replyComment?.toNoticeCommentEntity(),
post = post?.toNoticePostEntity()
)
}
}
/**
* 消息关联用户
*/
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("comment")
val comment: NoticeComment?,
// 点赞用户
@SerializedName("user")
val user: NoticeUser,
// 点赞时间
@SerializedName("likeTime")
val likeTime: String,
// 动态ID
@SerializedName("postId")
val postId: Int,
) {
fun toAccountLikeEntity(): AccountLikeEntity {
return AccountLikeEntity(
post = post?.toNoticePostEntity(),
comment = comment?.toNoticeCommentEntity(),
user = user.toNoticeUserEntity(),
likeTime = ApiClient.dateFromApiString(likeTime),
postId = postId
)
}
}
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 密码
* @param captchaInfo 验证码信息
*/
suspend fun loginUserWithPassword(
loginName: String,
password: String,
captchaInfo: CaptchaInfo? = null
): UserAuth
/**
* 使用google登录
* @param googleId googleId
*/
suspend fun loginUserWithGoogle(googleId: String): UserAuth
/**
* 退出登录
*/
suspend fun logout()
/**
* 更新用户资料
* @param avatar 头像
* @param nickName 昵称
* @param bio 简介
* @param banner 主页背景图
*/
suspend fun updateProfile(
avatar: UploadImage?,
banner: 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<AccountLike>
/**
* 获取我的关注通知
* @param page 页码
* @param pageSize 每页数量
*/
suspend fun getMyFollowNotice(page: Int, pageSize: Int): ListContainer<AccountFollow>
/**
* 获取我的收藏通知
* @param page 页码
* @param pageSize 每页数量
*/
suspend fun getMyFavouriteNotice(page: Int, pageSize: Int): ListContainer<AccountFavourite>
/**
* 获取我的通知信息
*/
suspend fun getMyNoticeInfo(): AccountNotice
/**
* 更新通知信息,更新最后一次查看时间
* @param payload 通知信息
*/
suspend fun updateNotice(payload: UpdateNoticeRequestBody)
/**
* 注册消息通道
*/
suspend fun registerMessageChannel(client: String, identifier: String)
/**
* 取消注册消息通道
*/
suspend fun unregisterMessageChannel(client: String, identifier: String)
/**
* 重置密码
*/
suspend fun resetPassword(email: String)
/**
* 更新用户额外信息
*/
suspend fun updateUserExtra(language: String, timeOffset: Int, timezone: String)
/**
* 获取腾讯云TRTC签名
*/
suspend fun getMyTrtcSign(): TrtcSignResponseBody
suspend fun getAppConfig(): AppConfig
}
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")
AppState.UserId = body.id
return UserAuth(body.id)
}
override suspend fun loginUserWithPassword(
loginName: String,
password: String,
captchaInfo: CaptchaInfo?
): UserAuth {
val resp = ApiClient.api.login(LoginUserRequestBody(
username = loginName,
password = password,
captcha = captchaInfo,
))
if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
throw ServiceException("Failed to register")
}
return UserAuth(0, resp.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.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
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?,
banner: UploadImage?,
nickName: String?,
bio: String?
) {
val nicknameField: RequestBody? = nickName?.toRequestBody("text/plain".toMediaTypeOrNull())
val bioField: RequestBody? = bio?.toRequestBody("text/plain".toMediaTypeOrNull())
val avatarField: MultipartBody.Part? = avatar?.let {
createMultipartBody(it.file, it.filename, "avatar")
}
val bannerField: MultipartBody.Part? = banner?.let {
createMultipartBody(it.file, it.filename, "banner")
}
ApiClient.api.updateProfile(avatarField, bannerField, nicknameField, bioField)
}
override suspend fun registerUserWithPassword(loginName: String, password: String) {
val resp = ApiClient.api.register(RegisterRequestBody(loginName, password))
if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
throw ServiceException("Failed to register")
}
}
override suspend fun changeAccountPassword(oldPassword: String, newPassword: String) {
val resp = ApiClient.api.changePassword(ChangePasswordRequestBody(oldPassword, newPassword))
if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
throw ServiceException("Failed to change password")
}
}
override suspend fun getMyLikeNotice(page: Int, pageSize: Int): ListContainer<AccountLike> {
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<AccountFollow> {
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<AccountFavourite> {
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)
}
override suspend fun registerMessageChannel(client: String, identifier: String) {
ApiClient.api.registerMessageChannel(RegisterMessageChannelRequestBody(client, identifier))
}
override suspend fun unregisterMessageChannel(client: String, identifier: String) {
ApiClient.api.unRegisterMessageChannel(UnRegisterMessageChannelRequestBody(client, identifier))
}
override suspend fun resetPassword(email: String) {
val resp = ApiClient.api.resetPassword(
ResetPasswordRequestBody(
username = email
)
)
if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
throw ServiceException("Failed to reset password")
}
}
override suspend fun updateUserExtra(language: String, timeOffset: Int, timezone: String) {
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
}
}

View File

@@ -0,0 +1,45 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.CaptchaRequestBody
import com.aiosman.ravenow.data.api.CaptchaResponseBody
import com.aiosman.ravenow.data.api.CheckLoginCaptchaRequestBody
import com.aiosman.ravenow.data.api.GenerateLoginCaptchaRequestBody
interface CaptchaService {
suspend fun generateCaptcha(source: String): CaptchaResponseBody
suspend fun checkLoginCaptcha(username: String): Boolean
suspend fun generateLoginCaptcha(username: String): CaptchaResponseBody
}
class CaptchaServiceImpl : CaptchaService {
override suspend fun generateCaptcha(source: String): CaptchaResponseBody {
val resp = ApiClient.api.generateCaptcha(
CaptchaRequestBody(source)
)
val data = resp.body() ?: throw Exception("Failed to generate captcha")
return data.data.copy(
masterBase64 = data.data.masterBase64.replace("data:image/jpeg;base64,", ""),
thumbBase64 = data.data.thumbBase64.replace("data:image/png;base64,", "")
)
}
override suspend fun checkLoginCaptcha(username: String): Boolean {
val resp = ApiClient.api.checkLoginCaptcha(
CheckLoginCaptchaRequestBody(username)
)
return resp.body()?.data ?: true
}
override suspend fun generateLoginCaptcha(username: String): CaptchaResponseBody {
val resp = ApiClient.api.generateLoginCaptcha(
GenerateLoginCaptchaRequestBody(username)
)
val data = resp.body() ?: throw Exception("Failed to generate captcha")
return data.data.copy(
masterBase64 = data.data.masterBase64.replace("data:image/jpeg;base64,", ""),
thumbBase64 = data.data.thumbBase64.replace("data:image/png;base64,", "")
)
}
}

View File

@@ -0,0 +1,42 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.UpdateChatNotificationRequestBody
import com.aiosman.ravenow.entity.ChatNotification
interface ChatService {
suspend fun getChatNotifications(
targetTrtcId: String
): ChatNotification?
suspend fun updateChatNotification(
targetUserId: Int,
strategy: String
): ChatNotification
}
class ChatServiceImpl : ChatService {
override suspend fun getChatNotifications(
targetTrtcId: String
): ChatNotification? {
val resp = ApiClient.api.getChatNotification(targetTrtcId)
if (resp.isSuccessful) {
return resp.body()?.data
}
return null
}
override suspend fun updateChatNotification(
targetUserId: Int,
strategy: String
): ChatNotification {
val resp = ApiClient.api.updateChatNotification(UpdateChatNotificationRequestBody(
targetUserId = targetUserId,
strategy = strategy
))
if (resp.isSuccessful) {
return resp.body()?.data!!
}
throw Exception("update chat notification failed")
}
}

View File

@@ -0,0 +1,255 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.CommentRequestBody
import com.aiosman.ravenow.entity.CommentEntity
import com.google.gson.annotations.SerializedName
/**
* 评论相关 Service
*/
interface CommentService {
/**
* 获取动态
* @param pageNumber 页码
* @param postId 动态ID,过滤条件
* @param postUser 动态作者ID,获取某个用户所有动态下的评论
* @param selfNotice 是否是自己的通知
* @param order 排序
* @param parentCommentId 父评论ID
* @param pageSize 每页数量
* @return 评论列表
*/
suspend fun getComments(
pageNumber: Int,
postId: Int? = null,
postUser: Int? = null,
selfNotice: Boolean? = null,
order: String? = null,
parentCommentId: Int? = null,
pageSize: Int? = null
): ListContainer<CommentEntity>
/**
* 创建评论
* @param postId 动态ID
* @param content 评论内容
* @param parentCommentId 父评论ID
* @param replyUserId 回复用户ID
*/
suspend fun createComment(
postId: Int,
content: String,
parentCommentId: Int? = null,
replyUserId: Int? = null,
replyCommentId: Int? = null
): CommentEntity
/**
* 点赞评论
* @param commentId 评论ID
*/
suspend fun likeComment(commentId: Int)
/**
* 取消点赞评论
* @param commentId 评论ID
*/
suspend fun dislikeComment(commentId: Int)
/**
* 更新评论已读状态
* @param commentId 评论ID
*/
suspend fun updateReadStatus(commentId: Int)
/**
* 删除评论
* @param commentId 评论ID
*/
suspend fun DeleteComment(commentId: Int)
/**
* 获取评论
* @param commentId 评论ID
*/
suspend fun getCommentById(commentId: Int): CommentEntity
}
/**
* 评论
*/
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,
@SerializedName("reply")
val reply: List<Comment>,
@SerializedName("replyUser")
val replyUser: User?,
@SerializedName("parentCommentId")
val parentCommentId: Int?,
@SerializedName("replyCount")
val replyCount: Int
) {
/**
* 转换为Entity
*/
fun toCommentEntity(): CommentEntity {
return CommentEntity(
id = id,
name = user.nickName,
comment = content,
date = ApiClient.dateFromApiString(createdAt),
likes = likeCount,
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}"
)
}
)
},
reply = reply.map { it.toCommentEntity() },
replyUserNickname = replyUser?.nickName,
replyUserId = replyUser?.id,
replyUserAvatar = replyUser?.avatar?.let { "${ApiClient.BASE_SERVER}$it" },
parentCommentId = parentCommentId,
replyCount = replyCount
)
}
}
class CommentRemoteDataSource(
private val commentService: CommentService,
) {
suspend fun getComments(
pageNumber: Int,
postId: Int?,
postUser: Int?,
selfNotice: Boolean?,
order: String?,
parentCommentId: Int?,
pageSize: Int? = 20
): ListContainer<CommentEntity> {
return commentService.getComments(
pageNumber,
postId,
postUser = postUser,
selfNotice = selfNotice,
order = order,
parentCommentId = parentCommentId,
pageSize = pageSize
)
}
}
class CommentServiceImpl : CommentService {
override suspend fun getComments(
pageNumber: Int,
postId: Int?,
postUser: Int?,
selfNotice: Boolean?,
order: String?,
parentCommentId: Int?,
pageSize: Int?
): ListContainer<CommentEntity> {
val resp = ApiClient.api.getComments(
page = pageNumber,
postId = postId,
postUser = postUser,
order = order,
selfNotice = selfNotice?.let {
if (it) 1 else 0
},
parentCommentId = parentCommentId,
pageSize = pageSize ?: 20
)
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,
parentCommentId: Int?,
replyUserId: Int?,
replyCommentId: Int?
): CommentEntity {
val resp = ApiClient.api.createComment(
postId,
CommentRequestBody(
content = content,
parentCommentId = parentCommentId,
replyUserId = replyUserId,
replyCommentId = replyCommentId
),
)
val body = resp.body() ?: throw ServiceException("Failed to create comment")
return body.data.toCommentEntity()
}
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
}
override suspend fun DeleteComment(commentId: Int) {
val resp = ApiClient.api.deleteComment(commentId)
return
}
override suspend fun getCommentById(commentId: Int): CommentEntity {
val resp = ApiClient.api.getComment(commentId)
val body = resp.body() ?: throw ServiceException("Failed to get comment")
return body.data.toCommentEntity()
}
}

View File

@@ -0,0 +1,8 @@
package com.aiosman.ravenow.data
/**
* 通用接口返回数据
*/
data class DataContainer<T>(
val data: T
)

View File

@@ -0,0 +1,19 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.api.DictItem
interface DictService {
/**
* 获取字典项
*/
suspend fun getDictByKey(key: String): DictItem
}
class DictServiceImpl : DictService {
override suspend fun getDictByKey(key: String): DictItem {
val resp = ApiClient.api.getDict(key)
return resp.body()?.data ?: throw Exception("failed to get dict")
}
}

View File

@@ -0,0 +1,47 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ErrorCode
import com.aiosman.ravenow.data.api.toErrorCode
import com.google.gson.Gson
import com.google.gson.annotations.SerializedName
import okhttp3.ResponseBody
/**
* 错误返回
*/
class ServiceException(
override val message: String,
val code: Int? = 0,
val data: Any? = null,
val error: String? = null,
val name: String? = null,
val errorType: ErrorCode = ErrorCode.UNKNOWN
) : Exception(
message
)
data class ApiErrorResponse(
@SerializedName("code")
val code: Int?,
@SerializedName("error")
val error: String?,
@SerializedName("message")
val name: String?,
) {
fun toServiceException(): ServiceException {
return ServiceException(
message = error ?: name ?: "",
code = code,
error = error,
name = name,
errorType = (code ?: 0).toErrorCode()
)
}
}
fun parseErrorResponse(errorBody: ResponseBody?): ApiErrorResponse? {
return errorBody?.let {
val gson = Gson()
gson.fromJson(it.charStream(), ApiErrorResponse::class.java)
}
}

View File

@@ -0,0 +1,22 @@
package com.aiosman.ravenow.data
import com.google.gson.annotations.SerializedName
/**
* 通用列表接口返回
*/
data class ListContainer<T>(
// 总数
@SerializedName("total")
val total: Int,
// 当前页
@SerializedName("page")
val page: Int,
// 每页数量
@SerializedName("pageSize")
val pageSize: Int,
// 列表
@SerializedName("list")
val list: List<T>
)

View File

@@ -0,0 +1,169 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.R
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.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<Image>,
@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,
@SerializedName("isFollowed")
val isFollowed: Boolean,
) {
fun toMomentItem(): MomentEntity {
return MomentEntity(
id = id.toInt(),
avatar = "${ApiClient.BASE_SERVER}${user.avatar}",
nickname = user.nickName,
location = "Worldwide",
time = ApiClient.dateFromApiString(time),
followStatus = isFollowed,
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,
width = it.width,
height = it.height
)
},
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?,
@SerializedName("width")
val width: Int?,
@SerializedName("height")
val height: Int?
)
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 内容搜索,过滤条件
* @param trend 是否趋势动态
* @param explore 是否探索动态
* @return 动态列表
*/
suspend fun getMoments(
pageNumber: Int,
author: Int? = null,
timelineId: Int? = null,
contentSearch: String? = null,
trend: Boolean? = false,
explore: Boolean? = false,
favoriteUserId: Int? = null
): ListContainer<MomentEntity>
/**
* 创建动态
* @param content 动态内容
* @param authorId 作者ID
* @param images 图片列表
* @param relPostId 关联动态ID
*/
suspend fun createMoment(
content: String,
authorId: Int,
images: List<UploadImage>,
relPostId: Int? = null
): MomentEntity
/**
* 收藏动态
* @param id 动态ID
*/
suspend fun favoriteMoment(id: Int)
/**
* 取消收藏动态
* @param id 动态ID
*/
suspend fun unfavoriteMoment(id: Int)
/**
* 删除动态
*/
suspend fun deleteMoment(id: Int)
}

View File

@@ -0,0 +1,100 @@
package com.aiosman.ravenow.data
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.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 昵称搜索
* @param followerId 粉丝ID,账号粉丝
* @param followingId 关注ID,账号关注
* @return 用户列表
*/
suspend fun getUsers(
pageSize: Int = 20,
page: Int = 1,
nickname: String? = null,
followerId: Int? = null,
followingId: Int? = null
): ListContainer<AccountProfileEntity>
suspend fun getUserProfileByTrtcUserId(id: String):AccountProfileEntity
}
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?,
followerId: Int?,
followingId: Int?
): ListContainer<AccountProfileEntity> {
val resp = ApiClient.api.getUsers(
page = page,
pageSize = pageSize,
search = nickname,
followerId = followerId,
followingId = followingId
)
val body = resp.body() ?: throw ServiceException("Failed to get account")
return ListContainer<AccountProfileEntity>(
list = body.list.map { it.toAccountProfileEntity() },
page = body.page,
total = body.total,
pageSize = body.pageSize,
)
}
override suspend fun getUserProfileByTrtcUserId(id: String): AccountProfileEntity {
val resp = ApiClient.api.getAccountProfileByTrtcUserId(id)
val body = resp.body() ?: throw ServiceException("Failed to get account")
return body.data.toAccountProfileEntity()
}
}

View File

@@ -0,0 +1,140 @@
package com.aiosman.ravenow.data.api
import android.icu.text.SimpleDateFormat
import android.icu.util.TimeZone
import com.aiosman.ravenow.AppStore
import com.aiosman.ravenow.ConstVars
import com.auth0.android.jwt.JWT
import kotlinx.coroutines.runBlocking
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(
authInterceptor: AuthInterceptor? = null
): OkHttpClient {
return try {
// Create a trust manager that does not validate certificate chains
val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
@Throws(CertificateException::class)
override fun checkClientTrusted(
chain: Array<java.security.cert.X509Certificate>,
authType: String
) {
}
@Throws(CertificateException::class)
override fun checkServerTrusted(
chain: Array<java.security.cert.X509Certificate>,
authType: String
) {
}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = 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 }
.apply {
authInterceptor?.let {
addInterceptor(it)
}
}
.build()
} catch (e: Exception) {
throw RuntimeException(e)
}
}
class AuthInterceptor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val requestBuilder = chain.request().newBuilder()
val token = AppStore.token
token?.let {
val jwt = JWT(token)
val expiresAt = jwt.expiresAt?.time?.minus(3000)
val currentTime = System.currentTimeMillis()
val isExpired = expiresAt != null && currentTime > expiresAt
if (isExpired) {
runBlocking {
val newToken = refreshToken()
if (newToken != null) {
AppStore.token = newToken
}
}
}
}
requestBuilder.addHeader("Authorization", "Bearer ${AppStore.token}")
val response = chain.proceed(requestBuilder.build())
return response
}
private suspend fun refreshToken(): String? {
val client = Retrofit.Builder()
.baseUrl(ApiClient.RETROFIT_URL)
.addConverterFactory(GsonConverterFactory.create())
.client(getUnsafeOkHttpClient())
.build()
.create(RiderProAPI::class.java)
val resp = client.refreshToken(AppStore.token ?: "")
val newToken = resp.body()?.token
if (newToken != null) {
AppStore.token = newToken
}
return newToken
}
}
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(authInterceptor = AuthInterceptor())
}
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)
}
}

View File

@@ -0,0 +1,42 @@
package com.aiosman.ravenow.data.api
import android.content.Context
import android.widget.Toast
import com.aiosman.ravenow.R
//
enum class ErrorCode(val code: Int) {
USER_EXIST(40001),
USER_NOT_EXIST(40002),
InvalidateCaptcha(40004),
IncorrectOldPassword(40005),
// 未知错误
UNKNOWN(99999)
}
fun ErrorCode.toErrorMessage(context: Context): String {
return context.getErrorMessageCode(code)
}
fun ErrorCode.showToast(context: Context) {
Toast.makeText(context, toErrorMessage(context), Toast.LENGTH_SHORT).show()
}
// code to ErrorCode
fun Int.toErrorCode(): ErrorCode {
return when (this) {
40001 -> ErrorCode.USER_EXIST
40002 -> ErrorCode.USER_NOT_EXIST
40004 -> ErrorCode.InvalidateCaptcha
40005 -> ErrorCode.IncorrectOldPassword
else -> ErrorCode.UNKNOWN
}
}
fun Context.getErrorMessageCode(code: Int?): String {
return when (code) {
40001 -> getString(R.string.error_10001_user_exist)
ErrorCode.IncorrectOldPassword.code -> getString(R.string.error_incorrect_old_password)
else -> getString(R.string.error_unknown)
}
}

View File

@@ -0,0 +1,428 @@
package com.aiosman.ravenow.data.api
import com.aiosman.ravenow.data.AccountFavourite
import com.aiosman.ravenow.data.AccountFollow
import com.aiosman.ravenow.data.AccountLike
import com.aiosman.ravenow.data.AccountNotice
import com.aiosman.ravenow.data.AccountProfile
import com.aiosman.ravenow.data.Comment
import com.aiosman.ravenow.data.DataContainer
import com.aiosman.ravenow.data.ListContainer
import com.aiosman.ravenow.data.Moment
import com.aiosman.ravenow.entity.ChatNotification
import com.google.gson.annotations.SerializedName
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
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,
@SerializedName("captcha")
val captcha: CaptchaInfo? = 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,
@SerializedName("parentCommentId")
val parentCommentId: Int? = null,
@SerializedName("replyUserId")
val replyUserId: Int? = null,
@SerializedName("replyCommentId")
val replyCommentId: Int? = null,
)
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
)
data class RegisterMessageChannelRequestBody(
@SerializedName("client")
val client: String,
@SerializedName("identifier")
val identifier: String,
)
data class UnRegisterMessageChannelRequestBody(
@SerializedName("client")
val client: String,
@SerializedName("identifier")
val identifier: String,
)
data class ResetPasswordRequestBody(
@SerializedName("username")
val username: String,
)
data class UpdateUserLangRequestBody(
@SerializedName("language")
val lang: String,
@SerializedName("timeOffset")
val timeOffset: Int,
@SerializedName("timezone")
val timezone: String,
)
data class TrtcSignResponseBody(
@SerializedName("sig")
val sig: String,
@SerializedName("userId")
val userId: String,
)
data class AppConfig(
@SerializedName("trtcAppId")
val trtcAppId: Int,
)
data class DictItem(
@SerializedName("key")
val key: String,
@SerializedName("value")
val value: String,
@SerializedName("desc")
val desc: String,
)
data class CaptchaRequestBody(
@SerializedName("source")
val source: String,
)
data class CaptchaResponseBody(
@SerializedName("id")
val id: Int,
@SerializedName("thumb_base64")
val thumbBase64: String,
@SerializedName("master_base64")
val masterBase64: String,
@SerializedName("count")
val count: Int,
)
data class CheckLoginCaptchaRequestBody(
@SerializedName("username")
val username: String,
)
data class GenerateLoginCaptchaRequestBody(
@SerializedName("username")
val username: String,
)
data class DotPosition(
@SerializedName("index")
val index: Int,
@SerializedName("x")
val x: Int,
@SerializedName("y")
val y: Int,
)
data class CaptchaInfo(
@SerializedName("id")
val id: Int,
@SerializedName("dot")
val dot: List<DotPosition>
)
data class UpdateChatNotificationRequestBody(
@SerializedName("targetUserId")
val targetUserId: Int,
@SerializedName("strategy")
val strategy: String,
)
interface RiderProAPI {
@POST("register")
suspend fun register(@Body body: RegisterRequestBody): Response<Unit>
@POST("login")
suspend fun login(@Body body: LoginUserRequestBody): Response<AuthResult>
@GET("auth/token")
suspend fun checkToken(): Response<ValidateTokenResult>
@GET("auth/refresh_token")
suspend fun refreshToken(
@Query("token") token: String
): Response<AuthResult>
@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,
@Query("trend") trend: String? = null,
@Query("favouriteUserId") favouriteUserId: Int? = null,
@Query("explore") explore: String? = null,
): Response<ListContainer<Moment>>
@Multipart
@POST("posts")
suspend fun createPost(
@Part image: List<MultipartBody.Part>,
@Part("textContent") textContent: RequestBody,
): Response<DataContainer<Moment>>
@GET("post/{id}")
suspend fun getPost(
@Path("id") id: Int
): Response<DataContainer<Moment>>
@POST("post/{id}/like")
suspend fun likePost(
@Path("id") id: Int
): Response<Unit>
@POST("post/{id}/dislike")
suspend fun dislikePost(
@Path("id") id: Int
): Response<Unit>
@POST("post/{id}/favorite")
suspend fun favoritePost(
@Path("id") id: Int
): Response<Unit>
@POST("post/{id}/unfavorite")
suspend fun unfavoritePost(
@Path("id") id: Int
): Response<Unit>
@POST("post/{id}/comment")
suspend fun createComment(
@Path("id") id: Int,
@Body body: CommentRequestBody
): Response<DataContainer<Comment>>
@POST("comment/{id}/like")
suspend fun likeComment(
@Path("id") id: Int
): Response<Unit>
@POST("comment/{id}/dislike")
suspend fun dislikeComment(
@Path("id") id: Int
): Response<Unit>
@POST("comment/{id}/read")
suspend fun updateReadStatus(
@Path("id") id: Int
): Response<Unit>
@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,
@Query("order") order: String? = null,
@Query("parentCommentId") parentCommentId: Int? = null,
): Response<ListContainer<Comment>>
@GET("account/my")
suspend fun getMyAccount(): Response<DataContainer<AccountProfile>>
@Multipart
@PATCH("account/my/profile")
suspend fun updateProfile(
@Part avatar: MultipartBody.Part?,
@Part banner: MultipartBody.Part?,
@Part("nickname") nickname: RequestBody?,
@Part("bio") bio: RequestBody?,
): Response<Unit>
@POST("account/my/password")
suspend fun changePassword(
@Body body: ChangePasswordRequestBody
): Response<Unit>
@GET("account/my/notice/like")
suspend fun getMyLikeNotices(
@Query("page") page: Int = 1,
@Query("pageSize") pageSize: Int = 20,
): Response<ListContainer<AccountLike>>
@GET("account/my/notice/follow")
suspend fun getMyFollowNotices(
@Query("page") page: Int = 1,
@Query("pageSize") pageSize: Int = 20,
): Response<ListContainer<AccountFollow>>
@GET("account/my/notice/favourite")
suspend fun getMyFavouriteNotices(
@Query("page") page: Int = 1,
@Query("pageSize") pageSize: Int = 20,
): Response<ListContainer<AccountFavourite>>
@GET("account/my/notice")
suspend fun getMyNoticeInfo(): Response<DataContainer<AccountNotice>>
@POST("account/my/notice")
suspend fun updateNoticeInfo(
@Body body: UpdateNoticeRequestBody
): Response<Unit>
@POST("account/my/messaging")
suspend fun registerMessageChannel(
@Body body: RegisterMessageChannelRequestBody
): Response<Unit>
@POST("account/my/messaging/unregister")
suspend fun unRegisterMessageChannel(
@Body body: UnRegisterMessageChannelRequestBody
): Response<Unit>
@GET("profile/{id}")
suspend fun getAccountProfileById(
@Path("id") id: Int
): Response<DataContainer<AccountProfile>>
@GET("profile/trtc/{id}")
suspend fun getAccountProfileByTrtcUserId(
@Path("id") id: String
): Response<DataContainer<AccountProfile>>
@POST("user/{id}/follow")
suspend fun followUser(
@Path("id") id: Int
): Response<Unit>
@POST("user/{id}/unfollow")
suspend fun unfollowUser(
@Path("id") id: Int
): Response<Unit>
@GET("users")
suspend fun getUsers(
@Query("page") page: Int = 1,
@Query("pageSize") pageSize: Int = 20,
@Query("nickname") search: String? = null,
@Query("followerId") followerId: Int? = null,
@Query("followingId") followingId: Int? = null,
): Response<ListContainer<AccountProfile>>
@POST("register/google")
suspend fun registerWithGoogle(@Body body: GoogleRegisterRequestBody): Response<AuthResult>
@DELETE("post/{id}")
suspend fun deletePost(
@Path("id") id: Int
): Response<Unit>
@DELETE("comment/{id}")
suspend fun deleteComment(
@Path("id") id: Int
): Response<Unit>
@POST("account/my/password/reset")
suspend fun resetPassword(
@Body body: ResetPasswordRequestBody
): Response<Unit>
@GET("comment/{id}")
suspend fun getComment(
@Path("id") id: Int
): Response<DataContainer<Comment>>
@PATCH("account/my/extra")
suspend fun updateUserExtra(
@Body body: UpdateUserLangRequestBody
): Response<Unit>
@GET("account/my/chat/sign")
suspend fun getChatSign(): Response<DataContainer<TrtcSignResponseBody>>
@GET("app/info")
suspend fun getAppConfig(): Response<DataContainer<AppConfig>>
@GET("dict")
suspend fun getDict(
@Query("key") key: String
): Response<DataContainer<DictItem>>
@POST("captcha/generate")
suspend fun generateCaptcha(
@Body body: CaptchaRequestBody
): Response<DataContainer<CaptchaResponseBody>>
@POST("login/needCaptcha")
suspend fun checkLoginCaptcha(
@Body body: CheckLoginCaptchaRequestBody
): Response<DataContainer<Boolean>>
@POST("captcha/login/generate")
suspend fun generateLoginCaptcha(
@Body body: GenerateLoginCaptchaRequestBody
): Response<DataContainer<CaptchaResponseBody>>
@GET("chat/notification")
suspend fun getChatNotification(
@Query("targetTrtcId") targetTrtcId: String
): Response<DataContainer<ChatNotification>>
@POST("chat/notification")
suspend fun updateChatNotification(
@Body body: UpdateChatNotificationRequestBody
): Response<DataContainer<ChatNotification>>
}

View File

@@ -0,0 +1,199 @@
package com.aiosman.ravenow.entity
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aiosman.ravenow.data.AccountFollow
import com.aiosman.ravenow.data.AccountService
import com.aiosman.ravenow.data.Image
import com.aiosman.ravenow.data.api.ApiClient
import java.io.IOException
import java.util.Date
/**
* 用户点赞
*/
data class AccountLikeEntity(
// 动态
val post: NoticePostEntity?,
// 回复评论
val comment: NoticeCommentEntity?,
// 点赞用户
val user: NoticeUserEntity,
// 点赞时间
val likeTime: Date,
// 动态ID
val postId: Int
)
/**
* 用户收藏
*/
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,
// 主页背景图
val banner: String?,
// trtcUserId
val trtcUserId: String,
)
/**
* 消息关联的动态
*/
data class NoticePostEntity(
// 动态ID
val id: Int,
// 动态内容
val textContent: String,
// 动态图片
val images: List<Image>,
// 时间
val time: Date,
)
data class NoticeCommentEntity(
// 评论ID
val id: Int,
// 评论内容
val content: String,
// 评论时间
val time: Date,
// 引用评论
val replyComment: NoticeCommentEntity?,
// 动态
val postId: Int,
// 动态
val post : NoticePostEntity?,
)
/**
* 消息关联的用户
*/
data class NoticeUserEntity(
// 用户ID
val id: Int,
// 昵称
val nickName: String,
// 头像
val avatar: String,
)
/**
* 用户点赞消息分页数据加载器
*/
class LikeItemPagingSource(
private val accountService: AccountService,
) : PagingSource<Int, AccountLikeEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AccountLikeEntity> {
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, AccountLikeEntity>): Int? {
return state.anchorPosition
}
}
/**
* 用户收藏消息分页数据加载器
*/
class FavoriteItemPagingSource(
private val accountService: AccountService,
) : PagingSource<Int, AccountFavouriteEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AccountFavouriteEntity> {
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, AccountFavouriteEntity>): Int? {
return state.anchorPosition
}
}
/**
* 用户关注消息分页数据加载器
*/
class FollowItemPagingSource(
private val accountService: AccountService,
) : PagingSource<Int, AccountFollow>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AccountFollow> {
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, AccountFollow>): Int? {
return state.anchorPosition
}
}

View File

@@ -0,0 +1,101 @@
package com.aiosman.ravenow.entity
import android.content.Context
import android.icu.util.Calendar
import com.aiosman.ravenow.exp.formatChatTime
import com.google.gson.annotations.SerializedName
import com.tencent.imsdk.v2.V2TIMImageElem
import com.tencent.imsdk.v2.V2TIMMessage
data class ChatItem(
val message: String,
val avatar: String,
val time: String,
val userId: String,
val nickname: String,
val timeCategory: String = "",
val timestamp: Long = 0,
val imageList: MutableList<V2TIMImageElem.V2TIMImage> = emptyList<V2TIMImageElem.V2TIMImage>().toMutableList(),
val messageType: Int = 0,
val textDisplay: String = "",
val msgId: String, // Add this property
var showTimestamp: Boolean = false,
var showTimeDivider: Boolean = false
) {
companion object {
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
val imageElm = message.imageElem?.imageList
when (message.elemType) {
V2TIMMessage.V2TIM_ELEM_TYPE_IMAGE -> {
val imageElm = message.imageElem?.imageList?.all {
it.size == 0
}
if (imageElm != true) {
return ChatItem(
message = "Image",
avatar = message.faceUrl,
time = calendar.time.formatChatTime(context),
userId = message.sender,
nickname = message.nickName,
timestamp = timestamp * 1000,
imageList = message.imageElem?.imageList
?: emptyList<V2TIMImageElem.V2TIMImage>().toMutableList(),
messageType = V2TIMMessage.V2TIM_ELEM_TYPE_IMAGE,
textDisplay = "Image",
msgId = message.msgID // Add this line to include msgId
)
}
return null
}
V2TIMMessage.V2TIM_ELEM_TYPE_TEXT -> {
return ChatItem(
message = message.textElem?.text ?: "Unsupported message type",
avatar = message.faceUrl,
time = calendar.time.formatChatTime(context),
userId = message.sender,
nickname = message.nickName,
timestamp = timestamp * 1000,
imageList = imageElm?.toMutableList()
?: emptyList<V2TIMImageElem.V2TIMImage>().toMutableList(),
messageType = V2TIMMessage.V2TIM_ELEM_TYPE_TEXT,
textDisplay = message.textElem?.text ?: "Unsupported message type",
msgId = message.msgID // Add this line to include msgId
)
}
else -> {
return null
}
}
}
}
}
data class ChatNotification(
@SerializedName("userId")
val userId: Int,
@SerializedName("userTrtcId")
val userTrtcId: String,
@SerializedName("targetUserId")
val targetUserId: Int,
@SerializedName("targetTrtcId")
val targetTrtcId: String,
@SerializedName("strategy")
val strategy: String
)

View File

@@ -0,0 +1,64 @@
package com.aiosman.ravenow.entity
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aiosman.ravenow.data.CommentRemoteDataSource
import com.aiosman.ravenow.data.NoticePost
import java.util.Date
data class CommentEntity(
val id: Int,
val name: String,
val comment: String,
val date: Date,
val likes: Int,
val postId: Int = 0,
val avatar: String,
val author: Long,
var liked: Boolean,
var unread: Boolean = false,
var post: NoticePost?,
var reply: List<CommentEntity>,
var replyUserId: Long?,
var replyUserNickname: String?,
var replyUserAvatar: String?,
var parentCommentId: Int?,
var replyCount: Int,
var replyPage: Int = 1
)
class CommentPagingSource(
private val remoteDataSource: CommentRemoteDataSource,
private val postId: Int? = null,
private val postUser: Int? = null,
private val selfNotice: Boolean? = null,
private val order: String? = null,
private val parentCommentId: Int? = null
) : PagingSource<Int, CommentEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CommentEntity> {
return try {
val currentPage = params.key ?: 1
val comments = remoteDataSource.getComments(
pageNumber = currentPage,
postId = postId,
postUser = postUser,
selfNotice = selfNotice,
order = order,
parentCommentId = parentCommentId,
pageSize = params.loadSize
)
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: Exception) {
return LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, CommentEntity>): Int? {
return state.anchorPosition
}
}

View File

@@ -0,0 +1,287 @@
package com.aiosman.ravenow.entity
import androidx.annotation.DrawableRes
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aiosman.ravenow.data.ListContainer
import com.aiosman.ravenow.data.MomentService
import com.aiosman.ravenow.data.ServiceException
import com.aiosman.ravenow.data.UploadImage
import com.aiosman.ravenow.data.api.ApiClient
import com.aiosman.ravenow.data.parseErrorResponse
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,
private val trend: Boolean? = false,
private val explore: Boolean? = false,
private val favoriteUserId: Int? = null
) : PagingSource<Int, MomentEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MomentEntity> {
return try {
val currentPage = params.key ?: 1
val moments = remoteDataSource.getMoments(
pageNumber = currentPage,
author = author,
timelineId = timelineId,
contentSearch = contentSearch,
trend = trend,
explore = explore,
favoriteUserId = favoriteUserId
)
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, MomentEntity>): Int? {
return state.anchorPosition
}
}
class MomentRemoteDataSource(
private val momentService: MomentService,
) {
suspend fun getMoments(
pageNumber: Int,
author: Int?,
timelineId: Int?,
contentSearch: String?,
trend: Boolean?,
explore: Boolean?,
favoriteUserId: Int?
): ListContainer<MomentEntity> {
return momentService.getMoments(
pageNumber = pageNumber,
author = author,
timelineId = timelineId,
contentSearch = contentSearch,
trend = trend,
explore = explore,
favoriteUserId = favoriteUserId
)
}
}
class MomentServiceImpl() : MomentService {
val momentBackend = MomentBackend()
override suspend fun getMoments(
pageNumber: Int,
author: Int?,
timelineId: Int?,
contentSearch: String?,
trend: Boolean?,
explore: Boolean?,
favoriteUserId: Int?,
): ListContainer<MomentEntity> {
return momentBackend.fetchMomentItems(
pageNumber = pageNumber,
author = author,
timelineId = timelineId,
contentSearch = contentSearch,
trend = trend,
favoriteUserId = favoriteUserId,
explore = explore
)
}
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<UploadImage>,
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)
}
override suspend fun deleteMoment(id: Int) {
momentBackend.deleteMoment(id)
}
}
class MomentBackend {
val DataBatchSize = 20
suspend fun fetchMomentItems(
pageNumber: Int,
author: Int? = null,
timelineId: Int?,
contentSearch: String?,
trend: Boolean?,
explore: Boolean?,
favoriteUserId: Int? = null
): ListContainer<MomentEntity> {
val resp = ApiClient.api.getPosts(
pageSize = DataBatchSize,
page = pageNumber,
timelineId = timelineId,
authorId = author,
contentSearch = contentSearch,
trend = if (trend == true) "true" else "",
favouriteUserId = favoriteUserId,
explore = if (explore == true) "true" else ""
)
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)
if (!resp.isSuccessful) {
parseErrorResponse(resp.errorBody())?.let {
throw it.toServiceException()
}
throw ServiceException("Failed to get moment")
}
return resp.body()?.data?.toMomentItem() ?: throw ServiceException("Failed to get moment")
}
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<UploadImage>,
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)
}
suspend fun deleteMoment(id: Int) {
ApiClient.api.deletePost(id)
}
}
/**
* 动态图片
*/
data class MomentImageEntity(
// 图片ID
val id: Long,
// 图片URL
val url: String,
// 缩略图URL
val thumbnail: String,
// 图片BlurHash
val blurHash: String? = null,
// 宽度
var width: Int? = null,
// 高度
var height: Int? = 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<MomentImageEntity> = emptyList(),
// 作者ID
val authorId: Int = 0,
// 是否点赞
var liked: Boolean = false,
// 关联动态ID
var relPostId: Int? = null,
// 关联动态
var relMoment: MomentEntity? = null,
// 是否收藏
var isFavorite: Boolean = false
)

View File

@@ -0,0 +1,40 @@
package com.aiosman.ravenow.entity
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aiosman.ravenow.data.UserService
import java.io.IOException
/**
* 用户信息分页加载器
*/
class AccountPagingSource(
private val userService: UserService,
private val nickname: String? = null,
private val followerId: Int? = null,
private val followingId: Int? = null
) : PagingSource<Int, AccountProfileEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, AccountProfileEntity> {
return try {
val currentPage = params.key ?: 1
val users = userService.getUsers(
page = currentPage,
nickname = nickname,
followerId = followerId,
followingId = followingId
)
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, AccountProfileEntity>): Int? {
return state.anchorPosition
}
}

View File

@@ -0,0 +1,9 @@
package com.aiosman.ravenow.exp
import android.graphics.Bitmap
fun Bitmap.rotate(degree: Int): Bitmap {
val matrix = android.graphics.Matrix()
matrix.postRotate(degree.toFloat())
return Bitmap.createBitmap(this, 0, 0, this.width, this.height, matrix, true)
}

View File

@@ -0,0 +1,81 @@
package com.aiosman.ravenow.exp
import android.content.Context
import android.icu.text.SimpleDateFormat
import android.icu.util.Calendar
import com.aiosman.ravenow.R
import java.util.Date
import java.util.Locale
import java.util.concurrent.TimeUnit
/**
* 格式化时间为 xx 前
*/
fun Date.timeAgo(context: Context): 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 -> context.getString(R.string.second_ago, seconds)
minutes < 60 -> context.getString(R.string.minute_ago, minutes)
hours < 24 -> context.getString(R.string.hour_ago, hours)
days < 365 -> context.getString(R.string.days_ago, days)
else -> SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(this)
}
}
/**
* 格式化时间为 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)
}
/**
* YYYY.DD.MM HH:MM
*/
fun Date.formatPostTime2(): String {
val calendar = Calendar.getInstance()
calendar.time = this
val year = calendar.get(Calendar.YEAR)
val month = calendar.get(Calendar.MONTH) + 1
val day = calendar.get(Calendar.DAY_OF_MONTH)
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)
}
}

View File

@@ -0,0 +1,95 @@
package com.aiosman.ravenow.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
}

View File

@@ -0,0 +1,9 @@
package com.aiosman.ravenow.exp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
inline fun <VM : ViewModel> viewModelFactory(crossinline f: () -> VM) =
object : ViewModelProvider.Factory {
override fun <T : ViewModel> create(aClass: Class<T>):T = f() as T
}

View File

@@ -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(".") # 从当前目录开始递归读取

View File

@@ -0,0 +1,11 @@
package com.aiosman.ravenow.model
import androidx.annotation.DrawableRes
data class ChatNotificationData(
@DrawableRes val avatar: Int,
val name: String,
val message: String,
val time: String,
val unread: Int
)

View File

@@ -0,0 +1,50 @@
package com.aiosman.ravenow.model
import androidx.paging.PagingSource
import androidx.paging.PagingState
import kotlin.math.ceil
import kotlinx.coroutines.delay
internal class TestChatBackend(
private val backendDataList: List<ChatNotificationData>,
private val loadDelay: Long = 500,
) {
val DataBatchSize = 1
class DesiredLoadResultPageResponse(val data: List<ChatNotificationData>)
/** 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<Int, ChatNotificationData>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ChatNotificationData> {
// 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, ChatNotificationData>): Int? {
return state.anchorPosition?.let {
state.closestPageToPosition(it)?.prevKey?.plus(1)
?: state.closestPageToPosition(it)?.nextKey?.minus(1)
}
}
}

View File

@@ -0,0 +1,41 @@
package com.aiosman.ravenow.model
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.util.Log
data class UpdateInfo(
val versionCode: Int,
val versionName: String,
val updateContent: String,
val downloadUrl: String,
val forceUpdate: Boolean
)
class ApkInstallReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
Log.d("ApkInstallReceiver", "onReceive() called") // 添加日志输出
if (DownloadManager.ACTION_DOWNLOAD_COMPLETE == intent.action) {
Log.d("ApkInstallReceiver", "Download complete") // 添加日志输出
val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
// 方案二:通过 DownloadManager 的 API 获取 Uri
val uri = downloadManager.getUriForDownloadedFile(downloadId)
if (uri != null) {
installApk(context, uri)
}
}
}
private fun installApk(context: Context, uri: Uri) {
Log.d("ApkInstallReceiver", "installApk() called with: context = $context, uri = $uri") // 添加日志输出
val installIntent = Intent(Intent.ACTION_VIEW)
installIntent.setDataAndType(uri, "application/vnd.android.package-archive")
installIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
context.startActivity(installIntent)
}
}

View File

@@ -0,0 +1,55 @@
package com.aiosman.ravenow
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
// apply dark mode
if (sharedPreferences.getBoolean("darkMode", false)) {
AppState.darkMode = true
AppState.appTheme = DarkThemeColors()
}
}
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)
}
fun saveDarkMode(darkMode: Boolean) {
sharedPreferences.edit().apply {
putBoolean("darkMode", darkMode)
}.apply()
}
}

View File

@@ -0,0 +1,28 @@
package com.aiosman.ravenow.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)
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
package com.aiosman.ravenow.ui.favourite
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.aiosman.ravenow.AppState
import com.aiosman.ravenow.data.MomentService
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.entity.MomentPagingSource
import com.aiosman.ravenow.entity.MomentRemoteDataSource
import com.aiosman.ravenow.entity.MomentServiceImpl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object FavouriteListViewModel:ViewModel() {
private val momentService: MomentService = MomentServiceImpl()
private val _favouriteMomentsFlow =
MutableStateFlow<PagingData<MomentEntity>>(PagingData.empty())
val favouriteMomentsFlow = _favouriteMomentsFlow.asStateFlow()
var isLoading by mutableStateOf(false)
fun refreshPager(force:Boolean = false) {
viewModelScope.launch {
if (force) {
isLoading = true
}
isLoading = false
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
MomentPagingSource(
MomentRemoteDataSource(momentService),
favoriteUserId = AppState.UserId
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_favouriteMomentsFlow.value = it
}
}
}
fun ResetModel() {
isLoading = false
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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