改包名com.aiosman.ravenow
This commit is contained in:
163
app/src/main/java/com/aiosman/ravenow/AppState.kt
Normal file
163
app/src/main/java/com/aiosman/ravenow/AppState.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
40
app/src/main/java/com/aiosman/ravenow/ChatState.kt
Normal file
40
app/src/main/java/com/aiosman/ravenow/ChatState.kt
Normal 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
|
||||
}
|
||||
}
|
||||
73
app/src/main/java/com/aiosman/ravenow/Colors.kt
Normal file
73
app/src/main/java/com/aiosman/ravenow/Colors.kt
Normal 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)
|
||||
)
|
||||
35
app/src/main/java/com/aiosman/ravenow/Const.kt
Normal file
35
app/src/main/java/com/aiosman/ravenow/Const.kt
Normal 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"
|
||||
}
|
||||
|
||||
67
app/src/main/java/com/aiosman/ravenow/ImageListScreen.kt
Normal file
67
app/src/main/java/com/aiosman/ravenow/ImageListScreen.kt
Normal 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()
|
||||
}
|
||||
49
app/src/main/java/com/aiosman/ravenow/JpushReciver.kt
Normal file
49
app/src/main/java/com/aiosman/ravenow/JpushReciver.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
8
app/src/main/java/com/aiosman/ravenow/JpushService.kt
Normal file
8
app/src/main/java/com/aiosman/ravenow/JpushService.kt
Normal file
@@ -0,0 +1,8 @@
|
||||
package com.aiosman.ravenow
|
||||
|
||||
import cn.jpush.android.service.JCommonService
|
||||
|
||||
class JpushService : JCommonService() {
|
||||
|
||||
|
||||
}
|
||||
239
app/src/main/java/com/aiosman/ravenow/MainActivity.kt
Normal file
239
app/src/main/java/com/aiosman/ravenow/MainActivity.kt
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
58
app/src/main/java/com/aiosman/ravenow/Messaging.kt
Normal file
58
app/src/main/java/com/aiosman/ravenow/Messaging.kt
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
131
app/src/main/java/com/aiosman/ravenow/TrtcService.kt
Normal file
131
app/src/main/java/com/aiosman/ravenow/TrtcService.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
556
app/src/main/java/com/aiosman/ravenow/data/AccountService.kt
Normal file
556
app/src/main/java/com/aiosman/ravenow/data/AccountService.kt
Normal 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
|
||||
}
|
||||
}
|
||||
45
app/src/main/java/com/aiosman/ravenow/data/CaptchaService.kt
Normal file
45
app/src/main/java/com/aiosman/ravenow/data/CaptchaService.kt
Normal 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,", "")
|
||||
)
|
||||
}
|
||||
}
|
||||
42
app/src/main/java/com/aiosman/ravenow/data/ChatService.kt
Normal file
42
app/src/main/java/com/aiosman/ravenow/data/ChatService.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
255
app/src/main/java/com/aiosman/ravenow/data/CommentService.kt
Normal file
255
app/src/main/java/com/aiosman/ravenow/data/CommentService.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.aiosman.ravenow.data
|
||||
|
||||
/**
|
||||
* 通用接口返回数据
|
||||
*/
|
||||
data class DataContainer<T>(
|
||||
val data: T
|
||||
)
|
||||
19
app/src/main/java/com/aiosman/ravenow/data/DictService.kt
Normal file
19
app/src/main/java/com/aiosman/ravenow/data/DictService.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
47
app/src/main/java/com/aiosman/ravenow/data/Exception.kt
Normal file
47
app/src/main/java/com/aiosman/ravenow/data/Exception.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
22
app/src/main/java/com/aiosman/ravenow/data/ListContainer.kt
Normal file
22
app/src/main/java/com/aiosman/ravenow/data/ListContainer.kt
Normal 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>
|
||||
)
|
||||
169
app/src/main/java/com/aiosman/ravenow/data/MomentService.kt
Normal file
169
app/src/main/java/com/aiosman/ravenow/data/MomentService.kt
Normal 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)
|
||||
}
|
||||
|
||||
|
||||
100
app/src/main/java/com/aiosman/ravenow/data/UserService.kt
Normal file
100
app/src/main/java/com/aiosman/ravenow/data/UserService.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
140
app/src/main/java/com/aiosman/ravenow/data/api/ApiClient.kt
Normal file
140
app/src/main/java/com/aiosman/ravenow/data/api/ApiClient.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
42
app/src/main/java/com/aiosman/ravenow/data/api/Error.kt
Normal file
42
app/src/main/java/com/aiosman/ravenow/data/api/Error.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
428
app/src/main/java/com/aiosman/ravenow/data/api/RiderProAPI.kt
Normal file
428
app/src/main/java/com/aiosman/ravenow/data/api/RiderProAPI.kt
Normal 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>>
|
||||
}
|
||||
|
||||
199
app/src/main/java/com/aiosman/ravenow/entity/Account.kt
Normal file
199
app/src/main/java/com/aiosman/ravenow/entity/Account.kt
Normal 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
|
||||
}
|
||||
}
|
||||
101
app/src/main/java/com/aiosman/ravenow/entity/Chat.kt
Normal file
101
app/src/main/java/com/aiosman/ravenow/entity/Chat.kt
Normal 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
|
||||
)
|
||||
64
app/src/main/java/com/aiosman/ravenow/entity/Comment.kt
Normal file
64
app/src/main/java/com/aiosman/ravenow/entity/Comment.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
287
app/src/main/java/com/aiosman/ravenow/entity/Moment.kt
Normal file
287
app/src/main/java/com/aiosman/ravenow/entity/Moment.kt
Normal 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
|
||||
)
|
||||
40
app/src/main/java/com/aiosman/ravenow/entity/User.kt
Normal file
40
app/src/main/java/com/aiosman/ravenow/entity/User.kt
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
9
app/src/main/java/com/aiosman/ravenow/exp/Bitmap.kt
Normal file
9
app/src/main/java/com/aiosman/ravenow/exp/Bitmap.kt
Normal 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)
|
||||
}
|
||||
81
app/src/main/java/com/aiosman/ravenow/exp/Date.kt
Normal file
81
app/src/main/java/com/aiosman/ravenow/exp/Date.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
95
app/src/main/java/com/aiosman/ravenow/exp/StatusBarExp.kt
Normal file
95
app/src/main/java/com/aiosman/ravenow/exp/StatusBarExp.kt
Normal 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
|
||||
}
|
||||
9
app/src/main/java/com/aiosman/ravenow/exp/ViewModel.kt
Normal file
9
app/src/main/java/com/aiosman/ravenow/exp/ViewModel.kt
Normal 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
|
||||
}
|
||||
23
app/src/main/java/com/aiosman/ravenow/llama.py
Normal file
23
app/src/main/java/com/aiosman/ravenow/llama.py
Normal 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(".") # 从当前目录开始递归读取
|
||||
@@ -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
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
41
app/src/main/java/com/aiosman/ravenow/model/UpdateInfo.kt
Normal file
41
app/src/main/java/com/aiosman/ravenow/model/UpdateInfo.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
55
app/src/main/java/com/aiosman/ravenow/store.kt
Normal file
55
app/src/main/java/com/aiosman/ravenow/store.kt
Normal 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()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
28
app/src/main/java/com/aiosman/ravenow/test/TestStreetMap.kt
Normal file
28
app/src/main/java/com/aiosman/ravenow/test/TestStreetMap.kt
Normal 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)
|
||||
)
|
||||
427
app/src/main/java/com/aiosman/ravenow/ui/Navi.kt
Normal file
427
app/src/main/java/com/aiosman/ravenow/ui/Navi.kt
Normal file
@@ -0,0 +1,427 @@
|
||||
package com.aiosman.ravenow.ui
|
||||
|
||||
import ChangePasswordScreen
|
||||
import ImageViewer
|
||||
import ModificationListScreen
|
||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||
import androidx.compose.animation.SharedTransitionLayout
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.aiosman.ravenow.LocalAnimatedContentScope
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.LocalSharedTransitionScope
|
||||
import com.aiosman.ravenow.ui.account.AccountEditScreen2
|
||||
import com.aiosman.ravenow.ui.account.ResetPasswordScreen
|
||||
import com.aiosman.ravenow.ui.chat.ChatScreen
|
||||
import com.aiosman.ravenow.ui.comment.CommentsScreen
|
||||
import com.aiosman.ravenow.ui.comment.notice.CommentNoticeScreen
|
||||
import com.aiosman.ravenow.ui.crop.ImageCropScreen
|
||||
import com.aiosman.ravenow.ui.favourite.FavouriteListPage
|
||||
import com.aiosman.ravenow.ui.favourite.FavouriteNoticeScreen
|
||||
import com.aiosman.ravenow.ui.follower.FollowerListScreen
|
||||
import com.aiosman.ravenow.ui.follower.FollowerNoticeScreen
|
||||
import com.aiosman.ravenow.ui.follower.FollowingListScreen
|
||||
import com.aiosman.ravenow.ui.gallery.OfficialGalleryScreen
|
||||
import com.aiosman.ravenow.ui.gallery.OfficialPhotographerScreen
|
||||
import com.aiosman.ravenow.ui.gallery.ProfileTimelineScreen
|
||||
import com.aiosman.ravenow.ui.index.IndexScreen
|
||||
import com.aiosman.ravenow.ui.index.tabs.message.NotificationsScreen
|
||||
import com.aiosman.ravenow.ui.index.tabs.search.SearchScreen
|
||||
import com.aiosman.ravenow.ui.like.LikeNoticeScreen
|
||||
import com.aiosman.ravenow.ui.location.LocationDetailScreen
|
||||
import com.aiosman.ravenow.ui.login.EmailSignupScreen
|
||||
import com.aiosman.ravenow.ui.login.LoginPage
|
||||
import com.aiosman.ravenow.ui.login.SignupScreen
|
||||
import com.aiosman.ravenow.ui.login.UserAuthScreen
|
||||
import com.aiosman.ravenow.ui.modification.EditModificationScreen
|
||||
import com.aiosman.ravenow.ui.post.NewPostImageGridScreen
|
||||
import com.aiosman.ravenow.ui.post.NewPostScreen
|
||||
import com.aiosman.ravenow.ui.post.PostScreen
|
||||
import com.aiosman.ravenow.ui.profile.AccountProfileV2
|
||||
|
||||
sealed class NavigationRoute(
|
||||
val route: String,
|
||||
) {
|
||||
data object Index : NavigationRoute("Index")
|
||||
data object ProfileTimeline : NavigationRoute("ProfileTimeline")
|
||||
data object LocationDetail : NavigationRoute("LocationDetail/{x}/{y}")
|
||||
data object OfficialPhoto : NavigationRoute("OfficialPhoto")
|
||||
data object OfficialPhotographer : NavigationRoute("OfficialPhotographer")
|
||||
data object Post : NavigationRoute("Post/{id}/{highlightCommentId}/{initImagePagerIndex}")
|
||||
data object ModificationList : NavigationRoute("ModificationList")
|
||||
data object MyMessage : NavigationRoute("MyMessage")
|
||||
data object Comments : NavigationRoute("Comments")
|
||||
data object Likes : NavigationRoute("Likes")
|
||||
data object Followers : NavigationRoute("Followers")
|
||||
data object NewPost : NavigationRoute("NewPost")
|
||||
data object EditModification : NavigationRoute("EditModification")
|
||||
data object Login : NavigationRoute("Login")
|
||||
data object AccountProfile : NavigationRoute("AccountProfile/{id}")
|
||||
data object SignUp : NavigationRoute("SignUp")
|
||||
data object UserAuth : NavigationRoute("UserAuth")
|
||||
data object EmailSignUp : NavigationRoute("EmailSignUp")
|
||||
data object AccountEdit : NavigationRoute("AccountEditScreen")
|
||||
data object ImageViewer : NavigationRoute("ImageViewer")
|
||||
data object ChangePasswordScreen : NavigationRoute("ChangePasswordScreen")
|
||||
data object FavouritesScreen : NavigationRoute("FavouritesScreen")
|
||||
data object NewPostImageGrid : NavigationRoute("NewPostImageGrid")
|
||||
data object Search : NavigationRoute("Search")
|
||||
data object FollowerList : NavigationRoute("FollowerList/{id}")
|
||||
data object FollowingList : NavigationRoute("FollowingList/{id}")
|
||||
data object ResetPassword : NavigationRoute("ResetPassword")
|
||||
data object FavouriteList : NavigationRoute("FavouriteList")
|
||||
data object Chat : NavigationRoute("Chat/{id}")
|
||||
data object CommentNoticeScreen : NavigationRoute("CommentNoticeScreen")
|
||||
data object ImageCrop : NavigationRoute("ImageCrop")
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun NavigationController(
|
||||
navController: NavHostController,
|
||||
startDestination: String = NavigationRoute.Login.route
|
||||
) {
|
||||
val navigationBarHeight = with(LocalDensity.current) {
|
||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||
}
|
||||
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
) {
|
||||
composable(route = NavigationRoute.Index.route) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
IndexScreen()
|
||||
}
|
||||
}
|
||||
composable(route = NavigationRoute.ProfileTimeline.route) {
|
||||
ProfileTimelineScreen()
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.LocationDetail.route,
|
||||
arguments = listOf(
|
||||
navArgument("x") { type = NavType.FloatType },
|
||||
navArgument("y") { type = NavType.FloatType }
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(bottom = navigationBarHeight)
|
||||
) {
|
||||
val x = it.arguments?.getFloat("x") ?: 0f
|
||||
val y = it.arguments?.getFloat("y") ?: 0f
|
||||
LocationDetailScreen(
|
||||
x, y
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(route = NavigationRoute.OfficialPhoto.route) {
|
||||
OfficialGalleryScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.OfficialPhotographer.route) {
|
||||
OfficialPhotographerScreen()
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.Post.route,
|
||||
arguments = listOf(
|
||||
navArgument("id") { type = NavType.StringType },
|
||||
navArgument("highlightCommentId") { type = NavType.IntType },
|
||||
navArgument("initImagePagerIndex") { type = NavType.IntType }
|
||||
),
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 200))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 200))
|
||||
},
|
||||
popEnterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 200))
|
||||
},
|
||||
popExitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 200))
|
||||
}
|
||||
) { backStackEntry ->
|
||||
val id = backStackEntry.arguments?.getString("id")
|
||||
val highlightCommentId =
|
||||
backStackEntry.arguments?.getInt("highlightCommentId")?.let {
|
||||
if (it == 0) null else it
|
||||
}
|
||||
val initIndex = backStackEntry.arguments?.getInt("initImagePagerIndex")
|
||||
PostScreen(
|
||||
id!!,
|
||||
highlightCommentId,
|
||||
initImagePagerIndex = initIndex
|
||||
)
|
||||
}
|
||||
|
||||
composable(route = NavigationRoute.ModificationList.route,
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
||||
}
|
||||
) {
|
||||
ModificationListScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.MyMessage.route,
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
||||
}
|
||||
) {
|
||||
NotificationsScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.Comments.route,
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
||||
}
|
||||
) {
|
||||
CommentsScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.Likes.route,
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
||||
}
|
||||
) {
|
||||
LikeNoticeScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.Followers.route,
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
||||
}
|
||||
) {
|
||||
FollowerNoticeScreen()
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.NewPost.route,
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
||||
}
|
||||
) {
|
||||
NewPostScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.EditModification.route) {
|
||||
Box(
|
||||
modifier = Modifier.padding(top = 64.dp)
|
||||
) {
|
||||
EditModificationScreen()
|
||||
}
|
||||
}
|
||||
composable(route = NavigationRoute.Login.route) {
|
||||
LoginPage()
|
||||
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.AccountProfile.route,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
AccountProfileV2(it.arguments?.getString("id")!!)
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.SignUp.route,
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
||||
}
|
||||
) {
|
||||
SignupScreen()
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.UserAuth.route,
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
||||
}
|
||||
) {
|
||||
UserAuthScreen()
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.EmailSignUp.route,
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
||||
}
|
||||
) {
|
||||
EmailSignupScreen()
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.AccountEdit.route,
|
||||
enterTransition = {
|
||||
fadeIn(animationSpec = tween(durationMillis = 0))
|
||||
},
|
||||
exitTransition = {
|
||||
fadeOut(animationSpec = tween(durationMillis = 0))
|
||||
}
|
||||
) {
|
||||
AccountEditScreen2()
|
||||
}
|
||||
composable(route = NavigationRoute.ImageViewer.route) {
|
||||
|
||||
ImageViewer()
|
||||
|
||||
}
|
||||
composable(route = NavigationRoute.ChangePasswordScreen.route) {
|
||||
ChangePasswordScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.FavouritesScreen.route) {
|
||||
FavouriteNoticeScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.NewPostImageGrid.route) {
|
||||
NewPostImageGridScreen()
|
||||
}
|
||||
composable(route = NavigationRoute.Search.route) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
SearchScreen()
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.FollowerList.route,
|
||||
arguments = listOf(navArgument("id") { type = NavType.IntType })
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
FollowerListScreen(it.arguments?.getInt("id")!!)
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.FollowingList.route,
|
||||
arguments = listOf(navArgument("id") { type = NavType.IntType })
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
FollowingListScreen(it.arguments?.getInt("id")!!)
|
||||
}
|
||||
}
|
||||
composable(route = NavigationRoute.ResetPassword.route) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
ResetPasswordScreen()
|
||||
}
|
||||
}
|
||||
composable(route = NavigationRoute.FavouriteList.route) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
FavouriteListPage()
|
||||
}
|
||||
}
|
||||
composable(
|
||||
route = NavigationRoute.Chat.route,
|
||||
arguments = listOf(navArgument("id") { type = NavType.StringType })
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
ChatScreen(it.arguments?.getString("id")!!)
|
||||
}
|
||||
}
|
||||
composable(route = NavigationRoute.CommentNoticeScreen.route) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
CommentNoticeScreen()
|
||||
}
|
||||
}
|
||||
composable(route = NavigationRoute.ImageCrop.route) {
|
||||
CompositionLocalProvider(
|
||||
LocalAnimatedContentScope provides this,
|
||||
) {
|
||||
ImageCropScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun Navigation(
|
||||
startDestination: String = NavigationRoute.Login.route,
|
||||
onLaunch: (navController: NavHostController) -> Unit
|
||||
) {
|
||||
val navController = rememberNavController()
|
||||
LaunchedEffect(Unit) {
|
||||
onLaunch(navController)
|
||||
}
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(
|
||||
LocalNavController provides navController,
|
||||
LocalSharedTransitionScope provides this@SharedTransitionLayout,
|
||||
) {
|
||||
Box {
|
||||
NavigationController(
|
||||
navController = navController,
|
||||
startDestination = startDestination
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun NavHostController.navigateToPost(
|
||||
id: Int,
|
||||
highlightCommentId: Int? = 0,
|
||||
initImagePagerIndex: Int? = 0
|
||||
) {
|
||||
navigate(
|
||||
route = NavigationRoute.Post.route
|
||||
.replace("{id}", id.toString())
|
||||
.replace("{highlightCommentId}", highlightCommentId.toString())
|
||||
.replace("{initImagePagerIndex}", initImagePagerIndex.toString())
|
||||
)
|
||||
}
|
||||
|
||||
fun NavHostController.navigateToChat(id: String) {
|
||||
navigate(
|
||||
route = NavigationRoute.Chat.route
|
||||
.replace("{id}", id)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.UploadImage
|
||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||
import com.aiosman.ravenow.ui.index.tabs.profile.MyProfileViewModel
|
||||
import java.io.File
|
||||
|
||||
object AccountEditViewModel : ViewModel() {
|
||||
var name by mutableStateOf("")
|
||||
var bio by mutableStateOf("")
|
||||
var imageUrl by mutableStateOf<Uri?>(null)
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
var profile by mutableStateOf<AccountProfileEntity?>(null)
|
||||
var croppedBitmap by mutableStateOf<Bitmap?>(null)
|
||||
var isUpdating by mutableStateOf(false)
|
||||
suspend fun reloadProfile() {
|
||||
accountService.getMyAccountProfile().let {
|
||||
profile = it
|
||||
name = it.nickName
|
||||
bio = it.bio
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateUserProfile(context: Context) {
|
||||
val newAvatar = croppedBitmap?.let {
|
||||
val file = File(context.cacheDir, "avatar.jpg")
|
||||
it.compress(Bitmap.CompressFormat.JPEG, 100, file.outputStream())
|
||||
UploadImage(file, "avatar.jpg", "", "jpg")
|
||||
}
|
||||
val newName = if (name == profile?.nickName) null else name
|
||||
accountService.updateProfile(
|
||||
avatar = newAvatar,
|
||||
banner = null,
|
||||
nickName = newName,
|
||||
bio = bio
|
||||
)
|
||||
// 刷新用户资料
|
||||
reloadProfile()
|
||||
// 刷新个人资料页面的用户资料
|
||||
MyProfileViewModel.loadUserProfile()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.ConstVars
|
||||
import com.aiosman.ravenow.data.api.ErrorCode
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.DictService
|
||||
import com.aiosman.ravenow.data.DictServiceImpl
|
||||
import com.aiosman.ravenow.data.ServiceException
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.ActionButton
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.composables.TextInputField
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun ResetPasswordScreen() {
|
||||
var username by remember { mutableStateOf("") }
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
val dictService: DictService = DictServiceImpl()
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
var isSendSuccess by remember { mutableStateOf<Boolean?>(null) }
|
||||
var isLoading by remember { mutableStateOf(false) }
|
||||
val navController = LocalNavController.current
|
||||
var usernameError by remember { mutableStateOf<String?>(null) }
|
||||
var countDown by remember { mutableStateOf<Int?>(null) }
|
||||
var countDownMax by remember { mutableStateOf(60) }
|
||||
val appColors = LocalAppTheme.current
|
||||
fun validate(): Boolean {
|
||||
if (username.isEmpty()) {
|
||||
usernameError = context.getString(R.string.text_error_email_required)
|
||||
return false
|
||||
}
|
||||
usernameError = null
|
||||
return true
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
try {
|
||||
dictService.getDictByKey(ConstVars.DIC_KEY_RESET_EMAIL_INTERVAL).let {
|
||||
countDownMax = it.value.toInt()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
countDownMax = 60
|
||||
}
|
||||
}
|
||||
|
||||
fun startCountDown() {
|
||||
scope.launch {
|
||||
countDown = countDownMax
|
||||
while (countDown!! > 0) {
|
||||
delay(1000)
|
||||
countDown = countDown!! - 1
|
||||
}
|
||||
countDown = null
|
||||
}
|
||||
}
|
||||
|
||||
fun resetPassword() {
|
||||
if (!validate()) return
|
||||
scope.launch {
|
||||
isLoading = true
|
||||
try {
|
||||
accountService.resetPassword(username)
|
||||
isSendSuccess = true
|
||||
startCountDown()
|
||||
} catch (e: ServiceException) {
|
||||
if (e.code == ErrorCode.USER_NOT_EXIST.code){
|
||||
usernameError = context.getString(R.string.error_40002_user_not_exist)
|
||||
} else {
|
||||
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
|
||||
isSendSuccess = false
|
||||
} finally {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Box(
|
||||
modifier = Modifier.padding(
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
top = 16.dp,
|
||||
bottom = 0.dp
|
||||
)
|
||||
) {
|
||||
NoticeScreenHeader(
|
||||
stringResource(R.string.recover_account_upper),
|
||||
moreIcon = false
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(72.dp))
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
TextInputField(
|
||||
text = username,
|
||||
onValueChange = { username = it },
|
||||
hint = stringResource(R.string.text_hint_email),
|
||||
enabled = !isLoading && countDown == null,
|
||||
error = usernameError,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Box(
|
||||
modifier = Modifier.height(72.dp)
|
||||
) {
|
||||
isSendSuccess?.let {
|
||||
if (it) {
|
||||
Text(
|
||||
text = stringResource(R.string.reset_mail_send_success),
|
||||
style = TextStyle(
|
||||
color = appColors.text,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = stringResource(R.string.reset_mail_send_failed),
|
||||
style = TextStyle(
|
||||
color = appColors.text,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
text = if (countDown != null) {
|
||||
stringResource(R.string.resend, "(${countDown})")
|
||||
} else {
|
||||
stringResource(R.string.recover)
|
||||
},
|
||||
backgroundColor = appColors.main,
|
||||
color = appColors.mainText,
|
||||
isLoading = isLoading,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
enabled = countDown == null,
|
||||
) {
|
||||
resetPassword()
|
||||
}
|
||||
isSendSuccess?.let {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ActionButton(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp),
|
||||
text = stringResource(R.string.back_upper),
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
) {
|
||||
navController.navigateUp()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.ServiceException
|
||||
import com.aiosman.ravenow.data.api.ErrorCode
|
||||
import com.aiosman.ravenow.data.api.showToast
|
||||
import com.aiosman.ravenow.data.api.toErrorMessage
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.ActionButton
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.composables.TextInputField
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 修改密码页面的 ViewModel
|
||||
*/
|
||||
class ChangePasswordViewModel {
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
|
||||
/**
|
||||
* 修改密码
|
||||
* @param currentPassword 当前密码
|
||||
* @param newPassword 新密码
|
||||
*/
|
||||
suspend fun changePassword(currentPassword: String, newPassword: String) {
|
||||
accountService.changeAccountPassword(currentPassword, newPassword)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改密码页面
|
||||
*/
|
||||
@Composable
|
||||
fun ChangePasswordScreen() {
|
||||
val context = LocalContext.current
|
||||
val viewModel = remember { ChangePasswordViewModel() }
|
||||
var currentPassword by remember { mutableStateOf("") }
|
||||
var newPassword by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf("") }
|
||||
val scope = rememberCoroutineScope()
|
||||
val navController = LocalNavController.current
|
||||
var oldPasswordError by remember { mutableStateOf<String?>(null) }
|
||||
var confirmPasswordError by remember { mutableStateOf<String?>(null) }
|
||||
var passwordError by remember { mutableStateOf<String?>(null) }
|
||||
val AppColors = LocalAppTheme.current
|
||||
fun validate(): Boolean {
|
||||
oldPasswordError =
|
||||
if (currentPassword.isEmpty()) "Please enter your current password" else null
|
||||
passwordError = when {
|
||||
newPassword.length < 8 -> "Password must be at least 8 characters long"
|
||||
!newPassword.any { it.isDigit() } -> "Password must contain at least one digit"
|
||||
!newPassword.any { it.isUpperCase() } -> "Password must contain at least one uppercase letter"
|
||||
!newPassword.any { it.isLowerCase() } -> "Password must contain at least one lowercase letter"
|
||||
else -> null
|
||||
}
|
||||
confirmPasswordError =
|
||||
if (newPassword != confirmPassword) "Passwords do not match" else null
|
||||
return passwordError == null && confirmPasswordError == null && oldPasswordError == null
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppColors.background),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
NoticeScreenHeader(
|
||||
title = "Change password",
|
||||
moreIcon = false
|
||||
)
|
||||
|
||||
}
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(80.dp))
|
||||
TextInputField(
|
||||
text = currentPassword,
|
||||
onValueChange = { currentPassword = it },
|
||||
password = true,
|
||||
label = "Current password",
|
||||
hint = "Enter your current password",
|
||||
error = oldPasswordError
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
TextInputField(
|
||||
text = newPassword,
|
||||
onValueChange = { newPassword = it },
|
||||
password = true,
|
||||
label = "New password",
|
||||
hint = "Enter your new password",
|
||||
error = passwordError
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
TextInputField(
|
||||
text = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
password = true,
|
||||
label = "Confirm new password",
|
||||
hint = "Enter your new password again",
|
||||
error = confirmPasswordError
|
||||
)
|
||||
Spacer(modifier = Modifier.height(50.dp))
|
||||
ActionButton(
|
||||
modifier = Modifier
|
||||
.width(345.dp),
|
||||
text = "Let's Ride",
|
||||
) {
|
||||
if (validate()) {
|
||||
scope.launch {
|
||||
try {
|
||||
viewModel.changePassword(currentPassword, newPassword)
|
||||
|
||||
navController.navigateUp()
|
||||
} catch (e: ServiceException) {
|
||||
when (e.errorType) {
|
||||
ErrorCode.IncorrectOldPassword ->
|
||||
oldPasswordError = e.errorType.toErrorMessage(context)
|
||||
else ->
|
||||
e.errorType.showToast(context)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Toast.makeText(context, e.message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
240
app/src/main/java/com/aiosman/ravenow/ui/account/edit.kt
Normal file
240
app/src/main/java/com/aiosman/ravenow/ui/account/edit.kt
Normal file
@@ -0,0 +1,240 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.UploadImage
|
||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.ui.post.NewPostViewModel.uriToFile
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 编辑用户资料界面
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AccountEditScreen() {
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
var name by remember { mutableStateOf("") }
|
||||
var bio by remember { mutableStateOf("") }
|
||||
var imageUrl by remember { mutableStateOf<Uri?>(null) }
|
||||
var bannerImageUrl by remember { mutableStateOf<Uri?>(null) }
|
||||
var profile by remember {
|
||||
mutableStateOf<AccountProfileEntity?>(
|
||||
null
|
||||
)
|
||||
}
|
||||
val navController = LocalNavController.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val context = LocalContext.current
|
||||
|
||||
/**
|
||||
* 加载用户资料
|
||||
*/
|
||||
suspend fun reloadProfile() {
|
||||
accountService.getMyAccountProfile().let {
|
||||
profile = it
|
||||
name = it.nickName
|
||||
bio = it.bio
|
||||
}
|
||||
}
|
||||
|
||||
fun updateUserProfile() {
|
||||
scope.launch {
|
||||
val newAvatar = imageUrl?.let {
|
||||
val cursor = context.contentResolver.query(it, null, null, null, null)
|
||||
var newAvatar: UploadImage? = null
|
||||
cursor?.use { cur ->
|
||||
if (cur.moveToFirst()) {
|
||||
val displayName = cur.getString(cur.getColumnIndex("_display_name"))
|
||||
val extension = displayName.substringAfterLast(".")
|
||||
Log.d("NewPost", "File name: $displayName, extension: $extension")
|
||||
// read as file
|
||||
val file = uriToFile(context, it)
|
||||
Log.d("NewPost", "File size: ${file.length()}")
|
||||
newAvatar = UploadImage(file, displayName, it.toString(), extension)
|
||||
}
|
||||
}
|
||||
newAvatar
|
||||
}
|
||||
var newBanner = bannerImageUrl?.let {
|
||||
val cursor = context.contentResolver.query(it, null, null, null, null)
|
||||
var newBanner: UploadImage? = null
|
||||
cursor?.use { cur ->
|
||||
if (cur.moveToFirst()) {
|
||||
val displayName = cur.getString(cur.getColumnIndex("_display_name"))
|
||||
val extension = displayName.substringAfterLast(".")
|
||||
Log.d("NewPost", "File name: $displayName, extension: $extension")
|
||||
// read as file
|
||||
val file = uriToFile(context, it)
|
||||
Log.d("NewPost", "File size: ${file.length()}")
|
||||
newBanner = UploadImage(file, displayName, it.toString(), extension)
|
||||
}
|
||||
}
|
||||
newBanner
|
||||
}
|
||||
val newName = if (name == profile?.nickName) null else name
|
||||
accountService.updateProfile(
|
||||
avatar = newAvatar,
|
||||
banner = newBanner,
|
||||
nickName = newName,
|
||||
bio = bio
|
||||
)
|
||||
reloadProfile()
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
|
||||
val pickImageLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val uri = result.data?.data
|
||||
uri?.let {
|
||||
imageUrl = it
|
||||
}
|
||||
}
|
||||
}
|
||||
val pickBannerImageLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val uri = result.data?.data
|
||||
uri?.let {
|
||||
bannerImageUrl = uri
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
reloadProfile()
|
||||
}
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Edit") },
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
updateUserProfile()
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Save"
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
profile?.let {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
if (imageUrl != null) {
|
||||
imageUrl.toString()
|
||||
} else {
|
||||
it.avatar
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(100.dp)
|
||||
.noRippleClickable {
|
||||
Intent(Intent.ACTION_PICK).apply {
|
||||
type = "image/*"
|
||||
pickImageLauncher.launch(this)
|
||||
}
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
if (bannerImageUrl != null) {
|
||||
bannerImageUrl.toString()
|
||||
} else {
|
||||
it.banner!!
|
||||
},
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.noRippleClickable {
|
||||
Intent(Intent.ACTION_PICK).apply {
|
||||
type = "image/*"
|
||||
pickBannerImageLauncher.launch(this)
|
||||
}
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
TextField(
|
||||
value = name,
|
||||
onValueChange = {
|
||||
name = it
|
||||
},
|
||||
label = {
|
||||
Text("Name")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
TextField(
|
||||
value = bio,
|
||||
onValueChange = {
|
||||
bio = it
|
||||
},
|
||||
label = {
|
||||
Text("Bio")
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
190
app/src/main/java/com/aiosman/ravenow/ui/account/edit2.kt
Normal file
190
app/src/main/java/com/aiosman/ravenow/ui/account/edit2.kt
Normal file
@@ -0,0 +1,190 @@
|
||||
package com.aiosman.ravenow.ui.account
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.composables.form.FormTextInput
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 编辑用户资料界面
|
||||
*/
|
||||
@Composable
|
||||
fun AccountEditScreen2() {
|
||||
val model = AccountEditViewModel
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
var usernameError by remember { mutableStateOf<String?>(null) }
|
||||
var bioError by remember { mutableStateOf<String?>(null) }
|
||||
fun onNicknameChange(value: String) {
|
||||
model.name = value
|
||||
usernameError = when {
|
||||
value.isEmpty() -> "昵称不能为空"
|
||||
value.length < 3 -> "昵称长度不能小于3"
|
||||
value.length > 20 -> "昵称长度不能大于20"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
val appColors = LocalAppTheme.current
|
||||
|
||||
fun onBioChange(value: String) {
|
||||
model.bio = value
|
||||
bioError = when {
|
||||
value.length > 100 -> "个人简介长度不能大于24"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
fun validate(): Boolean {
|
||||
return usernameError == null && bioError == null
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
if (model.profile == null) {
|
||||
model.reloadProfile()
|
||||
}
|
||||
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(color = appColors.background),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
NoticeScreenHeader(
|
||||
title = stringResource(R.string.edit_profile),
|
||||
moreIcon = false
|
||||
) {
|
||||
|
||||
Icon(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.noRippleClickable {
|
||||
|
||||
if (validate() && !model.isUpdating) {
|
||||
model.viewModelScope.launch {
|
||||
model.isUpdating = true
|
||||
model.updateUserProfile(context)
|
||||
model.viewModelScope.launch(Dispatchers.Main) {
|
||||
navController.navigateUp()
|
||||
model.isUpdating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
},
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "保存",
|
||||
tint = if (validate() && !model.isUpdating) Color.Black else Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(44.dp))
|
||||
model.profile?.let {
|
||||
Box(
|
||||
modifier = Modifier.size(88.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
model.croppedBitmap ?: it.avatar,
|
||||
modifier = Modifier
|
||||
.size(88.dp)
|
||||
.clip(
|
||||
RoundedCornerShape(88.dp)
|
||||
),
|
||||
contentDescription = "",
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape)
|
||||
.background(appColors.main)
|
||||
.align(Alignment.BottomEnd)
|
||||
.noRippleClickable {
|
||||
navController.navigate(NavigationRoute.ImageCrop.route)
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Add,
|
||||
contentDescription = "Add",
|
||||
tint = Color.White,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
Spacer(modifier = Modifier.height(58.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
FormTextInput(
|
||||
value = model.name,
|
||||
label = stringResource(R.string.nickname),
|
||||
hint = "Input nickname",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
error = usernameError
|
||||
) { value ->
|
||||
onNicknameChange(value)
|
||||
}
|
||||
// Spacer(modifier = Modifier.height(16.dp))
|
||||
FormTextInput(
|
||||
value = model.bio,
|
||||
label = stringResource(R.string.bio),
|
||||
hint = "Input bio",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
error = bioError
|
||||
) { value ->
|
||||
onBioChange(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
644
app/src/main/java/com/aiosman/ravenow/ui/chat/ChatScreen.kt
Normal file
644
app/src/main/java/com/aiosman/ravenow/ui/chat/ChatScreen.kt
Normal file
@@ -0,0 +1,644 @@
|
||||
package com.aiosman.ravenow.ui.chat
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Scaffold
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.SoftwareKeyboardController
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.ChatItem
|
||||
import com.aiosman.ravenow.exp.formatChatTime
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.DropdownMenu
|
||||
import com.aiosman.ravenow.ui.composables.MenuItem
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.tencent.imsdk.v2.V2TIMMessage
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@Composable
|
||||
fun ChatScreen(userId: String) {
|
||||
var isMenuExpanded by remember { mutableStateOf(false) }
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalNavController.current.context
|
||||
val AppColors = LocalAppTheme.current
|
||||
var goToNewCount by remember { mutableStateOf(0) }
|
||||
val viewModel = viewModel<ChatViewModel>(
|
||||
key = "ChatViewModel_$userId",
|
||||
factory = object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return ChatViewModel(userId) as T
|
||||
}
|
||||
}
|
||||
)
|
||||
var isLoadingMore by remember { mutableStateOf(false) } // Add a state for loading
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.init(context = context)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
viewModel.UnRegistListener()
|
||||
viewModel.clearUnRead()
|
||||
}
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val navigationBarHeight = with(LocalDensity.current) {
|
||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||
}
|
||||
var inBottom by remember { mutableStateOf(true) }
|
||||
// 监听滚动状态,触发加载更多
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
|
||||
.collect { index ->
|
||||
Log.d("ChatScreen", "lastVisibleItemIndex: ${index}")
|
||||
if (index == listState.layoutInfo.totalItemsCount - 1) {
|
||||
coroutineScope.launch {
|
||||
viewModel.onLoadMore(context)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
// 监听滚动状态,触发滚动到底部
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.firstOrNull()?.index }
|
||||
.collect { index ->
|
||||
inBottom = index == 0
|
||||
if (index == 0) {
|
||||
goToNewCount = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 监听是否需要滚动到最新消息
|
||||
LaunchedEffect(viewModel.goToNew) {
|
||||
if (viewModel.goToNew) {
|
||||
if (inBottom) {
|
||||
listState.scrollToItem(0)
|
||||
} else {
|
||||
goToNewCount++
|
||||
}
|
||||
viewModel.goToNew = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Scaffold(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
topBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.background)
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp, horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.rider_pro_back_icon),
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.noRippleClickable {
|
||||
navController.navigateUp()
|
||||
},
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(
|
||||
AppColors.text)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = viewModel.userProfile?.nickName ?: "",
|
||||
modifier = Modifier.weight(1f),
|
||||
style = TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
|
||||
)
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Box {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.rider_pro_more_horizon),
|
||||
modifier = Modifier
|
||||
.size(28.dp)
|
||||
.noRippleClickable {
|
||||
isMenuExpanded = true
|
||||
},
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(
|
||||
AppColors.text)
|
||||
)
|
||||
DropdownMenu(
|
||||
expanded = isMenuExpanded,
|
||||
onDismissRequest = {
|
||||
isMenuExpanded = false
|
||||
},
|
||||
menuItems = listOf(
|
||||
MenuItem(
|
||||
title = if (viewModel.notificationStrategy == "mute") "Unmute" else "Mute",
|
||||
icon = if (viewModel.notificationStrategy == "mute") R.drawable.rider_pro_notice_mute else R.drawable.rider_pro_notice_active,
|
||||
) {
|
||||
|
||||
isMenuExpanded = false
|
||||
viewModel.viewModelScope.launch {
|
||||
if (viewModel.notificationStrategy == "mute") {
|
||||
viewModel.updateNotificationStrategy("active")
|
||||
} else {
|
||||
viewModel.updateNotificationStrategy("mute")
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
bottomBar = {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.imePadding()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(1.dp)
|
||||
.background(
|
||||
AppColors.decentBackground)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
ChatInput(
|
||||
onSendImage = {
|
||||
it?.let {
|
||||
viewModel.sendImageMessage(it, context)
|
||||
}
|
||||
},
|
||||
) {
|
||||
viewModel.sendMessage(it, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(AppColors.decentBackground)
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
reverseLayout = true,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
val chatList = groupMessagesByTime(viewModel.getDisplayChatList(), viewModel)
|
||||
items(chatList.size, key = { index -> chatList[index].msgId }) { index ->
|
||||
val item = chatList[index]
|
||||
if (item.showTimeDivider) {
|
||||
val calendar = java.util.Calendar.getInstance()
|
||||
calendar.timeInMillis = item.timestamp
|
||||
Text(
|
||||
text = calendar.time.formatChatTime(context), // Format the timestamp
|
||||
style = TextStyle(
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
ChatItem(item = item, viewModel.myProfile?.trtcUserId!!)
|
||||
|
||||
|
||||
}
|
||||
// item {
|
||||
// Spacer(modifier = Modifier.height(72.dp))
|
||||
// }
|
||||
}
|
||||
if (goToNewCount > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(bottom = 16.dp, end = 16.dp)
|
||||
.shadow(4.dp, shape = RoundedCornerShape(16.dp))
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(AppColors.background)
|
||||
.padding(8.dp)
|
||||
.noRippleClickable {
|
||||
coroutineScope.launch {
|
||||
listState.scrollToItem(0)
|
||||
}
|
||||
},
|
||||
|
||||
) {
|
||||
Text(
|
||||
text = "${goToNewCount} New Message",
|
||||
style = TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatSelfItem(item: ChatItem) {
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = androidx.compose.ui.Alignment.End,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(
|
||||
min = 20.dp,
|
||||
max = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 250.dp else 150.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFF000000))
|
||||
.padding(
|
||||
vertical = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 8.dp else 0.dp),
|
||||
horizontal = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 16.dp else 0.dp)
|
||||
)
|
||||
.padding(bottom = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 3.dp else 0.dp))
|
||||
) {
|
||||
when (item.messageType) {
|
||||
V2TIMMessage.V2TIM_ELEM_TYPE_TEXT -> {
|
||||
Text(
|
||||
text = item.message,
|
||||
style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
),
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
|
||||
V2TIMMessage.V2TIM_ELEM_TYPE_IMAGE -> {
|
||||
CustomAsyncImage(
|
||||
imageUrl = item.imageList[1].url,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = "image"
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = "Unsupported message type",
|
||||
style = TextStyle(
|
||||
color = Color.White,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(40.dp))
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = item.avatar,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = "avatar"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatOtherItem(item: ChatItem) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(40.dp))
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = item.avatar,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = "avatar"
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(
|
||||
min = 20.dp,
|
||||
max = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 250.dp else 150.dp)
|
||||
)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(AppColors.background)
|
||||
.padding(
|
||||
vertical = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 8.dp else 0.dp),
|
||||
horizontal = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 16.dp else 0.dp)
|
||||
)
|
||||
.padding(bottom = (if (item.messageType == V2TIMMessage.V2TIM_ELEM_TYPE_TEXT) 3.dp else 0.dp))
|
||||
) {
|
||||
when (item.messageType) {
|
||||
V2TIMMessage.V2TIM_ELEM_TYPE_TEXT -> {
|
||||
Text(
|
||||
text = item.message,
|
||||
style = TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
),
|
||||
textAlign = TextAlign.Start
|
||||
)
|
||||
}
|
||||
|
||||
V2TIMMessage.V2TIM_ELEM_TYPE_IMAGE -> {
|
||||
CustomAsyncImage(
|
||||
imageUrl = item.imageList[1].url,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = "image"
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Text(
|
||||
text = "Unsupported message type",
|
||||
style = TextStyle(
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatItem(item: ChatItem, currentUserId: String) {
|
||||
val isCurrentUser = item.userId == currentUserId
|
||||
if (isCurrentUser) {
|
||||
ChatSelfItem(item)
|
||||
} else {
|
||||
ChatOtherItem(item)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ChatInput(
|
||||
onSendImage: (Uri?) -> Unit = {},
|
||||
onSend: (String) -> Unit = {},
|
||||
) {
|
||||
val navigationBarHeight = with(LocalDensity.current) {
|
||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||
}
|
||||
var keyboardController by remember { mutableStateOf<SoftwareKeyboardController?>(null) }
|
||||
var isKeyboardOpen by remember { mutableStateOf(false) }
|
||||
var text by remember { mutableStateOf("") }
|
||||
val appColors = LocalAppTheme.current
|
||||
val inputBarHeight by animateDpAsState(
|
||||
targetValue = if (isKeyboardOpen) 8.dp else (navigationBarHeight + 8.dp),
|
||||
animationSpec = tween(
|
||||
durationMillis = 300,
|
||||
easing = androidx.compose.animation.core.LinearEasing
|
||||
), label = ""
|
||||
)
|
||||
|
||||
// 在 isKeyboardOpen 变化时立即更新 inputBarHeight 的动画目标值
|
||||
LaunchedEffect(isKeyboardOpen) {
|
||||
inputBarHeight // 触发 inputBarHeight 的重组
|
||||
}
|
||||
val focusManager = LocalFocusManager.current
|
||||
val windowInsets = WindowInsets.ime
|
||||
val density = LocalDensity.current
|
||||
val softwareKeyboardController = LocalSoftwareKeyboardController.current
|
||||
val currentDensity by rememberUpdatedState(density)
|
||||
|
||||
LaunchedEffect(windowInsets.getBottom(currentDensity)) {
|
||||
if (windowInsets.getBottom(currentDensity) <= 0) {
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
}
|
||||
|
||||
val imagePickUpLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
val uri = it.data?.data
|
||||
onSendImage(uri)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = inputBarHeight)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(appColors.background)
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = {
|
||||
text = it
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
color = appColors.text,
|
||||
fontSize = 16.sp
|
||||
),
|
||||
cursorBrush = SolidColor(appColors.text),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
.onFocusChanged { focusState ->
|
||||
isKeyboardOpen = focusState.isFocused
|
||||
}
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
keyboardController = softwareKeyboardController
|
||||
awaitFirstDown().also {
|
||||
keyboardController?.show()
|
||||
}
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.rider_pro_camera),
|
||||
contentDescription = "Emoji",
|
||||
modifier = Modifier
|
||||
.size(30.dp)
|
||||
.noRippleClickable {
|
||||
imagePickUpLauncher.launch(
|
||||
Intent.createChooser(
|
||||
Intent(Intent.ACTION_GET_CONTENT).apply {
|
||||
type = "image/*"
|
||||
},
|
||||
"Select Image"
|
||||
)
|
||||
)
|
||||
},
|
||||
tint = appColors.chatActionColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Crossfade(
|
||||
targetState = text.isNotEmpty(), animationSpec = tween(500),
|
||||
label = ""
|
||||
) { isNotEmpty ->
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.rider_pro_video_share),
|
||||
contentDescription = "Emoji",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.noRippleClickable {
|
||||
if (text.isNotEmpty()) {
|
||||
onSend(text)
|
||||
text = ""
|
||||
}
|
||||
},
|
||||
tint = if (isNotEmpty) appColors.main else appColors.chatActionColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun groupMessagesByTime(chatList: List<ChatItem>, viewModel: ChatViewModel): List<ChatItem> {
|
||||
for (i in chatList.indices) { // Iterate in normal order
|
||||
if (i == 0) {
|
||||
viewModel.showTimestampMap[chatList[i].msgId] = false
|
||||
chatList[i].showTimeDivider = false
|
||||
continue
|
||||
}
|
||||
val currentMessage = chatList[i]
|
||||
val timeDiff = currentMessage.timestamp - chatList[i - 1].timestamp
|
||||
// 时间间隔大于 3 分钟,显示时间戳
|
||||
if (-timeDiff > 30 * 60 * 1000) {
|
||||
viewModel.showTimestampMap[currentMessage.msgId] = true
|
||||
currentMessage.showTimeDivider = true
|
||||
}
|
||||
}
|
||||
return chatList
|
||||
}
|
||||
263
app/src/main/java/com/aiosman/ravenow/ui/chat/ChatViewModel.kt
Normal file
263
app/src/main/java/com/aiosman/ravenow/ui/chat/ChatViewModel.kt
Normal file
@@ -0,0 +1,263 @@
|
||||
package com.aiosman.ravenow.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.MediaStore
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aiosman.ravenow.ChatState
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.UserService
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||
import com.aiosman.ravenow.entity.ChatItem
|
||||
import com.aiosman.ravenow.entity.ChatNotification
|
||||
import com.tencent.imsdk.v2.V2TIMAdvancedMsgListener
|
||||
import com.tencent.imsdk.v2.V2TIMCallback
|
||||
import com.tencent.imsdk.v2.V2TIMManager
|
||||
import com.tencent.imsdk.v2.V2TIMMessage
|
||||
import com.tencent.imsdk.v2.V2TIMSendCallback
|
||||
import com.tencent.imsdk.v2.V2TIMValueCallback
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
|
||||
class ChatViewModel(
|
||||
val userId: String,
|
||||
) : ViewModel() {
|
||||
var chatData by mutableStateOf<List<ChatItem>>(emptyList())
|
||||
var userProfile by mutableStateOf<AccountProfileEntity?>(null)
|
||||
var myProfile by mutableStateOf<AccountProfileEntity?>(null)
|
||||
val userService: UserService = UserServiceImpl()
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
var textMessageListener: V2TIMAdvancedMsgListener? = null
|
||||
var hasMore by mutableStateOf(true)
|
||||
var isLoading by mutableStateOf(false)
|
||||
var lastMessage: V2TIMMessage? = null
|
||||
val showTimestampMap = mutableMapOf<String, Boolean>() // Add this map
|
||||
var chatNotification by mutableStateOf<ChatNotification?>(null)
|
||||
var goToNew by mutableStateOf(false)
|
||||
fun init(context: Context) {
|
||||
// 获取用户信息
|
||||
viewModelScope.launch {
|
||||
val resp = userService.getUserProfile(userId)
|
||||
userProfile = resp
|
||||
myProfile = accountService.getMyAccountProfile()
|
||||
|
||||
RegistListener(context)
|
||||
fetchHistoryMessage(context)
|
||||
// 获取通知信息
|
||||
val notiStrategy = ChatState.getStrategyByTargetTrtcId(resp.trtcUserId)
|
||||
chatNotification = notiStrategy
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun RegistListener(context: Context) {
|
||||
textMessageListener = object : V2TIMAdvancedMsgListener() {
|
||||
override fun onRecvNewMessage(msg: V2TIMMessage?) {
|
||||
super.onRecvNewMessage(msg)
|
||||
msg?.let {
|
||||
val chatItem = ChatItem.convertToChatItem(msg, context)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
V2TIMManager.getMessageManager().addAdvancedMsgListener(textMessageListener);
|
||||
}
|
||||
|
||||
fun UnRegistListener() {
|
||||
V2TIMManager.getMessageManager().removeAdvancedMsgListener(textMessageListener);
|
||||
}
|
||||
|
||||
fun clearUnRead() {
|
||||
val conversationID = "c2c_${userProfile?.trtcUserId}"
|
||||
V2TIMManager.getConversationManager()
|
||||
.cleanConversationUnreadMessageCount(conversationID, 0, 0, object : V2TIMCallback {
|
||||
override fun onSuccess() {
|
||||
Log.i("imsdk", "success")
|
||||
}
|
||||
|
||||
override fun onError(code: Int, desc: String) {
|
||||
Log.i("imsdk", "failure, code:$code, desc:$desc")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
fun onLoadMore(context: Context) {
|
||||
if (!hasMore || isLoading) {
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
viewModelScope.launch {
|
||||
V2TIMManager.getMessageManager().getC2CHistoryMessageList(
|
||||
userProfile?.trtcUserId!!,
|
||||
20,
|
||||
lastMessage,
|
||||
object : V2TIMValueCallback<List<V2TIMMessage>> {
|
||||
override fun onSuccess(p0: List<V2TIMMessage>?) {
|
||||
chatData = chatData + (p0 ?: emptyList()).map {
|
||||
ChatItem.convertToChatItem(it, context)
|
||||
}.filterNotNull()
|
||||
if ((p0?.size ?: 0) < 20) {
|
||||
hasMore = false
|
||||
}
|
||||
lastMessage = p0?.lastOrNull()
|
||||
isLoading = false
|
||||
Log.d("ChatViewModel", "fetch history message success")
|
||||
}
|
||||
|
||||
override fun onError(p0: Int, p1: String?) {
|
||||
Log.e("ChatViewModel", "fetch history message error: $p1")
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendMessage(message: String, context: Context) {
|
||||
V2TIMManager.getInstance().sendC2CTextMessage(
|
||||
message,
|
||||
userProfile?.trtcUserId!!,
|
||||
object : V2TIMSendCallback<V2TIMMessage> {
|
||||
override fun onProgress(p0: Int) {
|
||||
|
||||
}
|
||||
|
||||
override fun onError(p0: Int, p1: String?) {
|
||||
Log.e("ChatViewModel", "send message error: $p1")
|
||||
}
|
||||
|
||||
override fun onSuccess(p0: V2TIMMessage?) {
|
||||
Log.d("ChatViewModel", "send message success")
|
||||
val chatItem = ChatItem.convertToChatItem(p0!!, context)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun sendImageMessage(imageUri: Uri, context: Context) {
|
||||
val tempFile = createTempFile(context, imageUri)
|
||||
val imagePath = tempFile?.path
|
||||
if (imagePath != null) {
|
||||
val v2TIMMessage = V2TIMManager.getMessageManager().createImageMessage(imagePath)
|
||||
V2TIMManager.getMessageManager().sendMessage(
|
||||
v2TIMMessage,
|
||||
userProfile?.trtcUserId!!,
|
||||
null,
|
||||
V2TIMMessage.V2TIM_PRIORITY_NORMAL,
|
||||
false,
|
||||
null,
|
||||
object : V2TIMSendCallback<V2TIMMessage> {
|
||||
override fun onProgress(p0: Int) {
|
||||
Log.d("ChatViewModel", "send image message progress: $p0")
|
||||
}
|
||||
|
||||
override fun onError(p0: Int, p1: String?) {
|
||||
Log.e("ChatViewModel", "send image message error: $p1")
|
||||
}
|
||||
|
||||
override fun onSuccess(p0: V2TIMMessage?) {
|
||||
Log.d("ChatViewModel", "send image message success")
|
||||
val chatItem = ChatItem.convertToChatItem(p0!!, context)
|
||||
chatItem?.let {
|
||||
chatData = listOf(it) + chatData
|
||||
goToNew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun createTempFile(context: Context, uri: Uri): File? {
|
||||
return try {
|
||||
val projection = arrayOf(MediaStore.Images.Media.DATA)
|
||||
val cursor = context.contentResolver.query(uri, projection, null, null, null)
|
||||
cursor?.use {
|
||||
if (it.moveToFirst()) {
|
||||
val columnIndex = it.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||
val filePath = it.getString(columnIndex)
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||
val mimeType = context.contentResolver.getType(uri)
|
||||
val extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType)
|
||||
val tempFile =
|
||||
File.createTempFile("temp_image", ".$extension", context.cacheDir)
|
||||
val outputStream = FileOutputStream(tempFile)
|
||||
|
||||
inputStream?.use { input ->
|
||||
outputStream.use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
tempFile
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchHistoryMessage(context: Context) {
|
||||
V2TIMManager.getMessageManager().getC2CHistoryMessageList(
|
||||
userProfile?.trtcUserId!!,
|
||||
20,
|
||||
null,
|
||||
object : V2TIMValueCallback<List<V2TIMMessage>> {
|
||||
override fun onSuccess(p0: List<V2TIMMessage>?) {
|
||||
chatData = (p0 ?: emptyList()).mapNotNull {
|
||||
ChatItem.convertToChatItem(it, context)
|
||||
}
|
||||
if ((p0?.size ?: 0) < 20) {
|
||||
hasMore = false
|
||||
}
|
||||
lastMessage = p0?.lastOrNull()
|
||||
Log.d("ChatViewModel", "fetch history message success")
|
||||
}
|
||||
|
||||
override fun onError(p0: Int, p1: String?) {
|
||||
Log.e("ChatViewModel", "fetch history message error: $p1")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun getDisplayChatList(): List<ChatItem> {
|
||||
val list = chatData
|
||||
// Update showTimestamp for each message
|
||||
for (item in list) {
|
||||
item.showTimestamp = showTimestampMap.getOrDefault(item.msgId, false)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
suspend fun updateNotificationStrategy(strategy: String) {
|
||||
userProfile?.let {
|
||||
val result = ChatState.updateChatNotification(it.id, strategy)
|
||||
chatNotification = result
|
||||
}
|
||||
}
|
||||
|
||||
val notificationStrategy get() = chatNotification?.strategy ?: "default"
|
||||
}
|
||||
240
app/src/main/java/com/aiosman/ravenow/ui/comment/CommentModal.kt
Normal file
240
app/src/main/java/com/aiosman/ravenow/ui/comment/CommentModal.kt
Normal file
@@ -0,0 +1,240 @@
|
||||
package com.aiosman.ravenow.ui.comment
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.CommentEntity
|
||||
import com.aiosman.ravenow.ui.composables.EditCommentBottomModal
|
||||
import com.aiosman.ravenow.ui.post.CommentContent
|
||||
import com.aiosman.ravenow.ui.post.CommentMenuModal
|
||||
import com.aiosman.ravenow.ui.post.CommentsViewModel
|
||||
import com.aiosman.ravenow.ui.post.OrderSelectionComponent
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 评论弹窗的 ViewModel
|
||||
*/
|
||||
class CommentModalViewModel(
|
||||
val postId: Int?
|
||||
) : ViewModel() {
|
||||
var commentText by mutableStateOf("")
|
||||
var commentsViewModel: CommentsViewModel = CommentsViewModel(postId.toString())
|
||||
init {
|
||||
commentsViewModel.preTransit()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 评论弹窗
|
||||
* @param postId 帖子ID
|
||||
* @param onCommentAdded 评论添加回调
|
||||
* @param onDismiss 关闭回调
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CommentModalContent(
|
||||
postId: Int? = null,
|
||||
commentCount: Int = 0,
|
||||
onCommentAdded: () -> Unit = {},
|
||||
onDismiss: () -> Unit = {}
|
||||
) {
|
||||
val model = viewModel<CommentModalViewModel>(
|
||||
key = "CommentModalViewModel_$postId",
|
||||
factory = object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return CommentModalViewModel(postId) as T
|
||||
}
|
||||
}
|
||||
)
|
||||
val commentViewModel = model.commentsViewModel
|
||||
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
LaunchedEffect(Unit) {
|
||||
|
||||
}
|
||||
var showCommentMenu by remember { mutableStateOf(false) }
|
||||
var contextComment by remember { mutableStateOf<CommentEntity?>(null) }
|
||||
val insets = WindowInsets
|
||||
val imePadding = insets.ime.getBottom(density = LocalDensity.current)
|
||||
var bottomPadding by remember { mutableStateOf(0.dp) }
|
||||
var softwareKeyboardController = LocalSoftwareKeyboardController.current
|
||||
var replyComment by remember { mutableStateOf<CommentEntity?>(null) }
|
||||
|
||||
LaunchedEffect(imePadding) {
|
||||
bottomPadding = imePadding.dp
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
if (showCommentMenu) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
showCommentMenu = false
|
||||
},
|
||||
containerColor = Color.White,
|
||||
sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
),
|
||||
dragHandle = {},
|
||||
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
|
||||
windowInsets = WindowInsets(0)
|
||||
) {
|
||||
CommentMenuModal(
|
||||
onDeleteClick = {
|
||||
showCommentMenu = false
|
||||
contextComment?.let {
|
||||
commentViewModel.deleteComment(it.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
suspend fun sendComment() {
|
||||
if (model.commentText.isNotEmpty()) {
|
||||
softwareKeyboardController?.hide()
|
||||
commentViewModel.createComment(
|
||||
model.commentText,
|
||||
)
|
||||
}
|
||||
onCommentAdded()
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, bottom = 16.dp, end = 16.dp)
|
||||
|
||||
) {
|
||||
Text(
|
||||
stringResource(R.string.comment),
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalDivider(
|
||||
color = Color(0xFFF7F7F7)
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.comment_count, commentCount),
|
||||
fontSize = 14.sp,
|
||||
color = Color(0xff666666)
|
||||
)
|
||||
OrderSelectionComponent {
|
||||
commentViewModel.order = it
|
||||
commentViewModel.reloadComment()
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.weight(1f)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
item {
|
||||
CommentContent(
|
||||
viewModel = commentViewModel,
|
||||
onLongClick = { commentEntity: CommentEntity ->
|
||||
|
||||
},
|
||||
onReply = { parentComment, _, _, _ ->
|
||||
|
||||
},
|
||||
)
|
||||
Spacer(modifier = Modifier.height(72.dp))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xfff7f7f7))
|
||||
) {
|
||||
EditCommentBottomModal(replyComment) {
|
||||
commentViewModel.viewModelScope.launch {
|
||||
if (replyComment != null) {
|
||||
if (replyComment?.parentCommentId != null) {
|
||||
// 第三级评论
|
||||
commentViewModel.createComment(
|
||||
it,
|
||||
parentCommentId = replyComment?.parentCommentId,
|
||||
replyUserId = replyComment?.author?.toInt()
|
||||
)
|
||||
} else {
|
||||
// 子级评论
|
||||
commentViewModel.createComment(it, replyComment?.id)
|
||||
}
|
||||
} else {
|
||||
// 顶级评论
|
||||
commentViewModel.createComment(it)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Spacer(modifier = Modifier.height(navBarHeight))
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.aiosman.ravenow.ui.comment
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun CommentsScreen() {
|
||||
StatusBarMaskLayout(
|
||||
darkIcons = true,
|
||||
maskBoxBackgroundColor = Color(0xFFFFFFFF)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(color = Color(0xFFFFFFFF))
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
NoticeScreenHeader("COMMENTS")
|
||||
Spacer(modifier = Modifier.height(28.dp))
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
item {
|
||||
repeat(20) {
|
||||
CommentsItem()
|
||||
}
|
||||
BottomNavigationPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NoticeScreenHeader(
|
||||
title:String,
|
||||
moreIcon: Boolean = true,
|
||||
rightIcon: @Composable (() -> Unit)? = null
|
||||
) {
|
||||
val nav = LocalNavController.current
|
||||
val AppColors = LocalAppTheme.current
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_back_icon,),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(16.dp).clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
nav.navigateUp()
|
||||
},
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Text(title, fontWeight = FontWeight.W800, fontSize = 17.sp, color = AppColors.text)
|
||||
if (moreIcon) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_more_horizon),
|
||||
contentDescription = "More",
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
if (rightIcon != null) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
rightIcon()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CommentsItem() {
|
||||
Box(
|
||||
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.default_avatar),
|
||||
contentDescription = "Avatar",
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text("Username", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text("Content", color = Color(0x99000000), fontSize = 12.sp)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
Text("Date", color = Color(0x99000000), fontSize = 12.sp)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_like),
|
||||
contentDescription = "Like",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Text(
|
||||
"270",
|
||||
color = Color(0x99000000),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(45.dp))
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_comments),
|
||||
contentDescription = "Comments",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Text(
|
||||
"270",
|
||||
color = Color(0x99000000),
|
||||
fontSize = 12.sp,
|
||||
modifier = Modifier.padding(start = 4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Box {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.default_moment_img),
|
||||
contentDescription = "More",
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package com.aiosman.ravenow.ui.comment.notice
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import androidx.paging.LoadState
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.CommentEntity
|
||||
import com.aiosman.ravenow.exp.timeAgo
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.ui.navigateToPost
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun CommentNoticeScreen() {
|
||||
val viewModel = viewModel<CommentNoticeListViewModel>(
|
||||
key = "CommentNotice",
|
||||
factory = object : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): T {
|
||||
return CommentNoticeListViewModel() as T
|
||||
}
|
||||
}
|
||||
)
|
||||
val context = LocalContext.current
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.initData(context)
|
||||
}
|
||||
var dataFlow = viewModel.commentItemsFlow
|
||||
var comments = dataFlow.collectAsLazyPagingItems()
|
||||
val navController = LocalNavController.current
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().background(color = AppColors.background)
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
NoticeScreenHeader(stringResource(R.string.comment), moreIcon = false)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize().padding(horizontal = 16.dp)
|
||||
) {
|
||||
items(comments.itemCount) { index ->
|
||||
comments[index]?.let { comment ->
|
||||
CommentNoticeItem(comment) {
|
||||
viewModel.updateReadStatus(comment.id)
|
||||
viewModel.viewModelScope.launch {
|
||||
var highlightCommentId = comment.id
|
||||
comment.parentCommentId?.let {
|
||||
highlightCommentId = it
|
||||
}
|
||||
navController.navigateToPost(
|
||||
id = comment.post!!.id,
|
||||
highlightCommentId = highlightCommentId,
|
||||
initImagePagerIndex = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// handle load error
|
||||
when {
|
||||
comments.loadState.append is LoadState.Loading -> {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.width(160.dp),
|
||||
color = AppColors.main
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comments.loadState.append is LoadState.Error -> {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.noRippleClickable {
|
||||
comments.retry()
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Load comment error, click to retry",
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(72.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CommentNoticeItem(
|
||||
commentItem: CommentEntity,
|
||||
onPostClick: () -> Unit = {},
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
Row(
|
||||
modifier = Modifier.padding(vertical = 20.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Box {
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = commentItem.avatar,
|
||||
contentDescription = commentItem.name,
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.noRippleClickable {
|
||||
navController.navigate(
|
||||
NavigationRoute.AccountProfile.route.replace(
|
||||
"{id}",
|
||||
commentItem.author.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp)
|
||||
.noRippleClickable {
|
||||
onPostClick()
|
||||
}
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(
|
||||
text = commentItem.name,
|
||||
fontSize = 18.sp,
|
||||
modifier = Modifier,
|
||||
color = AppColors.text
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row {
|
||||
var text = commentItem.comment
|
||||
if (commentItem.parentCommentId != null) {
|
||||
text = "Reply you: $text"
|
||||
}
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 1,
|
||||
color = AppColors.secondaryText,
|
||||
modifier = Modifier.weight(1f),
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text(
|
||||
text = commentItem.date.timeAgo(context),
|
||||
fontSize = 14.sp,
|
||||
color = AppColors.secondaryText,
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
commentItem.post?.let {
|
||||
Box {
|
||||
Box(
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = it.images[0].thumbnail,
|
||||
contentDescription = "Post Image",
|
||||
modifier = Modifier
|
||||
.size(48.dp).clip(RoundedCornerShape(8.dp))
|
||||
)
|
||||
// unread indicator
|
||||
|
||||
}
|
||||
|
||||
if (commentItem.unread) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(AppColors.main, CircleShape)
|
||||
.size(12.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.aiosman.ravenow.ui.comment.notice
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.CommentRemoteDataSource
|
||||
import com.aiosman.ravenow.data.CommentService
|
||||
import com.aiosman.ravenow.data.CommentServiceImpl
|
||||
import com.aiosman.ravenow.data.UserService
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.entity.CommentEntity
|
||||
import com.aiosman.ravenow.entity.CommentPagingSource
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CommentNoticeListViewModel : ViewModel() {
|
||||
val accountService: AccountService = AccountServiceImpl()
|
||||
val userService: UserService = UserServiceImpl()
|
||||
private val commentService: CommentService = CommentServiceImpl()
|
||||
private val _commentItemsFlow = MutableStateFlow<PagingData<CommentEntity>>(PagingData.empty())
|
||||
val commentItemsFlow = _commentItemsFlow.asStateFlow()
|
||||
var isLoading by mutableStateOf(false)
|
||||
var isFirstLoad = true
|
||||
fun initData(context: Context, force: Boolean = false) {
|
||||
if (!isFirstLoad && !force) {
|
||||
return
|
||||
}
|
||||
if (force) {
|
||||
isLoading = true
|
||||
}
|
||||
isFirstLoad = false
|
||||
viewModelScope.launch {
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
|
||||
pagingSourceFactory = {
|
||||
CommentPagingSource(
|
||||
CommentRemoteDataSource(commentService),
|
||||
selfNotice = true,
|
||||
order = "latest"
|
||||
)
|
||||
}
|
||||
).flow.cachedIn(viewModelScope).collectLatest {
|
||||
_commentItemsFlow.value = it
|
||||
}
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
|
||||
}
|
||||
|
||||
private fun updateIsRead(id: Int) {
|
||||
val currentPagingData = _commentItemsFlow.value
|
||||
val updatedPagingData = currentPagingData.map { commentEntity ->
|
||||
if (commentEntity.id == id) {
|
||||
commentEntity.copy(unread = false)
|
||||
} else {
|
||||
commentEntity
|
||||
}
|
||||
}
|
||||
_commentItemsFlow.value = updatedPagingData
|
||||
}
|
||||
|
||||
fun updateReadStatus(id: Int) {
|
||||
viewModelScope.launch {
|
||||
commentService.updateReadStatus(id)
|
||||
updateIsRead(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
//import androidx.compose.foundation.layout.ColumnScopeInstance.weight
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
@Composable
|
||||
fun ActionButton(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
color: Color? = null,
|
||||
backgroundColor: Color? = null,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
expandText: Boolean = false,
|
||||
contentPadding: PaddingValues = PaddingValues(vertical = 16.dp),
|
||||
isLoading: Boolean = false,
|
||||
loadingTextColor: Color? = null,
|
||||
loadingText: String = "Loading",
|
||||
loadingBackgroundColor: Color? = null,
|
||||
disabledBackgroundColor: Color? = null,
|
||||
enabled: Boolean = true,
|
||||
fullWidth: Boolean = false,
|
||||
roundCorner: Float = 24f,
|
||||
fontSize: TextUnit = 17.sp,
|
||||
fontWeight: FontWeight = FontWeight.W900,
|
||||
click: () -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val animatedBackgroundColor by animateColorAsState(
|
||||
targetValue = run {
|
||||
if (enabled) {
|
||||
if (isLoading) {
|
||||
loadingBackgroundColor ?: AppColors.loadingMain
|
||||
} else {
|
||||
backgroundColor ?: AppColors.basicMain
|
||||
}
|
||||
} else {
|
||||
disabledBackgroundColor ?: AppColors.disabledBackground
|
||||
}
|
||||
},
|
||||
animationSpec = tween(300), label = ""
|
||||
)
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(roundCorner.dp))
|
||||
.background(animatedBackgroundColor)
|
||||
.noRippleClickable {
|
||||
if (enabled && !isLoading) {
|
||||
click()
|
||||
}
|
||||
}
|
||||
.padding(contentPadding),
|
||||
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
if (!isLoading) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.let {
|
||||
if (fullWidth) {
|
||||
it.fillMaxWidth()
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
Box(modifier = Modifier.align(Alignment.CenterStart)) {
|
||||
leading?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text,
|
||||
fontSize = fontSize,
|
||||
color = color ?: AppColors.text,
|
||||
fontWeight = fontWeight,
|
||||
textAlign = if (expandText) TextAlign.Center else TextAlign.Start
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.let {
|
||||
if (fullWidth) {
|
||||
it.fillMaxWidth()
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.padding(horizontal = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
color = AppColors.text
|
||||
)
|
||||
Text(
|
||||
loadingText,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
textAlign = TextAlign.Center,
|
||||
color = loadingTextColor ?: AppColors.loadingText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
|
||||
@Composable
|
||||
fun AnimatedCounter(count: Int, modifier: Modifier = Modifier, fontSize: Int = 24) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
AnimatedContent(
|
||||
targetState = count,
|
||||
transitionSpec = {
|
||||
// Compare the incoming number with the previous number.
|
||||
if (targetState > initialState) {
|
||||
// If the target number is larger, it slides up and fades in
|
||||
// while the initial (smaller) number slides up and fades out.
|
||||
(slideInVertically { height -> height } + fadeIn()).togetherWith(slideOutVertically { height -> -height } + fadeOut())
|
||||
} else {
|
||||
// If the target number is smaller, it slides down and fades in
|
||||
// while the initial number slides down and fades out.
|
||||
(slideInVertically { height -> -height } + fadeIn()).togetherWith(slideOutVertically { height -> height } + fadeOut())
|
||||
}.using(
|
||||
// Disable clipping since the faded slide-in/out should
|
||||
// be displayed out of bounds.
|
||||
SizeTransform(clip = false)
|
||||
)
|
||||
}
|
||||
) { targetCount ->
|
||||
Text(text = "$targetCount", modifier = modifier, fontSize = fontSize.sp, color = AppColors.text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AnimatedFavouriteIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
isFavourite: Boolean = false,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val animatableRotation = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
suspend fun shake() {
|
||||
repeat(2) {
|
||||
animatableRotation.animateTo(
|
||||
targetValue = 10f,
|
||||
animationSpec = tween(100)
|
||||
) {
|
||||
|
||||
}
|
||||
animatableRotation.animateTo(
|
||||
targetValue = -10f,
|
||||
animationSpec = tween(100)
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
animatableRotation.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(100)
|
||||
)
|
||||
}
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.noRippleClickable {
|
||||
onClick?.invoke()
|
||||
// Trigger shake animation
|
||||
scope.launch {
|
||||
shake()
|
||||
}
|
||||
}) {
|
||||
Image(
|
||||
painter = if (isFavourite) {
|
||||
painterResource(id = R.drawable.rider_pro_favourited)
|
||||
} else {
|
||||
painterResource(id = R.drawable.rider_pro_favourite)
|
||||
},
|
||||
contentDescription = "Favourite",
|
||||
modifier = modifier.graphicsLayer {
|
||||
rotationZ = animatableRotation.value
|
||||
},
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Composable
|
||||
fun AnimatedLikeIcon(
|
||||
modifier: Modifier = Modifier,
|
||||
liked: Boolean = false,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
val animatableRotation = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
suspend fun shake() {
|
||||
repeat(2) {
|
||||
animatableRotation.animateTo(
|
||||
targetValue = 10f,
|
||||
animationSpec = tween(100)
|
||||
) {
|
||||
|
||||
}
|
||||
animatableRotation.animateTo(
|
||||
targetValue = -10f,
|
||||
animationSpec = tween(100)
|
||||
) {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
animatableRotation.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(100)
|
||||
)
|
||||
}
|
||||
Box(contentAlignment = Alignment.Center, modifier = Modifier.noRippleClickable {
|
||||
onClick?.invoke()
|
||||
// Trigger shake animation
|
||||
scope.launch {
|
||||
shake()
|
||||
}
|
||||
}) {
|
||||
Image(
|
||||
painter = if (!liked) painterResource(id = R.drawable.rider_pro_moment_like) else painterResource(
|
||||
id = R.drawable.rider_pro_moment_liked
|
||||
),
|
||||
contentDescription = "Like",
|
||||
modifier = modifier.graphicsLayer {
|
||||
rotationZ = animatableRotation.value
|
||||
},
|
||||
colorFilter = if (!liked) ColorFilter.tint(AppColors.text) else null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.graphics.drawable.toDrawable
|
||||
import coil.annotation.ExperimentalCoilApi
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.aiosman.ravenow.utils.BlurHashDecoder
|
||||
import com.aiosman.ravenow.utils.Utils.getImageLoader
|
||||
|
||||
const val DEFAULT_HASHED_BITMAP_WIDTH = 4
|
||||
const val DEFAULT_HASHED_BITMAP_HEIGHT = 3
|
||||
|
||||
/**
|
||||
* This function is used to load an image asynchronously and blur it using BlurHash.
|
||||
* @param imageUrl The URL of the image to be loaded.
|
||||
* @param modifier The modifier to be applied to the image.
|
||||
* @param imageModifier The modifier to be applied to the image.
|
||||
* @param contentDescription The content description to be applied to the image.
|
||||
* @param contentScale The content scale to be applied to the image.
|
||||
* @param isCrossFadeRequired Whether cross-fade is required or not.
|
||||
* @param onImageLoadSuccess The callback to be called when the image is loaded successfully.
|
||||
* @param onImageLoadFailure The callback to be called when the image is failed to load.
|
||||
* @see AsyncImage
|
||||
*/
|
||||
@Suppress("LongParameterList")
|
||||
@ExperimentalCoilApi
|
||||
@Composable
|
||||
fun AsyncBlurImage(
|
||||
imageUrl: String,
|
||||
blurHash: String,
|
||||
modifier: Modifier = Modifier,
|
||||
imageModifier: Modifier? = null,
|
||||
contentDescription: String? = null,
|
||||
contentScale: ContentScale = ContentScale.Fit,
|
||||
isCrossFadeRequired: Boolean = false,
|
||||
onImageLoadSuccess: () -> Unit = {},
|
||||
onImageLoadFailure: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val resources = context.resources
|
||||
val imageLoader = getImageLoader(context)
|
||||
|
||||
val blurBitmap by remember(blurHash) {
|
||||
mutableStateOf(
|
||||
BlurHashDecoder.decode(
|
||||
blurHash = blurHash,
|
||||
width = DEFAULT_HASHED_BITMAP_WIDTH,
|
||||
height = DEFAULT_HASHED_BITMAP_HEIGHT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
AsyncImage(
|
||||
modifier = imageModifier ?: modifier,
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(imageUrl)
|
||||
.crossfade(isCrossFadeRequired)
|
||||
.placeholder(
|
||||
blurBitmap?.toDrawable(resources)
|
||||
)
|
||||
.fallback(blurBitmap?.toDrawable(resources))
|
||||
.build(),
|
||||
contentDescription = contentDescription,
|
||||
contentScale = contentScale,
|
||||
onSuccess = { onImageLoadSuccess() },
|
||||
onError = { onImageLoadFailure() },
|
||||
imageLoader = imageLoader
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
@Composable
|
||||
fun BottomNavigationPlaceholder(
|
||||
color: Color? = null
|
||||
) {
|
||||
val navigationBarHeight = with(LocalDensity.current) {
|
||||
WindowInsets.navigationBars.getBottom(this).toDp()
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.height(navigationBarHeight).fillMaxWidth().background(color ?: Color.Transparent)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
|
||||
@Composable
|
||||
fun Checkbox(
|
||||
size: Int = 24,
|
||||
checked: Boolean = false,
|
||||
onCheckedChange: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val backgroundColor by animateColorAsState(if (checked) AppColors.checkedBackground else Color.Transparent)
|
||||
val borderColor by animateColorAsState(if (checked) Color.Transparent else AppColors.secondaryText)
|
||||
val borderWidth by animateDpAsState(if (checked) 0.dp else 2.dp)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size.dp)
|
||||
.noRippleClickable {
|
||||
onCheckedChange(!checked)
|
||||
}
|
||||
.clip(CircleShape)
|
||||
.background(color = backgroundColor)
|
||||
.border(width = borderWidth, color = borderColor, shape = CircleShape)
|
||||
.padding(2.dp)
|
||||
) {
|
||||
if (checked) {
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = "Checked",
|
||||
tint = AppColors.checkedText
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
|
||||
@Composable
|
||||
fun CheckboxWithLabel(
|
||||
checked: Boolean = false,
|
||||
checkSize: Int = 16,
|
||||
label: String = "",
|
||||
fontSize: Int = 12,
|
||||
error: Boolean = false,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Row(
|
||||
) {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = {
|
||||
onCheckedChange(it)
|
||||
},
|
||||
size = checkSize
|
||||
)
|
||||
Text(
|
||||
text = label,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
fontSize = fontSize.sp,
|
||||
style = TextStyle(
|
||||
color = if (error) AppColors.error else AppColors.text
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import android.graphics.BitmapFactory
|
||||
import android.util.Base64
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.material.AlertDialog
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.input.pointer.pointerInteropFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.data.api.CaptchaResponseBody
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
fun ClickCaptchaView(
|
||||
captchaData: CaptchaResponseBody,
|
||||
onPositionClicked: (Offset) -> Unit
|
||||
) {
|
||||
var clickPositions by remember { mutableStateOf(listOf<Offset>()) }
|
||||
|
||||
val context = LocalContext.current
|
||||
val imageBitmap = remember(captchaData.masterBase64) {
|
||||
val decodedString = Base64.decode(captchaData.masterBase64, Base64.DEFAULT)
|
||||
val inputStream = ByteArrayInputStream(decodedString)
|
||||
BitmapFactory.decodeStream(inputStream).asImageBitmap()
|
||||
}
|
||||
val thumbnailBitmap = remember(captchaData.thumbBase64) {
|
||||
val decodedString = Base64.decode(captchaData.thumbBase64, Base64.DEFAULT)
|
||||
val inputStream = ByteArrayInputStream(decodedString)
|
||||
BitmapFactory.decodeStream(inputStream).asImageBitmap()
|
||||
}
|
||||
var boxWidth by remember { mutableStateOf(0) }
|
||||
var boxHeightInDp by remember { mutableStateOf(0.dp) }
|
||||
var scale by remember { mutableStateOf(1f) }
|
||||
val density = LocalDensity.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
|
||||
Text(stringResource(R.string.captcha_hint))
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Image(
|
||||
bitmap = thumbnailBitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onGloballyPositioned {
|
||||
boxWidth = it.size.width
|
||||
scale = imageBitmap.width.toFloat() / boxWidth
|
||||
boxHeightInDp = with(density) { (imageBitmap.height.toFloat() / scale).toDp() }
|
||||
}
|
||||
.background(Color.Gray)
|
||||
) {
|
||||
if (boxWidth != 0 && boxHeightInDp != 0.dp) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(boxHeightInDp)
|
||||
) {
|
||||
Image(
|
||||
bitmap = imageBitmap,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.FillWidth,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.pointerInteropFilter { event ->
|
||||
if (event.action == android.view.MotionEvent.ACTION_DOWN) {
|
||||
val newPosition = Offset(event.x, event.y)
|
||||
clickPositions = clickPositions + newPosition
|
||||
// 计算出点击的位置在图片上的坐标
|
||||
val imagePosition = Offset(
|
||||
newPosition.x * scale,
|
||||
newPosition.y * scale
|
||||
)
|
||||
onPositionClicked(imagePosition)
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Draw markers at click positions
|
||||
clickPositions.forEachIndexed { index, position ->
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawCircle(
|
||||
color = Color(0xaada3832).copy(),
|
||||
radius = 40f,
|
||||
center = position
|
||||
)
|
||||
drawContext.canvas.nativeCanvas.apply {
|
||||
drawText(
|
||||
(index + 1).toString(),
|
||||
position.x,
|
||||
position.y + 15f, // Adjusting the y position to center the text
|
||||
android.graphics.Paint().apply {
|
||||
color = android.graphics.Color.WHITE
|
||||
textSize = 50f
|
||||
textAlign = android.graphics.Paint.Align.CENTER
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ClickCaptchaDialog(
|
||||
captchaData: CaptchaResponseBody,
|
||||
onLoadCaptcha: () -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
onPositionClicked: (Offset) -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = {
|
||||
onDismissRequest()
|
||||
},
|
||||
title = {
|
||||
Text(stringResource(R.string.captcha))
|
||||
},
|
||||
text = {
|
||||
Column {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
ClickCaptchaView(
|
||||
captchaData = captchaData,
|
||||
onPositionClicked = onPositionClicked
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
ActionButton(
|
||||
text = stringResource(R.string.refresh),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
) {
|
||||
onLoadCaptcha()
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.text.BasicText
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
|
||||
@Composable
|
||||
fun CustomClickableText(
|
||||
text: AnnotatedString,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = TextStyle.Default,
|
||||
softWrap: Boolean = true,
|
||||
overflow: TextOverflow = TextOverflow.Clip,
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
onTextLayout: (TextLayoutResult) -> Unit = {},
|
||||
onLongPress: () -> Unit = {},
|
||||
onClick: (Int) -> Unit
|
||||
) {
|
||||
val layoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
|
||||
val pressIndicator = Modifier.pointerInput(onClick) {
|
||||
detectTapGestures(
|
||||
onLongPress = { onLongPress() }
|
||||
) { pos ->
|
||||
layoutResult.value?.let { layoutResult ->
|
||||
onClick(layoutResult.getOffsetForPosition(pos))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BasicText(
|
||||
text = text,
|
||||
modifier = modifier.then(pressIndicator),
|
||||
style = style,
|
||||
softWrap = softWrap,
|
||||
overflow = overflow,
|
||||
maxLines = maxLines,
|
||||
onTextLayout = {
|
||||
layoutResult.value = it
|
||||
onTextLayout(it)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.VectorConverter
|
||||
import androidx.compose.animation.core.VisibilityThreshold
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.scrollBy
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridItemInfo
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.toOffset
|
||||
import androidx.compose.ui.unit.toSize
|
||||
import androidx.compose.ui.zIndex
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun <T : Any> DraggableGrid(
|
||||
items: List<T>,
|
||||
getItemId: (T) -> String,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onDragModeStart: () -> Unit, // New parameter for drag start
|
||||
onDragModeEnd: () -> Unit, // New parameter for drag end,
|
||||
additionalItems: List<@Composable () -> Unit> = emptyList(), // New parameter for additional items
|
||||
lockedIndices: List<Int> = emptyList(), // New parameter for locked indices
|
||||
content: @Composable (T, Boolean) -> Unit,
|
||||
) {
|
||||
|
||||
val gridState = rememberLazyGridState()
|
||||
val dragDropState =
|
||||
rememberGridDragDropState(gridState, onMove, onDragModeStart, onDragModeEnd, lockedIndices)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.dragContainer(dragDropState),
|
||||
state = gridState,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
|
||||
) {
|
||||
itemsIndexed(items, key = { _, item ->
|
||||
getItemId(item)
|
||||
}) { index, item ->
|
||||
DraggableItem(dragDropState, index) { isDragging ->
|
||||
content(item, isDragging)
|
||||
}
|
||||
}
|
||||
additionalItems.forEach { additionalItem ->
|
||||
item {
|
||||
additionalItem()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.dragContainer(dragDropState: GridDragDropState): Modifier {
|
||||
return pointerInput(dragDropState) {
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDrag = { change, offset ->
|
||||
change.consume()
|
||||
dragDropState.onDrag(offset = offset)
|
||||
},
|
||||
onDragStart = { offset -> dragDropState.onDragStart(offset) },
|
||||
onDragEnd = { dragDropState.onDragInterrupted() },
|
||||
onDragCancel = { dragDropState.onDragInterrupted() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalFoundationApi
|
||||
@Composable
|
||||
fun LazyGridItemScope.DraggableItem(
|
||||
dragDropState: GridDragDropState,
|
||||
index: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (isDragging: Boolean) -> Unit,
|
||||
) {
|
||||
val dragging = index == dragDropState.draggingItemIndex
|
||||
val draggingModifier = if (dragging) {
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.graphicsLayer {
|
||||
translationX = dragDropState.draggingItemOffset.x
|
||||
translationY = dragDropState.draggingItemOffset.y
|
||||
}
|
||||
} else if (index == dragDropState.previousIndexOfDraggedItem) {
|
||||
Modifier
|
||||
.zIndex(1f)
|
||||
.graphicsLayer {
|
||||
translationX = dragDropState.previousItemOffset.value.x
|
||||
translationY = dragDropState.previousItemOffset.value.y
|
||||
}
|
||||
} else {
|
||||
Modifier.animateItemPlacement()
|
||||
}
|
||||
Box(modifier = modifier.then(draggingModifier), propagateMinConstraints = true) {
|
||||
content(dragging)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun rememberGridDragDropState(
|
||||
gridState: LazyGridState,
|
||||
onMove: (Int, Int) -> Unit,
|
||||
onDragModeStart: () -> Unit,
|
||||
onDragModeEnd: () -> Unit,
|
||||
lockedIndices: List<Int> // New parameter for locked indices
|
||||
): GridDragDropState {
|
||||
val scope = rememberCoroutineScope()
|
||||
val state = remember(gridState) {
|
||||
GridDragDropState(
|
||||
state = gridState,
|
||||
onMove = onMove,
|
||||
scope = scope,
|
||||
onDragModeStart = onDragModeStart,
|
||||
onDragModeEnd = onDragModeEnd,
|
||||
lockedIndices = lockedIndices // Pass the locked indices
|
||||
)
|
||||
}
|
||||
LaunchedEffect(state) {
|
||||
while (true) {
|
||||
val diff = state.scrollChannel.receive()
|
||||
gridState.scrollBy(diff)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
class GridDragDropState internal constructor(
|
||||
private val state: LazyGridState,
|
||||
private val scope: CoroutineScope,
|
||||
private val onMove: (Int, Int) -> Unit,
|
||||
private val onDragModeStart: () -> Unit,
|
||||
private val onDragModeEnd: () -> Unit,
|
||||
private val lockedIndices: List<Int> // New parameter for locked indices
|
||||
) {
|
||||
var draggingItemIndex by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
|
||||
internal val scrollChannel = Channel<Float>()
|
||||
|
||||
private var draggingItemDraggedDelta by mutableStateOf(Offset.Zero)
|
||||
private var draggingItemInitialOffset by mutableStateOf(Offset.Zero)
|
||||
internal val draggingItemOffset: Offset
|
||||
get() = draggingItemLayoutInfo?.let { item ->
|
||||
draggingItemInitialOffset + draggingItemDraggedDelta - item.offset.toOffset()
|
||||
} ?: Offset.Zero
|
||||
|
||||
private val draggingItemLayoutInfo: LazyGridItemInfo?
|
||||
get() = state.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { it.index == draggingItemIndex }
|
||||
|
||||
internal var previousIndexOfDraggedItem by mutableStateOf<Int?>(null)
|
||||
private set
|
||||
internal var previousItemOffset = Animatable(Offset.Zero, Offset.VectorConverter)
|
||||
private set
|
||||
|
||||
internal fun onDragStart(offset: Offset) {
|
||||
state.layoutInfo.visibleItemsInfo
|
||||
.firstOrNull { item ->
|
||||
offset.x.toInt() in item.offset.x..item.offsetEnd.x &&
|
||||
offset.y.toInt() in item.offset.y..item.offsetEnd.y
|
||||
}?.also {
|
||||
if (it.index !in lockedIndices) { // Check if the item is not locked
|
||||
draggingItemIndex = it.index
|
||||
draggingItemInitialOffset = it.offset.toOffset()
|
||||
onDragModeStart() // Notify drag start
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onDragInterrupted() {
|
||||
if (draggingItemIndex != null) {
|
||||
previousIndexOfDraggedItem = draggingItemIndex
|
||||
val startOffset = draggingItemOffset
|
||||
scope.launch {
|
||||
previousItemOffset.snapTo(startOffset)
|
||||
previousItemOffset.animateTo(
|
||||
Offset.Zero,
|
||||
spring(
|
||||
stiffness = Spring.StiffnessMediumLow,
|
||||
visibilityThreshold = Offset.VisibilityThreshold
|
||||
)
|
||||
)
|
||||
previousIndexOfDraggedItem = null
|
||||
}
|
||||
}
|
||||
draggingItemDraggedDelta = Offset.Zero
|
||||
draggingItemIndex = null
|
||||
draggingItemInitialOffset = Offset.Zero
|
||||
onDragModeEnd() // Notify drag end
|
||||
}
|
||||
|
||||
internal fun onDrag(offset: Offset) {
|
||||
draggingItemDraggedDelta += offset
|
||||
|
||||
val draggingItem = draggingItemLayoutInfo ?: return
|
||||
val startOffset = draggingItem.offset.toOffset() + draggingItemOffset
|
||||
val endOffset = startOffset + draggingItem.size.toSize()
|
||||
val middleOffset = startOffset + (endOffset - startOffset) / 2f
|
||||
|
||||
val targetItem = state.layoutInfo.visibleItemsInfo.find { item ->
|
||||
middleOffset.x.toInt() in item.offset.x..item.offsetEnd.x &&
|
||||
middleOffset.y.toInt() in item.offset.y..item.offsetEnd.y &&
|
||||
draggingItem.index != item.index &&
|
||||
item.index !in lockedIndices // Check if the target item is not locked
|
||||
}
|
||||
if (targetItem != null) {
|
||||
val scrollToIndex = if (targetItem.index == state.firstVisibleItemIndex) {
|
||||
draggingItem.index
|
||||
} else if (draggingItem.index == state.firstVisibleItemIndex) {
|
||||
targetItem.index
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (scrollToIndex != null) {
|
||||
scope.launch {
|
||||
state.scrollToItem(scrollToIndex, state.firstVisibleItemScrollOffset)
|
||||
onMove.invoke(draggingItem.index, targetItem.index)
|
||||
}
|
||||
} else {
|
||||
onMove.invoke(draggingItem.index, targetItem.index)
|
||||
}
|
||||
draggingItemIndex = targetItem.index
|
||||
} else {
|
||||
val overscroll = when {
|
||||
draggingItemDraggedDelta.y > 0 ->
|
||||
(endOffset.y - state.layoutInfo.viewportEndOffset).coerceAtLeast(0f)
|
||||
|
||||
draggingItemDraggedDelta.y < 0 ->
|
||||
(startOffset.y - state.layoutInfo.viewportStartOffset).coerceAtMost(0f)
|
||||
|
||||
else -> 0f
|
||||
}
|
||||
if (overscroll != 0f) {
|
||||
scrollChannel.trySend(overscroll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val LazyGridItemInfo.offsetEnd: IntOffset
|
||||
get() = this.offset + this.size
|
||||
}
|
||||
|
||||
|
||||
operator fun IntOffset.plus(size: IntSize): IntOffset {
|
||||
return IntOffset(x + size.width, y + size.height)
|
||||
}
|
||||
|
||||
operator fun Offset.plus(size: Size): Offset {
|
||||
return Offset(x + size.width, y + size.height)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
data class MenuItem(
|
||||
val title: String,
|
||||
val icon: Int,
|
||||
val action: () -> Unit
|
||||
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun DropdownMenu(
|
||||
expanded: Boolean = false,
|
||||
menuItems: List<MenuItem> = emptyList(),
|
||||
width: Int? = null,
|
||||
onDismissRequest: () -> Unit = {},
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
|
||||
MaterialTheme(
|
||||
shapes = MaterialTheme.shapes.copy(
|
||||
extraSmall = RoundedCornerShape(
|
||||
16.dp
|
||||
)
|
||||
)
|
||||
) {
|
||||
androidx.compose.material3.DropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier = Modifier
|
||||
.let {
|
||||
if (width != null) it.width(width.dp) else it
|
||||
}
|
||||
.background(AppColors.background)
|
||||
) {
|
||||
for (item in menuItems) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 14.dp, horizontal = 24.dp)
|
||||
.noRippleClickable {
|
||||
item.action()
|
||||
}) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
item.title,
|
||||
fontWeight = FontWeight.W500,
|
||||
color = AppColors.text,
|
||||
)
|
||||
if (width != null) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
} else {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
Icon(
|
||||
painter = painterResource(id = item.icon),
|
||||
contentDescription = "",
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = AppColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.CommentEntity
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
@Composable
|
||||
fun EditCommentBottomModal(
|
||||
replyComment: CommentEntity? = null,
|
||||
onSend: (String) -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
var text by remember { mutableStateOf("") }
|
||||
var navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.background)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
if (replyComment == null) "Comment" else "Reply",
|
||||
fontWeight = FontWeight.W600,
|
||||
modifier = Modifier.weight(1f),
|
||||
fontSize = 20.sp,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = AppColors.text
|
||||
)
|
||||
Crossfade(
|
||||
targetState = text.isNotEmpty(), animationSpec = tween(500),
|
||||
label = ""
|
||||
) { isNotEmpty ->
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.rider_pro_video_share),
|
||||
contentDescription = "Emoji",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.noRippleClickable {
|
||||
if (text.isNotEmpty()) {
|
||||
onSend(text)
|
||||
text = ""
|
||||
}
|
||||
|
||||
},
|
||||
tint = if (isNotEmpty) AppColors.main else AppColors.nonActive
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
if (replyComment != null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
replyComment.avatar,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentDescription = "Avatar",
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
replyComment.name,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 16.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
replyComment.comment,
|
||||
maxLines = 1,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 32.dp),
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = AppColors.text
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(AppColors.inputBackground)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = {
|
||||
text = it
|
||||
},
|
||||
cursorBrush = SolidColor(AppColors.text),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.focusRequester(focusRequester),
|
||||
textStyle = TextStyle(
|
||||
color = AppColors.text,
|
||||
fontWeight = FontWeight.Normal
|
||||
),
|
||||
minLines = 5
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(navBarHeight))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
@Composable
|
||||
fun FollowButton(
|
||||
isFollowing: Boolean,
|
||||
fontSize: TextUnit = 12.sp,
|
||||
onFollowClick: () -> Unit,
|
||||
){
|
||||
val AppColors = LocalAppTheme.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
color = if (isFollowing) AppColors.main else AppColors.nonActive
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
.noRippleClickable {
|
||||
onFollowClick()
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = if (isFollowing) stringResource(R.string.following_upper) else stringResource(
|
||||
R.string.follow_upper
|
||||
),
|
||||
fontSize = fontSize,
|
||||
color = if (isFollowing) AppColors.mainText else AppColors.nonActiveText,
|
||||
style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import coil.ImageLoader
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import coil.request.SuccessResult
|
||||
import com.aiosman.ravenow.utils.Utils.getImageLoader
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Composable
|
||||
fun rememberImageBitmap(imageUrl: String, imageLoader: ImageLoader): Bitmap? {
|
||||
val context = LocalContext.current
|
||||
var bitmap by remember { mutableStateOf<Bitmap?>(null) }
|
||||
|
||||
LaunchedEffect(imageUrl) {
|
||||
val request = ImageRequest.Builder(context)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build()
|
||||
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
(imageLoader.execute(request) as? SuccessResult)?.drawable?.toBitmap()
|
||||
}
|
||||
|
||||
bitmap = result
|
||||
}
|
||||
|
||||
return bitmap
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CustomAsyncImage(
|
||||
context: Context? = null,
|
||||
imageUrl: Any?,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
blurHash: String? = null,
|
||||
@DrawableRes
|
||||
placeholderRes: Int? = null,
|
||||
contentScale: ContentScale = ContentScale.Crop
|
||||
) {
|
||||
val localContext = LocalContext.current
|
||||
|
||||
val imageLoader = getImageLoader(context ?: localContext)
|
||||
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context ?: localContext)
|
||||
.data(imageUrl)
|
||||
.crossfade(200)
|
||||
.build(),
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale,
|
||||
imageLoader = imageLoader
|
||||
)
|
||||
}
|
||||
540
app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt
Normal file
540
app/src/main/java/com/aiosman/ravenow/ui/composables/Moment.kt
Normal file
@@ -0,0 +1,540 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
import com.aiosman.ravenow.entity.MomentImageEntity
|
||||
import com.aiosman.ravenow.exp.timeAgo
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.comment.CommentModalContent
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.ui.navigateToPost
|
||||
|
||||
@Composable
|
||||
fun MomentCard(
|
||||
momentEntity: MomentEntity,
|
||||
onLikeClick: () -> Unit = {},
|
||||
onFavoriteClick: () -> Unit = {},
|
||||
onAddComment: () -> Unit = {},
|
||||
onFollowClick: () -> Unit = {},
|
||||
hideAction: Boolean = false,
|
||||
showFollowButton: Boolean = true
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
var imageIndex by remember { mutableStateOf(0) }
|
||||
val navController = LocalNavController.current
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(AppColors.background)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
|
||||
) {
|
||||
MomentTopRowGroup(
|
||||
momentEntity = momentEntity,
|
||||
onFollowClick = onFollowClick,
|
||||
showFollowButton = showFollowButton
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.noRippleClickable {
|
||||
navController.navigateToPost(
|
||||
momentEntity.id,
|
||||
highlightCommentId = 0,
|
||||
initImagePagerIndex = imageIndex
|
||||
)
|
||||
}
|
||||
) {
|
||||
MomentContentGroup(
|
||||
momentEntity = momentEntity,
|
||||
onPageChange = { index -> imageIndex = index }
|
||||
)
|
||||
}
|
||||
if (!hideAction) {
|
||||
MomentBottomOperateRowGroup(
|
||||
momentEntity = momentEntity,
|
||||
onLikeClick = onLikeClick,
|
||||
onAddComment = onAddComment,
|
||||
onFavoriteClick = onFavoriteClick,
|
||||
imageIndex = imageIndex,
|
||||
onCommentClick = {
|
||||
navController.navigateToPost(
|
||||
momentEntity.id,
|
||||
highlightCommentId = 0,
|
||||
initImagePagerIndex = imageIndex
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ModificationListHeader() {
|
||||
val navController = LocalNavController.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFFF8F8F8))
|
||||
.padding(4.dp)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) {
|
||||
navController.navigate("ModificationList")
|
||||
}
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color(0xFFEB4869))
|
||||
.padding(8.dp)
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.Build,
|
||||
contentDescription = "Modification Icon",
|
||||
tint = Color.White, // Assuming the icon should be white
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Modification List",
|
||||
color = Color(0xFF333333),
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Left
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentName(name: String, modifier: Modifier = Modifier) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Text(
|
||||
modifier = modifier,
|
||||
textAlign = TextAlign.Start,
|
||||
text = name,
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp, style = TextStyle(fontWeight = FontWeight.Bold)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentFollowBtn() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = 53.dp, height = 18.dp)
|
||||
.padding(start = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
painter = painterResource(id = R.drawable.follow_bg),
|
||||
contentDescription = ""
|
||||
)
|
||||
Text(
|
||||
text = "Follow",
|
||||
color = Color.White,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentPostLocation(location: String) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Text(
|
||||
text = location,
|
||||
color = AppColors.secondaryText,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentPostTime(time: String) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Text(
|
||||
modifier = Modifier,
|
||||
text = time, color = AppColors.text,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentTopRowGroup(
|
||||
momentEntity: MomentEntity,
|
||||
showFollowButton: Boolean = true,
|
||||
onFollowClick: () -> Unit = {}
|
||||
) {
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
Row(
|
||||
modifier = Modifier
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
momentEntity.avatar,
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(40.dp))
|
||||
.noRippleClickable {
|
||||
navController.navigate(
|
||||
NavigationRoute.AccountProfile.route.replace(
|
||||
"{id}",
|
||||
momentEntity.authorId.toString()
|
||||
)
|
||||
)
|
||||
},
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 12.dp, end = 12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(22.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
MomentName(
|
||||
modifier = Modifier.weight(1f),
|
||||
name = momentEntity.nickname
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(21.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
MomentPostTime(momentEntity.time.timeAgo(context))
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
MomentPostLocation(momentEntity.location)
|
||||
}
|
||||
}
|
||||
val isFollowing = momentEntity.followStatus
|
||||
if (showFollowButton && !isFollowing) {
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
if (AppState.UserId != momentEntity.authorId) {
|
||||
FollowButton(
|
||||
isFollowing = false
|
||||
) {
|
||||
onFollowClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun PostImageView(
|
||||
images: List<MomentImageEntity>,
|
||||
onPageChange: (Int) -> Unit = {}
|
||||
) {
|
||||
val pagerState = rememberPagerState(pageCount = { images.size })
|
||||
LaunchedEffect(pagerState.currentPage) {
|
||||
onPageChange(pagerState.currentPage)
|
||||
}
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f),
|
||||
) { page ->
|
||||
val image = images[page]
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
image.thumbnail,
|
||||
contentDescription = "Image",
|
||||
blurHash = image.blurHash,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentContentGroup(
|
||||
momentEntity: MomentEntity,
|
||||
onPageChange: (Int) -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
if (momentEntity.momentTextContent.isNotEmpty()) {
|
||||
Text(
|
||||
text = momentEntity.momentTextContent,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||
fontSize = 16.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
if (momentEntity.relMoment != null) {
|
||||
RelPostCard(
|
||||
momentEntity = momentEntity.relMoment!!,
|
||||
modifier = Modifier.background(Color(0xFFF8F8F8))
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
PostImageView(
|
||||
images = momentEntity.images,
|
||||
onPageChange = onPageChange
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentOperateBtn(@DrawableRes icon: Int, count: String) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
modifier = Modifier
|
||||
.size(width = 24.dp, height = 24.dp),
|
||||
painter = painterResource(id = icon),
|
||||
contentDescription = "",
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
Text(
|
||||
text = count,
|
||||
modifier = Modifier.padding(start = 7.dp),
|
||||
fontSize = 12.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentOperateBtn(count: String, content: @Composable () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
content()
|
||||
AnimatedCounter(
|
||||
count = count.toInt(),
|
||||
fontSize = 14,
|
||||
modifier = Modifier
|
||||
.padding(start = 7.dp)
|
||||
.width(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MomentBottomOperateRowGroup(
|
||||
onLikeClick: () -> Unit = {},
|
||||
onAddComment: () -> Unit = {},
|
||||
onCommentClick: () -> Unit = {},
|
||||
onFavoriteClick: () -> Unit = {},
|
||||
momentEntity: MomentEntity,
|
||||
imageIndex: Int = 0
|
||||
) {
|
||||
var showCommentModal by remember { mutableStateOf(false) }
|
||||
if (showCommentModal) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = { showCommentModal = false },
|
||||
containerColor = Color.White,
|
||||
sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true
|
||||
),
|
||||
windowInsets = WindowInsets(0),
|
||||
dragHandle = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.clip(CircleShape)
|
||||
) {
|
||||
|
||||
}
|
||||
}
|
||||
) {
|
||||
CommentModalContent(
|
||||
postId = momentEntity.id,
|
||||
commentCount = momentEntity.commentCount,
|
||||
onCommentAdded = {
|
||||
onAddComment()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(56.dp)
|
||||
.padding(start = 16.dp, end = 0.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MomentOperateBtn(count = momentEntity.likeCount.toString()) {
|
||||
AnimatedLikeIcon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
liked = momentEntity.liked
|
||||
) {
|
||||
onLikeClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.noRippleClickable {
|
||||
onCommentClick()
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
MomentOperateBtn(
|
||||
icon = R.drawable.rider_pro_comment,
|
||||
count = momentEntity.commentCount.toString()
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.fillMaxHeight()
|
||||
.noRippleClickable {
|
||||
onFavoriteClick()
|
||||
},
|
||||
contentAlignment = Alignment.CenterEnd
|
||||
) {
|
||||
MomentOperateBtn(count = momentEntity.favoriteCount.toString()) {
|
||||
AnimatedFavouriteIcon(
|
||||
modifier = Modifier.size(24.dp),
|
||||
isFavourite = momentEntity.isFavorite
|
||||
) {
|
||||
onFavoriteClick()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (momentEntity.images.size > 1) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
momentEntity.images.forEachIndexed { index, _ ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(4.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (imageIndex == index) Color.Red else Color.Gray.copy(
|
||||
alpha = 0.5f
|
||||
)
|
||||
)
|
||||
.padding(1.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MomentListLoading() {
|
||||
CircularProgressIndicator(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.wrapContentWidth(Alignment.CenterHorizontally),
|
||||
color = Color.Red
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.aiosman.ravenow.utils.Utils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* 选择图片并压缩
|
||||
*/
|
||||
@Composable
|
||||
fun pickupAndCompressLauncher(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
maxSize: Int = 512,
|
||||
quality: Int = 85,
|
||||
onImagePicked: (Uri, File) -> Unit
|
||||
) = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val uri = result.data?.data
|
||||
uri?.let {
|
||||
scope.launch {
|
||||
// Compress the image
|
||||
val file = Utils.compressImage(context, it, maxSize = maxSize, quality = quality)
|
||||
// Check the compressed image size
|
||||
onImagePicked(it, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import android.net.http.SslError
|
||||
import android.webkit.SslErrorHandler
|
||||
import android.webkit.WebSettings
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.text.ClickableText
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.aiosman.ravenow.ConstVars
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.data.DictService
|
||||
import com.aiosman.ravenow.data.DictServiceImpl
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun PolicyCheckbox(
|
||||
checked: Boolean = false,
|
||||
error: Boolean = false,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
) {
|
||||
var showModal by remember { mutableStateOf(false) }
|
||||
var modalSheetState = androidx.compose.material3.rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
)
|
||||
var scope = rememberCoroutineScope()
|
||||
val dictService: DictService = DictServiceImpl()
|
||||
var policyUrl by remember { mutableStateOf("") }
|
||||
val appColor = LocalAppTheme.current
|
||||
fun openPolicyModel() {
|
||||
scope.launch {
|
||||
try {
|
||||
val resp = dictService.getDictByKey(ConstVars.DICT_KEY_PRIVATE_POLICY_URL)
|
||||
policyUrl = resp.value
|
||||
showModal = true
|
||||
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
if (showModal) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
showModal = false
|
||||
},
|
||||
sheetState = modalSheetState,
|
||||
windowInsets = WindowInsets(0),
|
||||
containerColor = Color.White,
|
||||
) {
|
||||
WebViewDisplay(
|
||||
url = policyUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
Row {
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = {
|
||||
onCheckedChange(it)
|
||||
},
|
||||
size = 16
|
||||
)
|
||||
val text = buildAnnotatedString {
|
||||
val keyword = stringResource(R.string.private_policy_keyword)
|
||||
val template = stringResource(R.string.private_policy_template)
|
||||
append(template)
|
||||
append(" ")
|
||||
withStyle(style = SpanStyle(color = if (error) appColor.error else appColor.text)) {
|
||||
append(keyword)
|
||||
}
|
||||
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
color = appColor.main,
|
||||
textDecoration = TextDecoration.Underline
|
||||
),
|
||||
start = template.length + 1,
|
||||
end = template.length + keyword.length + 1
|
||||
)
|
||||
append(".")
|
||||
}
|
||||
ClickableText(
|
||||
text = text,
|
||||
modifier = Modifier.padding(start = 8.dp),
|
||||
onClick = {
|
||||
openPolicyModel()
|
||||
},
|
||||
style = TextStyle(
|
||||
fontSize = 12.sp,
|
||||
color = if (error) appColor.error else appColor.text
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun WebViewDisplay(modifier: Modifier = Modifier, url: String) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
item {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onReceivedSslError(
|
||||
view: WebView?,
|
||||
handler: SslErrorHandler?,
|
||||
error: SslError?
|
||||
) {
|
||||
handler?.proceed() // 忽略证书错误
|
||||
}
|
||||
}
|
||||
settings.apply {
|
||||
domStorageEnabled = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
}
|
||||
loadUrl(url)
|
||||
}
|
||||
},
|
||||
modifier = modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
|
||||
@Composable
|
||||
fun RelPostCard(
|
||||
momentEntity: MomentEntity,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val image = momentEntity.images.firstOrNull()
|
||||
val context = LocalContext.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
MomentTopRowGroup(momentEntity = momentEntity)
|
||||
Box(
|
||||
modifier=Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||
) {
|
||||
image?.let {
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
image.thumbnail,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(100.dp),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
|
||||
@Composable
|
||||
fun StatusBarMask(darkIcons: Boolean = true) {
|
||||
val paddingValues = WindowInsets.systemBars.asPaddingValues()
|
||||
val systemUiController = rememberSystemUiController()
|
||||
LaunchedEffect(Unit) {
|
||||
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = darkIcons)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun StatusBarMaskLayout(
|
||||
modifier: Modifier = Modifier,
|
||||
darkIcons: Boolean = true,
|
||||
useNavigationBarMask: Boolean = true,
|
||||
maskBoxBackgroundColor: Color = Color.Transparent,
|
||||
content: @Composable ColumnScope.() -> Unit
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val paddingValues = WindowInsets.systemBars.asPaddingValues()
|
||||
val systemUiController = rememberSystemUiController()
|
||||
val navigationBarPaddings =
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
LaunchedEffect(Unit) {
|
||||
systemUiController.setStatusBarColor(Color.Transparent, darkIcons = darkIcons)
|
||||
}
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(paddingValues.calculateTopPadding())
|
||||
.fillMaxWidth()
|
||||
.background(maskBoxBackgroundColor)
|
||||
) {
|
||||
|
||||
}
|
||||
content()
|
||||
if (navigationBarPaddings > 24.dp && useNavigationBarMask) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(navigationBarPaddings).fillMaxWidth().background(AppColors.background)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.systemBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
fun StatusBarSpacer() {
|
||||
val paddingValues = WindowInsets.systemBars.asPaddingValues()
|
||||
Spacer(modifier = Modifier.height(paddingValues.calculateTopPadding()))
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.aiosman.ravenow.ui.composables
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
|
||||
@Composable
|
||||
fun TextInputField(
|
||||
modifier: Modifier = Modifier,
|
||||
text: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
password: Boolean = false,
|
||||
label: String? = null,
|
||||
hint: String? = null,
|
||||
error: String? = null,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
var showPassword by remember { mutableStateOf(!password) }
|
||||
var isFocused by remember { mutableStateOf(false) }
|
||||
Column(modifier = modifier) {
|
||||
label?.let {
|
||||
Text(it, color = AppColors.secondaryText)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
Box(
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(AppColors.inputBackground)
|
||||
.border(
|
||||
width = 2.dp,
|
||||
color = if (error == null) Color.Transparent else AppColors.error,
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically){
|
||||
BasicTextField(
|
||||
value = text,
|
||||
onValueChange = onValueChange,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.onFocusChanged { focusState ->
|
||||
isFocused = focusState.isFocused
|
||||
},
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W500,
|
||||
color = AppColors.text
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = if (password) KeyboardType.Password else KeyboardType.Text
|
||||
),
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
singleLine = true,
|
||||
enabled = enabled,
|
||||
cursorBrush = SolidColor(AppColors.text),
|
||||
)
|
||||
if (password) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_eye),
|
||||
contentDescription = "Password",
|
||||
modifier = Modifier
|
||||
.size(18.dp)
|
||||
.noRippleClickable {
|
||||
showPassword = !showPassword
|
||||
},
|
||||
colorFilter = ColorFilter.tint(AppColors.text)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (text.isEmpty()) {
|
||||
hint?.let {
|
||||
Text(it, modifier = Modifier.padding(start = 5.dp), color = AppColors.inputHint, fontWeight = FontWeight.W600)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = error != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.rider_pro_input_error),
|
||||
contentDescription = "Error",
|
||||
modifier = Modifier.size(8.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
AnimatedContent(targetState = error) { targetError ->
|
||||
Text(targetError ?: "", color = AppColors.text, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.aiosman.ravenow.ui.composables.form
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
|
||||
@Composable
|
||||
fun FormTextInput(
|
||||
modifier: Modifier = Modifier,
|
||||
value: String,
|
||||
label: String? = null,
|
||||
error: String? = null,
|
||||
hint: String? = null,
|
||||
onValueChange: (String) -> Unit
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
Column(
|
||||
modifier = modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(AppColors.inputBackground)
|
||||
.let {
|
||||
if (error != null) {
|
||||
it.border(1.dp, AppColors.error, RoundedCornerShape(24.dp))
|
||||
} else {
|
||||
it
|
||||
}
|
||||
}
|
||||
.padding(17.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
label?.let {
|
||||
Text(
|
||||
text = it,
|
||||
modifier = Modifier
|
||||
.widthIn(100.dp),
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = AppColors.text
|
||||
)
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(start = 16.dp)
|
||||
) {
|
||||
if (value.isEmpty()) {
|
||||
Text(
|
||||
text = hint ?: "",
|
||||
style = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = AppColors.inputHint
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
BasicTextField(
|
||||
maxLines = 1,
|
||||
value = value,
|
||||
onValueChange = {
|
||||
onValueChange(it)
|
||||
},
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
color = AppColors.text
|
||||
),
|
||||
cursorBrush = SolidColor(AppColors.text),
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = error != null,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut()
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.rider_pro_input_error),
|
||||
contentDescription = "Error",
|
||||
modifier = Modifier.size(8.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(4.dp))
|
||||
AnimatedContent(targetState = error) { targetError ->
|
||||
Text(targetError ?: "", color = AppColors.error, fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
@RequiresOptIn(
|
||||
message = "This is an experimental API of compose-collapsing-toolbar. Any declarations with " +
|
||||
"the annotation might be removed or changed in some way without any notice.",
|
||||
level = RequiresOptIn.Level.WARNING
|
||||
)
|
||||
@Target(
|
||||
AnnotationTarget.FUNCTION,
|
||||
AnnotationTarget.PROPERTY,
|
||||
AnnotationTarget.CLASS
|
||||
)
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ExperimentalToolbarApi
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasurePolicy
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.layout.Placeable
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import kotlin.math.max
|
||||
|
||||
@Deprecated(
|
||||
"Use AppBarContainer for naming consistency",
|
||||
replaceWith = ReplaceWith(
|
||||
"AppBarContainer(modifier, scrollStrategy, collapsingToolbarState, content)",
|
||||
"me.onebone.toolbar"
|
||||
)
|
||||
)
|
||||
@Composable
|
||||
fun AppbarContainer(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollStrategy: ScrollStrategy,
|
||||
collapsingToolbarState: CollapsingToolbarState,
|
||||
content: @Composable AppbarContainerScope.() -> Unit
|
||||
) {
|
||||
AppBarContainer(
|
||||
modifier = modifier,
|
||||
scrollStrategy = scrollStrategy,
|
||||
collapsingToolbarState = collapsingToolbarState,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
|
||||
@Deprecated(
|
||||
"AppBarContainer is replaced with CollapsingToolbarScaffold",
|
||||
replaceWith = ReplaceWith(
|
||||
"CollapsingToolbarScaffold",
|
||||
"me.onebone.toolbar"
|
||||
)
|
||||
)
|
||||
@Composable
|
||||
fun AppBarContainer(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollStrategy: ScrollStrategy,
|
||||
/** The state of a connected collapsing toolbar */
|
||||
collapsingToolbarState: CollapsingToolbarState,
|
||||
content: @Composable AppbarContainerScope.() -> Unit
|
||||
) {
|
||||
val offsetY = remember { mutableStateOf(0) }
|
||||
val flingBehavior = ScrollableDefaults.flingBehavior()
|
||||
|
||||
val (scope, measurePolicy) = remember(scrollStrategy, collapsingToolbarState) {
|
||||
AppbarContainerScopeImpl(scrollStrategy.create(offsetY, collapsingToolbarState, flingBehavior)) to
|
||||
AppbarMeasurePolicy(scrollStrategy, collapsingToolbarState, offsetY)
|
||||
}
|
||||
|
||||
Layout(
|
||||
content = { scope.content() },
|
||||
measurePolicy = measurePolicy,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
interface AppbarContainerScope {
|
||||
fun Modifier.appBarBody(): Modifier
|
||||
}
|
||||
|
||||
internal class AppbarContainerScopeImpl(
|
||||
private val nestedScrollConnection: NestedScrollConnection
|
||||
): AppbarContainerScope {
|
||||
override fun Modifier.appBarBody(): Modifier {
|
||||
return this
|
||||
.then(AppBarBodyMarkerModifier)
|
||||
.nestedScroll(nestedScrollConnection)
|
||||
}
|
||||
}
|
||||
|
||||
private object AppBarBodyMarkerModifier: ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return AppBarBodyMarker
|
||||
}
|
||||
}
|
||||
|
||||
private object AppBarBodyMarker
|
||||
|
||||
private class AppbarMeasurePolicy(
|
||||
private val scrollStrategy: ScrollStrategy,
|
||||
private val toolbarState: CollapsingToolbarState,
|
||||
private val offsetY: State<Int>
|
||||
): MeasurePolicy {
|
||||
override fun MeasureScope.measure(
|
||||
measurables: List<Measurable>,
|
||||
constraints: Constraints
|
||||
): MeasureResult {
|
||||
var width = 0
|
||||
var height = 0
|
||||
|
||||
var toolbarPlaceable: Placeable? = null
|
||||
|
||||
val nonToolbars = measurables.filter {
|
||||
val data = it.parentData
|
||||
if(data != AppBarBodyMarker) {
|
||||
if(toolbarPlaceable != null)
|
||||
throw IllegalStateException("There cannot exist multiple toolbars under single parent")
|
||||
|
||||
val placeable = it.measure(constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0
|
||||
))
|
||||
width = max(width, placeable.width)
|
||||
height = max(height, placeable.height)
|
||||
|
||||
toolbarPlaceable = placeable
|
||||
|
||||
false
|
||||
}else{
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
val placeables = nonToolbars.map { measurable ->
|
||||
val childConstraints = if(scrollStrategy == ScrollStrategy.ExitUntilCollapsed) {
|
||||
constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxHeight = max(0, constraints.maxHeight - toolbarState.minHeight)
|
||||
)
|
||||
}else{
|
||||
constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0
|
||||
)
|
||||
}
|
||||
|
||||
val placeable = measurable.measure(childConstraints)
|
||||
|
||||
width = max(width, placeable.width)
|
||||
height = max(height, placeable.height)
|
||||
|
||||
placeable
|
||||
}
|
||||
|
||||
height += (toolbarPlaceable?.height ?: 0)
|
||||
|
||||
return layout(
|
||||
width.coerceIn(constraints.minWidth, constraints.maxWidth),
|
||||
height.coerceIn(constraints.minHeight, constraints.maxHeight)
|
||||
) {
|
||||
toolbarPlaceable?.place(x = 0, y = offsetY.value)
|
||||
|
||||
placeables.forEach { placeable ->
|
||||
placeable.place(
|
||||
x = 0,
|
||||
y = offsetY.value + (toolbarPlaceable?.height ?: 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.annotation.FloatRange
|
||||
import androidx.compose.animation.core.AnimationState
|
||||
import androidx.compose.animation.core.animateTo
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.MutatePriority
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.foundation.gestures.ScrollScope
|
||||
import androidx.compose.foundation.gestures.ScrollableState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.Measurable
|
||||
import androidx.compose.ui.layout.MeasurePolicy
|
||||
import androidx.compose.ui.layout.MeasureResult
|
||||
import androidx.compose.ui.layout.MeasureScope
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.absoluteValue
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Stable
|
||||
class CollapsingToolbarState(
|
||||
initial: Int = Int.MAX_VALUE
|
||||
): ScrollableState {
|
||||
/**
|
||||
* [height] indicates current height of the toolbar.
|
||||
*/
|
||||
var height: Int by mutableStateOf(initial)
|
||||
private set
|
||||
|
||||
/**
|
||||
* [minHeight] indicates the minimum height of the collapsing toolbar. The toolbar
|
||||
* may collapse its height to [minHeight] but not smaller. This size is determined by
|
||||
* the smallest child.
|
||||
*/
|
||||
var minHeight: Int
|
||||
get() = minHeightState
|
||||
internal set(value) {
|
||||
minHeightState = value
|
||||
|
||||
if(height < value) {
|
||||
height = value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [maxHeight] indicates the maximum height of the collapsing toolbar. The toolbar
|
||||
* may expand its height to [maxHeight] but not larger. This size is determined by
|
||||
* the largest child.
|
||||
*/
|
||||
var maxHeight: Int
|
||||
get() = maxHeightState
|
||||
internal set(value) {
|
||||
maxHeightState = value
|
||||
|
||||
if(value < height) {
|
||||
height = value
|
||||
}
|
||||
}
|
||||
|
||||
private var maxHeightState by mutableStateOf(Int.MAX_VALUE)
|
||||
private var minHeightState by mutableStateOf(0)
|
||||
|
||||
val progress: Float
|
||||
@FloatRange(from = 0.0, to = 1.0)
|
||||
get() =
|
||||
if(minHeight == maxHeight) {
|
||||
0f
|
||||
}else{
|
||||
((height - minHeight).toFloat() / (maxHeight - minHeight)).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private val scrollableState = ScrollableState { value ->
|
||||
val consume = if(value < 0) {
|
||||
max(minHeight.toFloat() - height, value)
|
||||
}else{
|
||||
min(maxHeight.toFloat() - height, value)
|
||||
}
|
||||
|
||||
val current = consume + deferredConsumption
|
||||
val currentInt = current.toInt()
|
||||
|
||||
if(current.absoluteValue > 0) {
|
||||
height += currentInt
|
||||
deferredConsumption = current - currentInt
|
||||
}
|
||||
|
||||
consume
|
||||
}
|
||||
|
||||
private var deferredConsumption: Float = 0f
|
||||
|
||||
/**
|
||||
* @return consumed scroll value is returned
|
||||
*/
|
||||
@Deprecated(
|
||||
message = "feedScroll() is deprecated, use dispatchRawDelta() instead.",
|
||||
replaceWith = ReplaceWith("dispatchRawDelta(value)")
|
||||
)
|
||||
fun feedScroll(value: Float): Float = dispatchRawDelta(value)
|
||||
|
||||
@ExperimentalToolbarApi
|
||||
suspend fun expand(duration: Int = 200) {
|
||||
val anim = AnimationState(height.toFloat())
|
||||
|
||||
scroll {
|
||||
var prev = anim.value
|
||||
anim.animateTo(maxHeight.toFloat(), tween(duration)) {
|
||||
scrollBy(value - prev)
|
||||
prev = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalToolbarApi
|
||||
suspend fun collapse(duration: Int = 200) {
|
||||
val anim = AnimationState(height.toFloat())
|
||||
|
||||
scroll {
|
||||
var prev = anim.value
|
||||
anim.animateTo(minHeight.toFloat(), tween(duration)) {
|
||||
scrollBy(value - prev)
|
||||
prev = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Remaining velocity after fling
|
||||
*/
|
||||
suspend fun fling(flingBehavior: FlingBehavior, velocity: Float): Float {
|
||||
var left = velocity
|
||||
scroll {
|
||||
with(flingBehavior) {
|
||||
left = performFling(left)
|
||||
}
|
||||
}
|
||||
|
||||
return left
|
||||
}
|
||||
|
||||
override val isScrollInProgress: Boolean
|
||||
get() = scrollableState.isScrollInProgress
|
||||
|
||||
override fun dispatchRawDelta(delta: Float): Float = scrollableState.dispatchRawDelta(delta)
|
||||
|
||||
override suspend fun scroll(
|
||||
scrollPriority: MutatePriority,
|
||||
block: suspend ScrollScope.() -> Unit
|
||||
) = scrollableState.scroll(scrollPriority, block)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberCollapsingToolbarState(
|
||||
initial: Int = Int.MAX_VALUE
|
||||
): CollapsingToolbarState {
|
||||
return remember {
|
||||
CollapsingToolbarState(
|
||||
initial = initial
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CollapsingToolbar(
|
||||
modifier: Modifier = Modifier,
|
||||
clipToBounds: Boolean = true,
|
||||
collapsingToolbarState: CollapsingToolbarState,
|
||||
content: @Composable CollapsingToolbarScope.() -> Unit
|
||||
) {
|
||||
val measurePolicy = remember(collapsingToolbarState) {
|
||||
CollapsingToolbarMeasurePolicy(collapsingToolbarState)
|
||||
}
|
||||
|
||||
Layout(
|
||||
content = { CollapsingToolbarScopeInstance.content() },
|
||||
measurePolicy = measurePolicy,
|
||||
modifier = modifier.then(
|
||||
if (clipToBounds) {
|
||||
Modifier.clipToBounds()
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private class CollapsingToolbarMeasurePolicy(
|
||||
private val collapsingToolbarState: CollapsingToolbarState
|
||||
): MeasurePolicy {
|
||||
override fun MeasureScope.measure(
|
||||
measurables: List<Measurable>,
|
||||
constraints: Constraints
|
||||
): MeasureResult {
|
||||
val placeables = measurables.map {
|
||||
it.measure(
|
||||
constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxHeight = Constraints.Infinity
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val placeStrategy = measurables.map { it.parentData }
|
||||
|
||||
val minHeight = placeables.minOfOrNull { it.height }
|
||||
?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0
|
||||
|
||||
val maxHeight = placeables.maxOfOrNull { it.height }
|
||||
?.coerceIn(constraints.minHeight, constraints.maxHeight) ?: 0
|
||||
|
||||
val maxWidth = placeables.maxOfOrNull{ it.width }
|
||||
?.coerceIn(constraints.minWidth, constraints.maxWidth) ?: 0
|
||||
|
||||
collapsingToolbarState.also {
|
||||
it.minHeight = minHeight
|
||||
it.maxHeight = maxHeight
|
||||
}
|
||||
|
||||
val height = collapsingToolbarState.height
|
||||
return layout(maxWidth, height) {
|
||||
val progress = collapsingToolbarState.progress
|
||||
|
||||
placeables.forEachIndexed { i, placeable ->
|
||||
val strategy = placeStrategy[i]
|
||||
if(strategy is CollapsingToolbarData) {
|
||||
strategy.progressListener?.onProgressUpdate(progress)
|
||||
}
|
||||
|
||||
when(strategy) {
|
||||
is CollapsingToolbarRoadData -> {
|
||||
val collapsed = strategy.whenCollapsed
|
||||
val expanded = strategy.whenExpanded
|
||||
|
||||
val collapsedOffset = collapsed.align(
|
||||
size = IntSize(placeable.width, placeable.height),
|
||||
space = IntSize(maxWidth, height),
|
||||
layoutDirection = layoutDirection
|
||||
)
|
||||
|
||||
val expandedOffset = expanded.align(
|
||||
size = IntSize(placeable.width, placeable.height),
|
||||
space = IntSize(maxWidth, height),
|
||||
layoutDirection = layoutDirection
|
||||
)
|
||||
|
||||
val offset = collapsedOffset + (expandedOffset - collapsedOffset) * progress
|
||||
|
||||
placeable.place(offset.x, offset.y)
|
||||
}
|
||||
is CollapsingToolbarParallaxData ->
|
||||
placeable.placeRelative(
|
||||
x = 0,
|
||||
y = -((maxHeight - minHeight) * (1 - progress) * strategy.ratio).roundToInt()
|
||||
)
|
||||
else -> placeable.placeRelative(0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface CollapsingToolbarScope {
|
||||
fun Modifier.progress(listener: ProgressListener): Modifier
|
||||
|
||||
fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier
|
||||
|
||||
fun Modifier.parallax(ratio: Float = 0.2f): Modifier
|
||||
|
||||
fun Modifier.pin(): Modifier
|
||||
}
|
||||
|
||||
internal object CollapsingToolbarScopeInstance: CollapsingToolbarScope {
|
||||
override fun Modifier.progress(listener: ProgressListener): Modifier {
|
||||
return this.then(ProgressUpdateListenerModifier(listener))
|
||||
}
|
||||
|
||||
override fun Modifier.road(whenCollapsed: Alignment, whenExpanded: Alignment): Modifier {
|
||||
return this.then(RoadModifier(whenCollapsed, whenExpanded))
|
||||
}
|
||||
|
||||
override fun Modifier.parallax(ratio: Float): Modifier {
|
||||
return this.then(ParallaxModifier(ratio))
|
||||
}
|
||||
|
||||
override fun Modifier.pin(): Modifier {
|
||||
return this.then(PinModifier())
|
||||
}
|
||||
}
|
||||
|
||||
internal class RoadModifier(
|
||||
private val whenCollapsed: Alignment,
|
||||
private val whenExpanded: Alignment
|
||||
): ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return CollapsingToolbarRoadData(
|
||||
this@RoadModifier.whenCollapsed, this@RoadModifier.whenExpanded,
|
||||
(parentData as? CollapsingToolbarData)?.progressListener
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal class ParallaxModifier(
|
||||
private val ratio: Float
|
||||
): ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return CollapsingToolbarParallaxData(ratio, (parentData as? CollapsingToolbarData)?.progressListener)
|
||||
}
|
||||
}
|
||||
|
||||
internal class PinModifier: ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return CollapsingToolbarPinData((parentData as? CollapsingToolbarData)?.progressListener)
|
||||
}
|
||||
}
|
||||
|
||||
internal class ProgressUpdateListenerModifier(
|
||||
private val listener: ProgressListener
|
||||
): ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return CollapsingToolbarProgressData(listener)
|
||||
}
|
||||
}
|
||||
|
||||
fun interface ProgressListener {
|
||||
fun onProgressUpdate(value: Float)
|
||||
}
|
||||
|
||||
internal sealed class CollapsingToolbarData(
|
||||
var progressListener: ProgressListener?
|
||||
)
|
||||
|
||||
internal class CollapsingToolbarProgressData(
|
||||
progressListener: ProgressListener?
|
||||
): CollapsingToolbarData(progressListener)
|
||||
|
||||
internal class CollapsingToolbarRoadData(
|
||||
var whenCollapsed: Alignment,
|
||||
var whenExpanded: Alignment,
|
||||
progressListener: ProgressListener? = null
|
||||
): CollapsingToolbarData(progressListener)
|
||||
|
||||
internal class CollapsingToolbarPinData(
|
||||
progressListener: ProgressListener? = null
|
||||
): CollapsingToolbarData(progressListener)
|
||||
|
||||
internal class CollapsingToolbarParallaxData(
|
||||
var ratio: Float,
|
||||
progressListener: ProgressListener? = null
|
||||
): CollapsingToolbarData(progressListener)
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.gestures.ScrollableDefaults
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.SaverScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.layout.ParentDataModifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlin.math.max
|
||||
|
||||
@Stable
|
||||
class CollapsingToolbarScaffoldState(
|
||||
val toolbarState: CollapsingToolbarState,
|
||||
initialOffsetY: Int = 0
|
||||
) {
|
||||
val offsetY: Int
|
||||
get() = offsetYState.value
|
||||
|
||||
internal val offsetYState = mutableStateOf(initialOffsetY)
|
||||
}
|
||||
|
||||
private class CollapsingToolbarScaffoldStateSaver: Saver<CollapsingToolbarScaffoldState, List<Any>> {
|
||||
override fun restore(value: List<Any>): CollapsingToolbarScaffoldState =
|
||||
CollapsingToolbarScaffoldState(
|
||||
CollapsingToolbarState(value[0] as Int),
|
||||
value[1] as Int
|
||||
)
|
||||
|
||||
override fun SaverScope.save(value: CollapsingToolbarScaffoldState): List<Any> =
|
||||
listOf(
|
||||
value.toolbarState.height,
|
||||
value.offsetY
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberCollapsingToolbarScaffoldState(
|
||||
toolbarState: CollapsingToolbarState = rememberCollapsingToolbarState()
|
||||
): CollapsingToolbarScaffoldState {
|
||||
return rememberSaveable(toolbarState, saver = CollapsingToolbarScaffoldStateSaver()) {
|
||||
CollapsingToolbarScaffoldState(toolbarState)
|
||||
}
|
||||
}
|
||||
|
||||
interface CollapsingToolbarScaffoldScope {
|
||||
@ExperimentalToolbarApi
|
||||
fun Modifier.align(alignment: Alignment): Modifier
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CollapsingToolbarScaffold(
|
||||
modifier: Modifier,
|
||||
state: CollapsingToolbarScaffoldState,
|
||||
scrollStrategy: ScrollStrategy,
|
||||
enabled: Boolean = true,
|
||||
toolbarModifier: Modifier = Modifier,
|
||||
toolbarClipToBounds: Boolean = true,
|
||||
toolbarScrollable: Boolean = false,
|
||||
toolbar: @Composable CollapsingToolbarScope.(ScrollState) -> Unit,
|
||||
body: @Composable CollapsingToolbarScaffoldScope.() -> Unit
|
||||
) {
|
||||
val flingBehavior = ScrollableDefaults.flingBehavior()
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
|
||||
val nestedScrollConnection = remember(scrollStrategy, state) {
|
||||
scrollStrategy.create(state.offsetYState, state.toolbarState, flingBehavior)
|
||||
}
|
||||
|
||||
val toolbarState = state.toolbarState
|
||||
val toolbarScrollState = rememberScrollState()
|
||||
|
||||
Layout(
|
||||
content = {
|
||||
CollapsingToolbar(
|
||||
modifier = toolbarModifier,
|
||||
clipToBounds = toolbarClipToBounds,
|
||||
collapsingToolbarState = toolbarState,
|
||||
) {
|
||||
ToolbarScrollableBox(
|
||||
enabled,
|
||||
toolbarScrollable,
|
||||
toolbarState,
|
||||
toolbarScrollState
|
||||
)
|
||||
toolbar(toolbarScrollState)
|
||||
}
|
||||
|
||||
CollapsingToolbarScaffoldScopeInstance.body()
|
||||
},
|
||||
modifier = modifier
|
||||
.then(
|
||||
if (enabled) {
|
||||
Modifier.nestedScroll(nestedScrollConnection)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) { measurables, constraints ->
|
||||
check(measurables.size >= 2) {
|
||||
"the number of children should be at least 2: toolbar, (at least one) body"
|
||||
}
|
||||
|
||||
val toolbarConstraints = constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0
|
||||
)
|
||||
val bodyConstraints = constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxHeight = when (scrollStrategy) {
|
||||
ScrollStrategy.ExitUntilCollapsed ->
|
||||
(constraints.maxHeight - toolbarState.minHeight).coerceAtLeast(0)
|
||||
|
||||
ScrollStrategy.EnterAlways, ScrollStrategy.EnterAlwaysCollapsed ->
|
||||
constraints.maxHeight
|
||||
}
|
||||
)
|
||||
|
||||
val toolbarPlaceable = measurables[0].measure(toolbarConstraints)
|
||||
|
||||
val bodyMeasurables = measurables.subList(1, measurables.size)
|
||||
val childrenAlignments = bodyMeasurables.map {
|
||||
(it.parentData as? ScaffoldParentData)?.alignment
|
||||
}
|
||||
val bodyPlaceables = bodyMeasurables.map {
|
||||
it.measure(bodyConstraints)
|
||||
}
|
||||
|
||||
val toolbarHeight = toolbarPlaceable.height
|
||||
|
||||
val width = max(
|
||||
toolbarPlaceable.width,
|
||||
bodyPlaceables.maxOfOrNull { it.width } ?: 0
|
||||
).coerceIn(constraints.minWidth, constraints.maxWidth)
|
||||
val height = max(
|
||||
toolbarHeight,
|
||||
bodyPlaceables.maxOfOrNull { it.height } ?: 0
|
||||
).coerceIn(constraints.minHeight, constraints.maxHeight)
|
||||
|
||||
layout(width, height) {
|
||||
bodyPlaceables.forEachIndexed { index, placeable ->
|
||||
val alignment = childrenAlignments[index]
|
||||
|
||||
if (alignment == null) {
|
||||
placeable.placeRelative(0, toolbarHeight + state.offsetY)
|
||||
} else {
|
||||
val offset = alignment.align(
|
||||
size = IntSize(placeable.width, placeable.height),
|
||||
space = IntSize(width, height),
|
||||
layoutDirection = layoutDirection
|
||||
)
|
||||
placeable.place(offset)
|
||||
}
|
||||
}
|
||||
toolbarPlaceable.placeRelative(0, state.offsetY)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToolbarScrollableBox(
|
||||
enabled: Boolean,
|
||||
toolbarScrollable: Boolean,
|
||||
toolbarState: CollapsingToolbarState,
|
||||
toolbarScrollState: ScrollState
|
||||
) {
|
||||
val toolbarScrollableEnabled = enabled && toolbarScrollable
|
||||
if (toolbarScrollableEnabled && toolbarState.height != Constraints.Infinity) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(with(LocalDensity.current) { toolbarState.height.toDp() })
|
||||
.verticalScroll(state = toolbarScrollState)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal object CollapsingToolbarScaffoldScopeInstance: CollapsingToolbarScaffoldScope {
|
||||
@ExperimentalToolbarApi
|
||||
override fun Modifier.align(alignment: Alignment): Modifier =
|
||||
this.then(ScaffoldChildAlignmentModifier(alignment))
|
||||
}
|
||||
|
||||
private class ScaffoldChildAlignmentModifier(
|
||||
private val alignment: Alignment
|
||||
) : ParentDataModifier {
|
||||
override fun Density.modifyParentData(parentData: Any?): Any {
|
||||
return (parentData as? ScaffoldParentData) ?: ScaffoldParentData(alignment)
|
||||
}
|
||||
}
|
||||
|
||||
private data class ScaffoldParentData(
|
||||
var alignment: Alignment? = null
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
class FabPlacement(
|
||||
val left: Int,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
enum class FabPosition {
|
||||
Center,
|
||||
End
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* Copyright (c) 2021 onebone <me@onebone.me>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
* OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
|
||||
* OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.compose.foundation.gestures.FlingBehavior
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
|
||||
enum class ScrollStrategy {
|
||||
EnterAlways {
|
||||
override fun create(
|
||||
offsetY: MutableState<Int>,
|
||||
toolbarState: CollapsingToolbarState,
|
||||
flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection =
|
||||
EnterAlwaysNestedScrollConnection(offsetY, toolbarState, flingBehavior)
|
||||
},
|
||||
EnterAlwaysCollapsed {
|
||||
override fun create(
|
||||
offsetY: MutableState<Int>,
|
||||
toolbarState: CollapsingToolbarState,
|
||||
flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection =
|
||||
EnterAlwaysCollapsedNestedScrollConnection(offsetY, toolbarState, flingBehavior)
|
||||
},
|
||||
ExitUntilCollapsed {
|
||||
override fun create(
|
||||
offsetY: MutableState<Int>,
|
||||
toolbarState: CollapsingToolbarState,
|
||||
flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection =
|
||||
ExitUntilCollapsedNestedScrollConnection(toolbarState, flingBehavior)
|
||||
};
|
||||
|
||||
internal abstract fun create(
|
||||
offsetY: MutableState<Int>,
|
||||
toolbarState: CollapsingToolbarState,
|
||||
flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection
|
||||
}
|
||||
|
||||
private class ScrollDelegate(
|
||||
private val offsetY: MutableState<Int>
|
||||
) {
|
||||
private var scrollToBeConsumed: Float = 0f
|
||||
|
||||
fun doScroll(delta: Float) {
|
||||
val scroll = scrollToBeConsumed + delta
|
||||
val scrollInt = scroll.toInt()
|
||||
|
||||
scrollToBeConsumed = scroll - scrollInt
|
||||
|
||||
offsetY.value += scrollInt
|
||||
}
|
||||
}
|
||||
|
||||
internal class EnterAlwaysNestedScrollConnection(
|
||||
private val offsetY: MutableState<Int>,
|
||||
private val toolbarState: CollapsingToolbarState,
|
||||
private val flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection {
|
||||
private val scrollDelegate = ScrollDelegate(offsetY)
|
||||
//private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl())
|
||||
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val dy = available.y
|
||||
|
||||
val toolbar = toolbarState.height.toFloat()
|
||||
val offset = offsetY.value.toFloat()
|
||||
|
||||
// -toolbarHeight <= offsetY + dy <= 0
|
||||
val consume = if(dy < 0) {
|
||||
val toolbarConsumption = toolbarState.dispatchRawDelta(dy)
|
||||
val remaining = dy - toolbarConsumption
|
||||
val offsetConsumption = remaining.coerceAtLeast(-toolbar - offset)
|
||||
scrollDelegate.doScroll(offsetConsumption)
|
||||
|
||||
toolbarConsumption + offsetConsumption
|
||||
}else{
|
||||
val offsetConsumption = dy.coerceAtMost(-offset)
|
||||
scrollDelegate.doScroll(offsetConsumption)
|
||||
|
||||
val toolbarConsumption = toolbarState.dispatchRawDelta(dy - offsetConsumption)
|
||||
|
||||
offsetConsumption + toolbarConsumption
|
||||
}
|
||||
|
||||
return Offset(0f, consume)
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
val left = if(available.y > 0) {
|
||||
toolbarState.fling(flingBehavior, available.y)
|
||||
}else{
|
||||
// If velocity < 0, the main content should have a remaining scroll space
|
||||
// so the scroll resumes to the onPreScroll(..., Fling) phase. Hence we do
|
||||
// not need to process it at onPostFling() manually.
|
||||
available.y
|
||||
}
|
||||
|
||||
return Velocity(x = 0f, y = available.y - left)
|
||||
}
|
||||
}
|
||||
|
||||
internal class EnterAlwaysCollapsedNestedScrollConnection(
|
||||
private val offsetY: MutableState<Int>,
|
||||
private val toolbarState: CollapsingToolbarState,
|
||||
private val flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection {
|
||||
private val scrollDelegate = ScrollDelegate(offsetY)
|
||||
//private val tracker = RelativeVelocityTracker(CurrentTimeProviderImpl())
|
||||
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val dy = available.y
|
||||
|
||||
val consumed = if(dy > 0) { // expanding: offset -> body -> toolbar
|
||||
val offsetConsumption = dy.coerceAtMost(-offsetY.value.toFloat())
|
||||
scrollDelegate.doScroll(offsetConsumption)
|
||||
|
||||
offsetConsumption
|
||||
}else{ // collapsing: toolbar -> offset -> body
|
||||
val toolbarConsumption = toolbarState.dispatchRawDelta(dy)
|
||||
val offsetConsumption = (dy - toolbarConsumption).coerceAtLeast(-toolbarState.height.toFloat() - offsetY.value)
|
||||
|
||||
scrollDelegate.doScroll(offsetConsumption)
|
||||
|
||||
toolbarConsumption + offsetConsumption
|
||||
}
|
||||
|
||||
return Offset(0f, consumed)
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val dy = available.y
|
||||
|
||||
return if(dy > 0) {
|
||||
Offset(0f, toolbarState.dispatchRawDelta(dy))
|
||||
}else{
|
||||
Offset(0f, 0f)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
val dy = available.y
|
||||
|
||||
val left = if(dy > 0) {
|
||||
// onPostFling() has positive available scroll value only called if the main scroll
|
||||
// has leftover scroll, i.e. the scroll of the main content has done. So we just process
|
||||
// fling if the available value is positive.
|
||||
toolbarState.fling(flingBehavior, dy)
|
||||
}else{
|
||||
dy
|
||||
}
|
||||
|
||||
return Velocity(x = 0f, y = available.y - left)
|
||||
}
|
||||
}
|
||||
|
||||
internal class ExitUntilCollapsedNestedScrollConnection(
|
||||
private val toolbarState: CollapsingToolbarState,
|
||||
private val flingBehavior: FlingBehavior
|
||||
): NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val dy = available.y
|
||||
|
||||
val consume = if(dy < 0) { // collapsing: toolbar -> body
|
||||
toolbarState.dispatchRawDelta(dy)
|
||||
}else{
|
||||
0f
|
||||
}
|
||||
|
||||
return Offset(0f, consume)
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource
|
||||
): Offset {
|
||||
val dy = available.y
|
||||
|
||||
val consume = if(dy > 0) { // expanding: body -> toolbar
|
||||
toolbarState.dispatchRawDelta(dy)
|
||||
}else{
|
||||
0f
|
||||
}
|
||||
|
||||
return Offset(0f, consume)
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
val left = if(available.y < 0) {
|
||||
toolbarState.fling(flingBehavior, available.y)
|
||||
}else{
|
||||
available.y
|
||||
}
|
||||
|
||||
return Velocity(x = 0f, y = available.y - left)
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
val velocity = available.y
|
||||
|
||||
val left = if(velocity > 0) {
|
||||
toolbarState.fling(flingBehavior, velocity)
|
||||
}else{
|
||||
velocity
|
||||
}
|
||||
|
||||
return Velocity(x = 0f, y = available.y - left)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.aiosman.ravenow.ui.composables.toolbar
|
||||
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.SubcomposeLayout
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@ExperimentalToolbarApi
|
||||
@Composable
|
||||
fun ToolbarWithFabScaffold(
|
||||
modifier: Modifier,
|
||||
state: CollapsingToolbarScaffoldState,
|
||||
scrollStrategy: ScrollStrategy,
|
||||
toolbarModifier: Modifier = Modifier,
|
||||
toolbarClipToBounds: Boolean = true,
|
||||
toolbar: @Composable CollapsingToolbarScope.(ScrollState) -> Unit,
|
||||
toolbarScrollable: Boolean = false,
|
||||
fab: @Composable () -> Unit,
|
||||
fabPosition: FabPosition = FabPosition.End,
|
||||
body: @Composable CollapsingToolbarScaffoldScope.() -> Unit
|
||||
) {
|
||||
SubcomposeLayout(
|
||||
modifier = modifier
|
||||
) { constraints ->
|
||||
|
||||
val toolbarScaffoldConstraints = constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0,
|
||||
maxHeight = constraints.maxHeight
|
||||
)
|
||||
|
||||
val toolbarScaffoldPlaceables = subcompose(ToolbarWithFabScaffoldContent.ToolbarScaffold) {
|
||||
CollapsingToolbarScaffold(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
scrollStrategy = scrollStrategy,
|
||||
toolbarModifier = toolbarModifier,
|
||||
toolbarClipToBounds = toolbarClipToBounds,
|
||||
toolbar = toolbar,
|
||||
body = body,
|
||||
toolbarScrollable = toolbarScrollable
|
||||
)
|
||||
}.map { it.measure(toolbarScaffoldConstraints) }
|
||||
|
||||
val fabConstraints = constraints.copy(
|
||||
minWidth = 0,
|
||||
minHeight = 0
|
||||
)
|
||||
|
||||
val fabPlaceables = subcompose(
|
||||
ToolbarWithFabScaffoldContent.Fab,
|
||||
fab
|
||||
).mapNotNull { measurable ->
|
||||
measurable.measure(fabConstraints).takeIf { it.height != 0 && it.width != 0 }
|
||||
}
|
||||
|
||||
val fabPlacement = if (fabPlaceables.isNotEmpty()) {
|
||||
val fabWidth = fabPlaceables.maxOfOrNull { it.width } ?: 0
|
||||
val fabHeight = fabPlaceables.maxOfOrNull { it.height } ?: 0
|
||||
// FAB distance from the left of the layout, taking into account LTR / RTL
|
||||
val fabLeftOffset = if (fabPosition == FabPosition.End) {
|
||||
if (layoutDirection == LayoutDirection.Ltr) {
|
||||
constraints.maxWidth - 16.dp.roundToPx() - fabWidth
|
||||
} else {
|
||||
16.dp.roundToPx()
|
||||
}
|
||||
} else {
|
||||
(constraints.maxWidth - fabWidth) / 2
|
||||
}
|
||||
|
||||
FabPlacement(
|
||||
left = fabLeftOffset,
|
||||
width = fabWidth,
|
||||
height = fabHeight
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val fabOffsetFromBottom = fabPlacement?.let {
|
||||
it.height + 16.dp.roundToPx()
|
||||
}
|
||||
|
||||
val width = constraints.maxWidth
|
||||
val height = constraints.maxHeight
|
||||
|
||||
layout(width, height) {
|
||||
toolbarScaffoldPlaceables.forEach {
|
||||
it.place(0, 0)
|
||||
}
|
||||
|
||||
fabPlacement?.let { placement ->
|
||||
fabPlaceables.forEach {
|
||||
it.place(placement.left, height - fabOffsetFromBottom!!)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private enum class ToolbarWithFabScaffoldContent {
|
||||
ToolbarScaffold, Fab
|
||||
}
|
||||
175
app/src/main/java/com/aiosman/ravenow/ui/crop/ImageCropScreen.kt
Normal file
175
app/src/main/java/com/aiosman/ravenow/ui/crop/ImageCropScreen.kt
Normal file
@@ -0,0 +1,175 @@
|
||||
package com.aiosman.ravenow.ui.crop
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.account.AccountEditViewModel
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import com.image.cropview.CropType
|
||||
import com.image.cropview.EdgeType
|
||||
import com.image.cropview.ImageCrop
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.InputStream
|
||||
|
||||
@Composable
|
||||
fun ImageCropScreen() {
|
||||
var imageCrop by remember { mutableStateOf<ImageCrop?>(null) }
|
||||
val context = LocalContext.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidth = configuration.screenWidthDp
|
||||
var imageWidthInDp by remember { mutableStateOf(0) }
|
||||
var imageHeightInDp by remember { mutableStateOf(0) }
|
||||
var density = LocalDensity.current
|
||||
var navController = LocalNavController.current
|
||||
var imagePickLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
val bitmap = uriToBitmap(context = context, uri = it)
|
||||
if (bitmap != null) {
|
||||
val aspectRatio = bitmap.height.toFloat() / bitmap.width.toFloat()
|
||||
imageHeightInDp = (imageWidthInDp.toFloat() * aspectRatio).toInt()
|
||||
imageCrop = ImageCrop(bitmap)
|
||||
}
|
||||
}
|
||||
if (uri == null) {
|
||||
navController.popBackStack()
|
||||
}
|
||||
}
|
||||
val systemUiController = rememberSystemUiController()
|
||||
LaunchedEffect(Unit) {
|
||||
systemUiController.setStatusBarColor(darkIcons = false, color = Color.Black)
|
||||
imagePickLauncher.launch("image/*")
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
imageCrop = null
|
||||
systemUiController.setStatusBarColor(darkIcons = true, color = Color.White)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.background(Color.Black).fillMaxSize()
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(R.drawable.rider_pro_back_icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.clickable {
|
||||
navController.popBackStack()
|
||||
},
|
||||
colorFilter = ColorFilter.tint(Color.White)
|
||||
)
|
||||
Spacer(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Icon(
|
||||
Icons.Default.Check,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.clickable {
|
||||
imageCrop?.let {
|
||||
val bitmap = it.onCrop()
|
||||
AccountEditViewModel.croppedBitmap = bitmap
|
||||
AccountEditViewModel.viewModelScope.launch {
|
||||
AccountEditViewModel.updateUserProfile(context)
|
||||
navController.popBackStack()
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
// Spacer(
|
||||
// modifier = Modifier.height(120.dp)
|
||||
// )
|
||||
// ActionButton(
|
||||
// modifier = Modifier.fillMaxWidth(),
|
||||
// text = "选择图片"
|
||||
// ) {
|
||||
// imagePickLauncher.launch("image/*")
|
||||
// }
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(24.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(imageHeightInDp.dp)
|
||||
.onGloballyPositioned {
|
||||
with(density) {
|
||||
imageWidthInDp = it.size.width.toDp().value.toInt()
|
||||
}
|
||||
}
|
||||
) {
|
||||
imageCrop?.ImageCropView(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
guideLineColor = Color.White,
|
||||
guideLineWidth = 2.dp,
|
||||
edgeCircleSize = 5.dp,
|
||||
cropType = CropType.SQUARE,
|
||||
edgeType = EdgeType.CIRCULAR
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
// Configure ImageCropView.
|
||||
|
||||
|
||||
fun uriToBitmap(context: Context, uri: Uri): Bitmap? {
|
||||
return try {
|
||||
val inputStream: InputStream? = context.contentResolver.openInputStream(uri)
|
||||
BitmapFactory.decodeStream(inputStream)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.aiosman.ravenow.ui.dialogs
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.content.FileProvider
|
||||
import com.aiosman.ravenow.ConstVars
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.model.UpdateInfo
|
||||
import com.aiosman.ravenow.ui.composables.ActionButton
|
||||
import com.google.firebase.perf.config.RemoteConfigManager.getVersionCode
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.io.File
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CheckUpdateDialog() {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
var newApkUrl by remember { mutableStateOf("") }
|
||||
var progress by remember { mutableStateOf(0f) }
|
||||
var isDownloading by remember { mutableStateOf(false) } // Add downloading state
|
||||
var message by remember { mutableStateOf("") }
|
||||
var versionName by remember { mutableStateOf("") }
|
||||
fun checkUpdate() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val client = OkHttpClient()
|
||||
val request = Request.Builder()
|
||||
.url("${ConstVars.BASE_SERVER}/static/update/beta/version.json")
|
||||
.build()
|
||||
val response = client.newCall(request).execute()
|
||||
|
||||
if (response.isSuccessful) {
|
||||
val responseBody = response.body?.string()
|
||||
val updateInfo = Gson().fromJson(responseBody, UpdateInfo::class.java)
|
||||
|
||||
val versionCode = getVersionCode(context)
|
||||
if (updateInfo.versionCode > versionCode) {
|
||||
withContext(Dispatchers.Main) {
|
||||
message = updateInfo.updateContent
|
||||
versionName = updateInfo.versionName
|
||||
showDialog = true
|
||||
newApkUrl = updateInfo.downloadUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadApk() {
|
||||
isDownloading = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url(newApkUrl)
|
||||
.build()
|
||||
val client = OkHttpClient()
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) throw Exception("Unexpected code $response")
|
||||
val body = response.body
|
||||
if (body != null) {
|
||||
val apkFile = File(context.cacheDir, "rider_pro.apk")
|
||||
val totalBytes = body.contentLength()
|
||||
var downloadedBytes = 0L
|
||||
|
||||
apkFile.outputStream().use { output ->
|
||||
body.byteStream().use { input ->
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var bytesRead: Int
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
output.write(buffer, 0, bytesRead)
|
||||
downloadedBytes += bytesRead
|
||||
progress = downloadedBytes / totalBytes.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val apkUri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apkFile)
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.setDataAndType(apkUri, "application/vnd.android.package-archive")
|
||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
context.startActivity(intent)
|
||||
}
|
||||
}
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
checkUpdate()
|
||||
}
|
||||
|
||||
if (showDialog) {
|
||||
BasicAlertDialog(
|
||||
onDismissRequest = {
|
||||
if (!isDownloading) {
|
||||
showDialog = false
|
||||
}
|
||||
},
|
||||
modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 24.dp, end = 24.dp, bottom = 120.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(96.dp))
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(32.dp))
|
||||
.background(AppColors.background)
|
||||
.padding(vertical = 32.dp, horizontal = 24.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
Text(
|
||||
stringResource(id = R.string.update_find_new_version),
|
||||
fontWeight = FontWeight.W900,
|
||||
fontSize = 22.sp,
|
||||
color = AppColors.text
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
versionName,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = AppColors.text
|
||||
)
|
||||
Text(
|
||||
message,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = AppColors.text
|
||||
)
|
||||
if (progress > 0) {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = { progress },
|
||||
color = AppColors.main,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp)),
|
||||
trackColor = AppColors.basicMain
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(32.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
ActionButton(
|
||||
text = stringResource(id = R.string.update_later),
|
||||
color = AppColors.text,
|
||||
backgroundColor = AppColors.basicMain,
|
||||
modifier = Modifier.weight(1f),
|
||||
fullWidth = false,
|
||||
roundCorner = 16f,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
) {
|
||||
showDialog = false
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
ActionButton(
|
||||
text = stringResource(id = R.string.update_update_now),
|
||||
backgroundColor = AppColors.main,
|
||||
color = AppColors.mainText,
|
||||
modifier = Modifier.weight(1f),
|
||||
fullWidth = false,
|
||||
roundCorner = 16f,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Normal,
|
||||
) {
|
||||
downloadApk()
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
}
|
||||
}
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.rider_pro_update_header),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.aiosman.ravenow.ui.favourite
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarSpacer
|
||||
import com.aiosman.ravenow.ui.favourite.FavouriteListViewModel.refreshPager
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.ui.navigateToPost
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun FavouriteListPage() {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val model = FavouriteListViewModel
|
||||
var dataFlow = model.favouriteMomentsFlow
|
||||
var moments = dataFlow.collectAsLazyPagingItems()
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
val state = rememberPullRefreshState(FavouriteListViewModel.isLoading, onRefresh = {
|
||||
model.refreshPager(force = true)
|
||||
})
|
||||
LaunchedEffect(Unit) {
|
||||
refreshPager()
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().background(AppColors.background)
|
||||
) {
|
||||
StatusBarSpacer()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.pullRefresh(state)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
) {
|
||||
NoticeScreenHeader(stringResource(R.string.favourites_upper), moreIcon = false)
|
||||
}
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)
|
||||
) {
|
||||
items(moments.itemCount) { idx ->
|
||||
val momentItem = moments[idx] ?: return@items
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
.padding(2.dp)
|
||||
.noRippleClickable {
|
||||
navController.navigateToPost(
|
||||
id = momentItem.id,
|
||||
highlightCommentId = 0,
|
||||
initImagePagerIndex = 0
|
||||
)
|
||||
}
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
imageUrl = momentItem.images[0].thumbnail,
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
context = context
|
||||
)
|
||||
if (momentItem.images.size > 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(top = 8.dp, end = 8.dp)
|
||||
.align(Alignment.TopEnd)
|
||||
) {
|
||||
Image(
|
||||
modifier = Modifier.size(24.dp),
|
||||
painter = painterResource(R.drawable.rider_pro_picture_more),
|
||||
contentDescription = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
PullRefreshIndicator(
|
||||
FavouriteListViewModel.isLoading,
|
||||
state,
|
||||
Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.aiosman.ravenow.ui.favourite
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.data.MomentService
|
||||
import com.aiosman.ravenow.entity.MomentEntity
|
||||
import com.aiosman.ravenow.entity.MomentPagingSource
|
||||
import com.aiosman.ravenow.entity.MomentRemoteDataSource
|
||||
import com.aiosman.ravenow.entity.MomentServiceImpl
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object FavouriteListViewModel:ViewModel() {
|
||||
private val momentService: MomentService = MomentServiceImpl()
|
||||
private val _favouriteMomentsFlow =
|
||||
MutableStateFlow<PagingData<MomentEntity>>(PagingData.empty())
|
||||
val favouriteMomentsFlow = _favouriteMomentsFlow.asStateFlow()
|
||||
var isLoading by mutableStateOf(false)
|
||||
fun refreshPager(force:Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
if (force) {
|
||||
isLoading = true
|
||||
}
|
||||
isLoading = false
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
|
||||
pagingSourceFactory = {
|
||||
MomentPagingSource(
|
||||
MomentRemoteDataSource(momentService),
|
||||
favoriteUserId = AppState.UserId
|
||||
)
|
||||
}
|
||||
).flow.cachedIn(viewModelScope).collectLatest {
|
||||
_favouriteMomentsFlow.value = it
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
fun ResetModel() {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.aiosman.ravenow.ui.favourite
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder
|
||||
import com.aiosman.ravenow.ui.like.ActionPostNoticeItem
|
||||
|
||||
/**
|
||||
* 收藏消息界面
|
||||
*/
|
||||
@Composable
|
||||
fun FavouriteNoticeScreen() {
|
||||
|
||||
val model = FavouriteNoticeViewModel
|
||||
val listState = rememberLazyListState()
|
||||
var dataFlow = model.favouriteItemsFlow
|
||||
var favourites = dataFlow.collectAsLazyPagingItems()
|
||||
LaunchedEffect(Unit) {
|
||||
model.reload()
|
||||
model.updateNotice()
|
||||
}
|
||||
StatusBarMaskLayout(
|
||||
darkIcons = !AppState.darkMode,
|
||||
maskBoxBackgroundColor = Color(0xFFFFFFFF)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(color = Color(0xFFFFFFFF))
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
NoticeScreenHeader(
|
||||
stringResource(R.string.favourites_upper),
|
||||
moreIcon = false
|
||||
)
|
||||
}
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f),
|
||||
state = listState,
|
||||
) {
|
||||
items(favourites.itemCount) {
|
||||
val favouriteItem = favourites[it]
|
||||
if (favouriteItem != null) {
|
||||
ActionPostNoticeItem(
|
||||
avatar = favouriteItem.user.avatar,
|
||||
nickName = favouriteItem.user.nickName,
|
||||
likeTime = favouriteItem.favoriteTime,
|
||||
thumbnail = favouriteItem.post.images[0].thumbnail,
|
||||
action = "favourite",
|
||||
userId = favouriteItem.user.id,
|
||||
postId = favouriteItem.post.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
BottomNavigationPlaceholder()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.aiosman.ravenow.ui.favourite
|
||||
|
||||
import android.icu.util.Calendar
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import com.aiosman.ravenow.entity.AccountFavouriteEntity
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.entity.FavoriteItemPagingSource
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.aiosman.ravenow.data.api.UpdateNoticeRequestBody
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 收藏消息列表的 ViewModel
|
||||
*/
|
||||
object FavouriteNoticeViewModel : ViewModel() {
|
||||
private val accountService: AccountService = AccountServiceImpl()
|
||||
private val _favouriteItemsFlow =
|
||||
MutableStateFlow<PagingData<AccountFavouriteEntity>>(PagingData.empty())
|
||||
val favouriteItemsFlow = _favouriteItemsFlow.asStateFlow()
|
||||
var isFirstLoad = true
|
||||
|
||||
fun reload(force: Boolean = false) {
|
||||
if (!isFirstLoad && !force) {
|
||||
return
|
||||
}
|
||||
isFirstLoad = false
|
||||
viewModelScope.launch {
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
|
||||
pagingSourceFactory = {
|
||||
FavoriteItemPagingSource(
|
||||
accountService
|
||||
)
|
||||
}
|
||||
).flow.cachedIn(viewModelScope).collectLatest {
|
||||
_favouriteItemsFlow.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
// 更新收藏消息的查看时间
|
||||
suspend fun updateNotice() {
|
||||
var now = Calendar.getInstance().time
|
||||
accountService.updateNotice(
|
||||
UpdateNoticeRequestBody(
|
||||
lastLookFavouriteTime = ApiClient.formatTime(now)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun ResetModel() {
|
||||
isFirstLoad = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.aiosman.ravenow.ui.follower
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun FollowerListScreen(userId: Int) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val model = FollowerListViewModel
|
||||
val scope = rememberCoroutineScope()
|
||||
val refreshState = rememberPullRefreshState(model.isLoading, onRefresh = {
|
||||
model.loadData(userId, true)
|
||||
})
|
||||
LaunchedEffect(Unit) {
|
||||
model.loadData(userId)
|
||||
}
|
||||
StatusBarMaskLayout(
|
||||
modifier = Modifier
|
||||
.background(color = AppColors.background)
|
||||
.padding(horizontal = 16.dp),
|
||||
maskBoxBackgroundColor = AppColors.background,
|
||||
) {
|
||||
var dataFlow = model.usersFlow
|
||||
var users = dataFlow.collectAsLazyPagingItems()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
NoticeScreenHeader(stringResource(R.string.followers_upper), moreIcon = false)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.pullRefresh(refreshState)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(users.itemCount) { index ->
|
||||
users[index]?.let { user ->
|
||||
FollowItem(
|
||||
avatar = user.avatar,
|
||||
nickname = user.nickName,
|
||||
userId = user.id,
|
||||
isFollowing = user.isFollowing
|
||||
) {
|
||||
scope.launch {
|
||||
if (user.isFollowing) {
|
||||
model.unFollowUser(user.id)
|
||||
} else {
|
||||
model.followUser(user.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PullRefreshIndicator(
|
||||
refreshing = model.isLoading,
|
||||
state = refreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.aiosman.ravenow.ui.follower
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.entity.AccountPagingSource
|
||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object FollowerListViewModel : ViewModel() {
|
||||
private val userService = UserServiceImpl()
|
||||
private val _usersFlow = MutableStateFlow<PagingData<AccountProfileEntity>>(PagingData.empty())
|
||||
val usersFlow = _usersFlow.asStateFlow()
|
||||
private var userId by mutableStateOf<Int?>(null)
|
||||
var isLoading by mutableStateOf(false)
|
||||
fun loadData(id: Int,force : Boolean = false) {
|
||||
if (userId == id && !force) {
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
userId = id
|
||||
viewModelScope.launch {
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
|
||||
pagingSourceFactory = {
|
||||
AccountPagingSource(
|
||||
userService,
|
||||
followerId = id
|
||||
)
|
||||
}
|
||||
).flow.cachedIn(viewModelScope).collectLatest {
|
||||
_usersFlow.value = it
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private fun updateIsFollow(id: Int, isFollow: Boolean = true) {
|
||||
val currentPagingData = usersFlow.value
|
||||
val updatedPagingData = currentPagingData.map { user ->
|
||||
if (user.id == id) {
|
||||
user.copy(isFollowing = isFollow)
|
||||
} else {
|
||||
user
|
||||
}
|
||||
}
|
||||
_usersFlow.value = updatedPagingData
|
||||
}
|
||||
|
||||
suspend fun followUser(userId: Int) {
|
||||
userService.followUser(userId.toString())
|
||||
updateIsFollow(userId)
|
||||
}
|
||||
|
||||
suspend fun unFollowUser(userId: Int) {
|
||||
userService.unFollowUser(userId.toString())
|
||||
updateIsFollow(userId, false)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.aiosman.ravenow.ui.follower
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.aiosman.ravenow.AppState
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.NavigationRoute
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 关注消息列表
|
||||
*/
|
||||
@Composable
|
||||
fun FollowerNoticeScreen() {
|
||||
val scope = rememberCoroutineScope()
|
||||
val AppColors = LocalAppTheme.current
|
||||
StatusBarMaskLayout(
|
||||
modifier = Modifier.background(color = AppColors.background).padding(horizontal = 16.dp),
|
||||
darkIcons = !AppState.darkMode,
|
||||
maskBoxBackgroundColor = AppColors.background
|
||||
) {
|
||||
val model = FollowerNoticeViewModel
|
||||
var dataFlow = model.followerItemsFlow
|
||||
var followers = dataFlow.collectAsLazyPagingItems()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
.background(color = AppColors.background)
|
||||
) {
|
||||
NoticeScreenHeader(stringResource(R.string.followers_upper), moreIcon = false)
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
model.reload()
|
||||
model.updateNotice()
|
||||
}
|
||||
if (followers.itemCount == 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.rider_pro_followers_empty),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(140.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(32.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = "No followers yet",
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = "Share your life and get more followers.",
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W400
|
||||
)
|
||||
}
|
||||
}
|
||||
}else{
|
||||
LazyColumn(
|
||||
modifier = Modifier.weight(1f)
|
||||
.background(color = AppColors.background)
|
||||
) {
|
||||
items(followers.itemCount) { index ->
|
||||
followers[index]?.let { follower ->
|
||||
FollowItem(
|
||||
avatar = follower.avatar,
|
||||
nickname = follower.nickname,
|
||||
userId = follower.userId,
|
||||
isFollowing = follower.isFollowing
|
||||
) {
|
||||
scope.launch {
|
||||
model.followUser(follower.userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Composable
|
||||
fun FollowItem(
|
||||
avatar: String,
|
||||
nickname: String,
|
||||
userId: Int,
|
||||
isFollowing: Boolean,
|
||||
onFollow: () -> Unit = {}
|
||||
) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val context = LocalContext.current
|
||||
val navController = LocalNavController.current
|
||||
Box(
|
||||
modifier = Modifier.padding(vertical = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CustomAsyncImage(
|
||||
context = context,
|
||||
imageUrl = avatar,
|
||||
contentDescription = nickname,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.noRippleClickable {
|
||||
navController.navigate(
|
||||
NavigationRoute.AccountProfile.route.replace(
|
||||
"{id}",
|
||||
userId.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Text(nickname, fontWeight = FontWeight.Bold, fontSize = 16.sp, color = AppColors.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.aiosman.ravenow.ui.follower
|
||||
|
||||
import android.icu.util.Calendar
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import com.aiosman.ravenow.data.AccountFollow
|
||||
import com.aiosman.ravenow.data.AccountService
|
||||
import com.aiosman.ravenow.entity.FollowItemPagingSource
|
||||
import com.aiosman.ravenow.data.AccountServiceImpl
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.data.UserService
|
||||
import com.aiosman.ravenow.data.api.ApiClient
|
||||
import com.aiosman.ravenow.data.api.UpdateNoticeRequestBody
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* 关注消息列表的 ViewModel
|
||||
*/
|
||||
object FollowerNoticeViewModel : ViewModel() {
|
||||
private val accountService: AccountService = AccountServiceImpl()
|
||||
private val userService: UserService = UserServiceImpl()
|
||||
private val _followerItemsFlow =
|
||||
MutableStateFlow<PagingData<AccountFollow>>(PagingData.empty())
|
||||
val followerItemsFlow = _followerItemsFlow.asStateFlow()
|
||||
var isFirstLoad = true
|
||||
|
||||
fun reload(force: Boolean = false) {
|
||||
if (!isFirstLoad && !force) {
|
||||
return
|
||||
}
|
||||
isFirstLoad = false
|
||||
viewModelScope.launch {
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
|
||||
pagingSourceFactory = {
|
||||
FollowItemPagingSource(
|
||||
accountService
|
||||
)
|
||||
}
|
||||
).flow.cachedIn(viewModelScope).collectLatest {
|
||||
_followerItemsFlow.value = it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateIsFollow(id: Int) {
|
||||
val currentPagingData = _followerItemsFlow.value
|
||||
val updatedPagingData = currentPagingData.map { follow ->
|
||||
if (follow.userId == id) {
|
||||
follow.copy(isFollowing = true)
|
||||
} else {
|
||||
follow
|
||||
}
|
||||
}
|
||||
_followerItemsFlow.value = updatedPagingData
|
||||
}
|
||||
suspend fun followUser(userId: Int) {
|
||||
userService.followUser(userId.toString())
|
||||
updateIsFollow(userId)
|
||||
}
|
||||
|
||||
suspend fun updateNotice() {
|
||||
var now = Calendar.getInstance().time
|
||||
accountService.updateNotice(
|
||||
UpdateNoticeRequestBody(
|
||||
lastLookFollowTime = ApiClient.formatTime(now)
|
||||
)
|
||||
)
|
||||
}
|
||||
fun ResetModel() {
|
||||
isFirstLoad = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.aiosman.ravenow.ui.follower
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.ExperimentalMaterialApi
|
||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||
import androidx.compose.material.pullrefresh.pullRefresh
|
||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.paging.compose.collectAsLazyPagingItems
|
||||
import com.aiosman.ravenow.LocalAppTheme
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.comment.NoticeScreenHeader
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterialApi::class)
|
||||
@Composable
|
||||
fun FollowingListScreen(userId: Int) {
|
||||
val AppColors = LocalAppTheme.current
|
||||
val model = FollowingListViewModel
|
||||
val scope = rememberCoroutineScope()
|
||||
val refreshState = rememberPullRefreshState(model.isLoading, onRefresh = {
|
||||
model.loadData(userId, true)
|
||||
})
|
||||
LaunchedEffect(Unit) {
|
||||
model.loadData(userId)
|
||||
}
|
||||
StatusBarMaskLayout(
|
||||
modifier = Modifier
|
||||
.background(color = AppColors.background)
|
||||
.padding(horizontal = 16.dp),
|
||||
maskBoxBackgroundColor = AppColors.background
|
||||
) {
|
||||
var dataFlow = model.usersFlow
|
||||
var users = dataFlow.collectAsLazyPagingItems()
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 16.dp)
|
||||
) {
|
||||
NoticeScreenHeader(stringResource(R.string.following_upper), moreIcon = false)
|
||||
}
|
||||
if(users.itemCount == 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.mipmap.rider_pro_following_empty),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(140.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.size(32.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = "You haven't followed anyone yet",
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W600
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
androidx.compose.material.Text(
|
||||
text = "Click start your social journey.",
|
||||
color = AppColors.text,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.W400
|
||||
)
|
||||
}
|
||||
}
|
||||
}else{
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.pullRefresh(refreshState)
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(users.itemCount) { index ->
|
||||
users[index]?.let { user ->
|
||||
FollowItem(
|
||||
avatar = user.avatar,
|
||||
nickname = user.nickName,
|
||||
userId = user.id,
|
||||
isFollowing = user.isFollowing
|
||||
) {
|
||||
scope.launch {
|
||||
if (user.isFollowing) {
|
||||
model.unfollowUser(user.id)
|
||||
} else {
|
||||
model.followUser(user.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PullRefreshIndicator(
|
||||
refreshing = model.isLoading,
|
||||
state = refreshState,
|
||||
modifier = Modifier.align(Alignment.TopCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.aiosman.ravenow.ui.follower
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.PagingData
|
||||
import androidx.paging.cachedIn
|
||||
import androidx.paging.map
|
||||
import com.aiosman.ravenow.data.UserServiceImpl
|
||||
import com.aiosman.ravenow.entity.AccountPagingSource
|
||||
import com.aiosman.ravenow.entity.AccountProfileEntity
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object FollowingListViewModel : ViewModel() {
|
||||
private val userService = UserServiceImpl()
|
||||
private val _usersFlow = MutableStateFlow<PagingData<AccountProfileEntity>>(PagingData.empty())
|
||||
var isLoading by mutableStateOf(false)
|
||||
val usersFlow = _usersFlow.asStateFlow()
|
||||
private var userId by mutableStateOf<Int?>(null)
|
||||
fun loadData(id: Int, force: Boolean = false) {
|
||||
if (userId == id && !force) {
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
userId = id
|
||||
viewModelScope.launch {
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
|
||||
pagingSourceFactory = {
|
||||
AccountPagingSource(
|
||||
userService,
|
||||
followingId = id
|
||||
)
|
||||
}
|
||||
).flow.cachedIn(viewModelScope).collectLatest {
|
||||
_usersFlow.value = it
|
||||
}
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private fun updateIsFollow(id: Int, isFollow: Boolean = true) {
|
||||
val currentPagingData = usersFlow.value
|
||||
val updatedPagingData = currentPagingData.map { user ->
|
||||
if (user.id == id) {
|
||||
user.copy(isFollowing = isFollow)
|
||||
} else {
|
||||
user
|
||||
}
|
||||
}
|
||||
_usersFlow.value = updatedPagingData
|
||||
}
|
||||
|
||||
suspend fun followUser(userId: Int) {
|
||||
userService.followUser(userId.toString())
|
||||
updateIsFollow(userId)
|
||||
}
|
||||
|
||||
suspend fun unfollowUser(userId: Int) {
|
||||
userService.unFollowUser(userId.toString())
|
||||
updateIsFollow(userId, false)
|
||||
}
|
||||
|
||||
fun ResetModel() {
|
||||
userId = null
|
||||
}
|
||||
|
||||
}
|
||||
278
app/src/main/java/com/aiosman/ravenow/ui/gallery/Gallery.kt
Normal file
278
app/src/main/java/com/aiosman/ravenow/ui/gallery/Gallery.kt
Normal file
@@ -0,0 +1,278 @@
|
||||
package com.aiosman.ravenow.ui.gallery
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.PathEffect
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.R
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ProfileTimelineScreen() {
|
||||
val pagerState = rememberPagerState(pageCount = { 2 })
|
||||
val scope = rememberCoroutineScope()
|
||||
val systemUiController = rememberSystemUiController()
|
||||
fun switchToPage(page: Int) {
|
||||
scope.launch {
|
||||
pagerState.animateScrollToPage(page)
|
||||
}
|
||||
}
|
||||
LaunchedEffect(Unit) {
|
||||
systemUiController.setNavigationBarColor(Color.Transparent)
|
||||
}
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text("Gallery")
|
||||
},
|
||||
navigationIcon = { },
|
||||
actions = { })
|
||||
},
|
||||
) { paddingValues: PaddingValues ->
|
||||
Box(modifier = Modifier.padding(paddingValues)) {
|
||||
Column(modifier = Modifier) {
|
||||
ScrollableTabRow(
|
||||
edgePadding = 0.dp,
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
modifier = Modifier,
|
||||
divider = { },
|
||||
indicator = { tabPositions ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.tabIndicatorOffset(tabPositions[pagerState.currentPage])
|
||||
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.height(4.dp)
|
||||
.width(16.dp)
|
||||
.background(color = Color.Red)
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Tab(
|
||||
text = { Text("Timeline", color = Color.Black) },
|
||||
selected = pagerState.currentPage == 0,
|
||||
onClick = { switchToPage(0) }
|
||||
|
||||
)
|
||||
Tab(
|
||||
text = { Text("Position", color = Color.Black) },
|
||||
selected = pagerState.currentPage == 1,
|
||||
onClick = { switchToPage(1) }
|
||||
)
|
||||
}
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxSize()
|
||||
) { page ->
|
||||
when (page) {
|
||||
0 -> GalleryTimeline()
|
||||
1 -> GalleryPosition()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun GalleryTimeline() {
|
||||
val mockList = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
items(mockList) { item ->
|
||||
TimelineItem()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@Composable
|
||||
fun DashedVerticalLine(modifier: Modifier = Modifier) {
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
Canvas(modifier = Modifier.height(maxHeight)) {
|
||||
val path = Path().apply {
|
||||
moveTo(size.width / 2, 0f)
|
||||
lineTo(size.width / 2, size.height)
|
||||
}
|
||||
drawPath(
|
||||
path = path,
|
||||
color = Color.Gray,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@Composable
|
||||
fun DashedLine() {
|
||||
Canvas(modifier = Modifier
|
||||
.width(1.dp) // 控制线条的宽度
|
||||
.fillMaxHeight()) { // 填满父容器的高度
|
||||
|
||||
val canvasWidth = size.width
|
||||
val canvasHeight = size.height
|
||||
|
||||
// 创建一个PathEffect来定义如何绘制线段
|
||||
val pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 10f), 0f)
|
||||
|
||||
drawLine(
|
||||
color = Color.Gray, // 线条颜色
|
||||
start = Offset(x = canvasWidth / 2, y = 0f), // 起始点
|
||||
end = Offset(x = canvasWidth / 2, y = canvasHeight), // 终点
|
||||
pathEffect = pathEffect // 应用虚线效果
|
||||
)
|
||||
}
|
||||
}
|
||||
@Preview
|
||||
@Composable
|
||||
fun TimelineItem() {
|
||||
val itemsList = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9")
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxSize()
|
||||
.wrapContentWidth()
|
||||
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(64.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text("12", fontSize = 22.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)
|
||||
Text("7月", fontSize = 20.sp,fontWeight = androidx.compose.ui.text.font.FontWeight.Bold)
|
||||
// add vertical dash line
|
||||
// Box(
|
||||
// modifier = Modifier
|
||||
// .height(120.dp)
|
||||
// .width(3.dp)
|
||||
// .background(Color.Gray)
|
||||
// )
|
||||
DashedLine()
|
||||
}
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(bottom = 16.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.default_avatar),
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.padding(end = 16.dp)
|
||||
.size(24.dp)
|
||||
.clip(CircleShape) // Clip the image to a circle
|
||||
)
|
||||
Text("Onyama Limba")
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
) {
|
||||
Column {
|
||||
repeat(3) { // Create three rows
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
repeat(3) { // Create three columns in each row
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(1f) // Keep the aspect ratio 1:1 for square shape
|
||||
.padding(4.dp)
|
||||
){
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Gray)
|
||||
) {
|
||||
Text("1")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GalleryPosition() {
|
||||
val mockList = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
) {
|
||||
items(mockList) { item ->
|
||||
TimelineItem()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package com.aiosman.ravenow.ui.gallery
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
fun OfficialGalleryScreen() {
|
||||
StatusBarMaskLayout {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(start = 16.dp, end = 16.dp)
|
||||
) {
|
||||
OfficialGalleryPageHeader()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
ImageGrid()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OfficialGalleryPageHeader() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.rider_pro_back_icon), // Replace with your logo resource
|
||||
contentDescription = "Logo",
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = "官方摄影师作品", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CertificationSection() {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(Color(0xFFFFF3CD), RoundedCornerShape(8.dp))
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_launcher_foreground), // Replace with your certification icon resource
|
||||
contentDescription = "Certification Icon",
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = "成为认证摄影师", fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
Button(
|
||||
onClick = { /*TODO*/ },
|
||||
|
||||
) {
|
||||
Text(text = "去认证", color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ImageGrid() {
|
||||
val photographers = listOf(
|
||||
Pair(
|
||||
"Diego Morata",
|
||||
R.drawable.rider_pro_moment_demo_1
|
||||
), // Replace with your image resources
|
||||
Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2),
|
||||
Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3),
|
||||
Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1),
|
||||
Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2),
|
||||
Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3),
|
||||
Pair(
|
||||
"Diego Morata",
|
||||
R.drawable.rider_pro_moment_demo_1
|
||||
), // Replace with your image resources
|
||||
Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2),
|
||||
Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3),
|
||||
Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1),
|
||||
Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2),
|
||||
Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3),
|
||||
Pair(
|
||||
"Diego Morata",
|
||||
R.drawable.rider_pro_moment_demo_1
|
||||
), // Replace with your image resources
|
||||
Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2),
|
||||
Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3),
|
||||
Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1),
|
||||
Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2),
|
||||
Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3),
|
||||
Pair(
|
||||
"Diego Morata",
|
||||
R.drawable.rider_pro_moment_demo_1
|
||||
), // Replace with your image resources
|
||||
Pair("Usha Oliver", R.drawable.rider_pro_moment_demo_2),
|
||||
Pair("Mohsen Salehi", R.drawable.rider_pro_moment_demo_3),
|
||||
Pair("Thanawan Chadee", R.drawable.rider_pro_moment_demo_1),
|
||||
Pair("Photographer 5", R.drawable.rider_pro_moment_demo_2),
|
||||
Pair("Photographer 6", R.drawable.rider_pro_moment_demo_3)
|
||||
)
|
||||
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(photographers.size) { index ->
|
||||
PhotographerCard(photographers[index].first, photographers[index].second)
|
||||
}
|
||||
item{
|
||||
BottomNavigationPlaceholder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PhotographerCard(name: String, imageRes: Int) {
|
||||
val navController = LocalNavController.current
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.LightGray)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = imageRes),
|
||||
contentDescription = name,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(270.dp)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color(0x55000000))
|
||||
.align(Alignment.BottomStart)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
.clickable {
|
||||
navController.navigate("OfficialPhotographer")
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.default_avatar), // Replace with your profile picture resource
|
||||
contentDescription = "Profile Picture",
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = name, color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package com.aiosman.ravenow.ui.gallery
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import com.aiosman.ravenow.ui.composables.BottomNavigationPlaceholder
|
||||
|
||||
data class ArtWork(
|
||||
val id: Int,
|
||||
val resId: Int,
|
||||
)
|
||||
|
||||
fun GenerateMockArtWorks(): List<ArtWork> {
|
||||
val pickupImage = listOf(
|
||||
R.drawable.default_avatar,
|
||||
R.drawable.default_moment_img,
|
||||
R.drawable.rider_pro_moment_demo_1,
|
||||
R.drawable.rider_pro_moment_demo_2,
|
||||
R.drawable.rider_pro_moment_demo_3,
|
||||
)
|
||||
return List(30) {
|
||||
ArtWork(
|
||||
id = it,
|
||||
resId = pickupImage.random()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Preview
|
||||
@Composable
|
||||
fun OfficialPhotographerScreen() {
|
||||
val lazyListState = rememberLazyListState()
|
||||
var artWorks by remember { mutableStateOf<List<ArtWork>>(emptyList()) }
|
||||
LaunchedEffect(Unit) {
|
||||
artWorks = GenerateMockArtWorks()
|
||||
}
|
||||
// Observe the scroll state and calculate opacity
|
||||
val alpha by remember {
|
||||
derivedStateOf {
|
||||
// Example calculation: Adjust the range and formula as needed
|
||||
val alp = minOf(1f, lazyListState.firstVisibleItemScrollOffset / 900f)
|
||||
Log.d("alpha", "alpha: $alp")
|
||||
alp
|
||||
}
|
||||
}
|
||||
StatusBarMaskLayout(
|
||||
maskBoxBackgroundColor = Color.Black,
|
||||
darkIcons = false
|
||||
) {
|
||||
Column {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color.Black)
|
||||
|
||||
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = lazyListState
|
||||
) {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(400.dp)
|
||||
.fillMaxWidth()
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.default_moment_img),
|
||||
contentDescription = "Logo",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
// dark alpha overlay
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = alpha))
|
||||
)
|
||||
|
||||
// on bottom of box
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(120.dp)
|
||||
.background(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Black.copy(alpha = 1f),
|
||||
Color.Black.copy(alpha = 1f),
|
||||
Color.Black.copy(alpha = 0f),
|
||||
),
|
||||
startY = Float.POSITIVE_INFINITY,
|
||||
endY = 0f
|
||||
)
|
||||
)
|
||||
.padding(16.dp)
|
||||
.align(alignment = Alignment.BottomCenter)
|
||||
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.default_avatar),
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape) // Clip the image to a circle
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
// name
|
||||
Text("Onyama Limba", color = Color.White, fontSize = 14.sp)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
// round box
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(Color.Red, CircleShape)
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
// certification
|
||||
Text("摄影师", color = Color.White, fontSize = 12.sp)
|
||||
}
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
IconButton(
|
||||
onClick = { /*TODO*/ },
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Favorite,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("123", color = Color.White)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
IconButton(
|
||||
onClick = {},
|
||||
modifier = Modifier.size(32.dp)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_eye),
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Text("123", color = Color.White)
|
||||
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
) {
|
||||
// description
|
||||
Text(
|
||||
"摄影师 Diego Morata 的作品",
|
||||
color = Color.White,
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// circle avatar
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val imageSize =
|
||||
(screenWidth - (4.dp * 4)) / 3 // Subtracting total padding and divi
|
||||
val itemWidth = screenWidth / 3 - 4.dp * 2
|
||||
FlowRow(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
maxItemsInEachRow = 3
|
||||
) {
|
||||
for (artWork in artWorks) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(itemWidth)
|
||||
.aspectRatio(1f)
|
||||
.background(Color.Gray)
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = artWork.resId),
|
||||
contentDescription = "",
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.width(imageSize)
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
}
|
||||
}
|
||||
BottomNavigationPlaceholder()
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(64.dp)
|
||||
.background(Color.Black.copy(alpha = alpha))
|
||||
.padding(horizontal = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.rider_pro_back_icon),
|
||||
colorFilter = ColorFilter.tint(Color.White),
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape) // Clip the image to a circle
|
||||
)
|
||||
if (alpha == 1f) {
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.default_avatar),
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clip(CircleShape) // Clip the image to a circle
|
||||
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("Onyama Limba", color = Color.White)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.aiosman.ravenow.ui.imageviewer
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.aiosman.ravenow.entity.MomentImageEntity
|
||||
|
||||
object ImageViewerViewModel:ViewModel() {
|
||||
var imageList = mutableListOf<MomentImageEntity>()
|
||||
var initialIndex = 0
|
||||
fun asNew(images: List<MomentImageEntity>, index: Int = 0) {
|
||||
imageList.clear()
|
||||
imageList.addAll(images)
|
||||
initialIndex = index
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.CircularProgressIndicator
|
||||
import androidx.compose.material.Icon
|
||||
import androidx.compose.material.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.aiosman.ravenow.LocalNavController
|
||||
import com.aiosman.ravenow.R
|
||||
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
|
||||
import com.aiosman.ravenow.ui.composables.StatusBarMaskLayout
|
||||
import com.aiosman.ravenow.ui.imageviewer.ImageViewerViewModel
|
||||
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
|
||||
import com.aiosman.ravenow.utils.FileUtil.saveImageToGallery
|
||||
import kotlinx.coroutines.launch
|
||||
import net.engawapg.lib.zoomable.rememberZoomState
|
||||
import net.engawapg.lib.zoomable.zoomable
|
||||
|
||||
|
||||
@OptIn(
|
||||
ExperimentalFoundationApi::class,
|
||||
)
|
||||
@Composable
|
||||
fun ImageViewer() {
|
||||
val model = ImageViewerViewModel
|
||||
val images = model.imageList
|
||||
val pagerState =
|
||||
rememberPagerState(pageCount = { images.size }, initialPage = model.initialIndex)
|
||||
val navController = LocalNavController.current
|
||||
val context = LocalContext.current
|
||||
val navigationBarPaddings =
|
||||
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp
|
||||
val scope = rememberCoroutineScope()
|
||||
val showRawImageStates = remember { mutableStateListOf(*Array(images.size) { false }) }
|
||||
var isDownloading by remember { mutableStateOf(false) }
|
||||
var currentPage by remember { mutableStateOf(model.initialIndex) }
|
||||
LaunchedEffect(pagerState) {
|
||||
currentPage = pagerState.currentPage
|
||||
}
|
||||
StatusBarMaskLayout(
|
||||
modifier = Modifier.background(Color.Black),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(0.8f),
|
||||
) { page ->
|
||||
val zoomState = rememberZoomState()
|
||||
CustomAsyncImage(
|
||||
context,
|
||||
if (showRawImageStates[page]) images[page].url else images[page].thumbnail,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zoomable(
|
||||
zoomState = zoomState,
|
||||
onTap = {
|
||||
navController.navigateUp()
|
||||
}
|
||||
)
|
||||
,
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.padding(top = 10.dp, bottom = 10.dp)){
|
||||
if (images.size > 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(Color(0xff333333).copy(alpha = 0.6f))
|
||||
.padding(vertical = 4.dp, horizontal = 24.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "${pagerState.currentPage + 1}/${images.size}",
|
||||
color = Color.White,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(0.2f)
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black
|
||||
),
|
||||
)
|
||||
)
|
||||
.padding(start = 16.dp, end = 16.dp, bottom = navigationBarPaddings),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 72.dp, end = 72.dp)
|
||||
.padding(top = 16.dp),
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.noRippleClickable {
|
||||
if (isDownloading) {
|
||||
return@noRippleClickable
|
||||
}
|
||||
isDownloading = true
|
||||
scope.launch {
|
||||
saveImageToGallery(context, images[pagerState.currentPage].url)
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (isDownloading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.rider_pro_download_icon),
|
||||
contentDescription = "",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.download),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
if (!showRawImageStates[pagerState.currentPage]) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
if (!showRawImageStates[pagerState.currentPage]) {
|
||||
Column(
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.noRippleClickable {
|
||||
showRawImageStates[pagerState.currentPage] = true
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.rider_pro_original_raw),
|
||||
contentDescription = "",
|
||||
modifier = Modifier.size(32.dp),
|
||||
tint = Color.White
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
stringResource(R.string.original),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user