Merge pull request #85 from Kevinlinpr/nagisa

修复多个界面和权限相关问题
This commit is contained in:
2025-11-19 23:50:17 +08:00
committed by GitHub
7 changed files with 328 additions and 162 deletions

View File

@@ -1,5 +1,6 @@
package com.aiosman.ravenow.entity
import android.util.Log
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.aiosman.ravenow.data.ListContainer
@@ -272,19 +273,41 @@ class RoomRemoteDataSource {
pageSize: Int = 20,
search: String
): ListContainer<RoomEntity>? {
return try {
val resp = ApiClient.api.getRooms(
page = pageNumber,
pageSize = pageSize,
search = search,
roomType = "public" // 搜索时只显示公有房间
)
if (!resp.isSuccessful) {
// API 调用失败,返回 null
return null
}
val body = resp.body() ?: return null
return ListContainer(
// 安全地转换数据,过滤掉转换失败的项目
val roomList = body.list.mapNotNull { room ->
try {
room.toRoomtEntity()
} catch (e: Exception) {
// 如果某个房间数据转换失败,记录错误但继续处理其他房间
Log.e("RoomRemoteDataSource", "Failed to convert room: ${room.id}", e)
null
}
}
ListContainer(
total = body.total,
page = pageNumber,
pageSize = pageSize,
list = body.list.map { it.toRoomtEntity() }
list = roomList
)
} catch (e: Exception) {
// 捕获所有异常,返回 null 让 PagingSource 处理
Log.e("RoomRemoteDataSource", "searchRooms error", e)
null
}
}
}
@@ -303,17 +326,31 @@ class RoomSearchPagingSource(
pageSize = params.loadSize,
search = keyword
)
if (rooms == null) {
// API 调用失败,返回空列表
LoadResult.Page(
data = rooms?.list ?: listOf(),
data = emptyList(),
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = if (rooms?.list?.isNotEmpty() == true) currentPage + 1 else null
nextKey = null
)
} catch (exception: IOException) {
} else {
LoadResult.Page(
data = rooms.list,
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = if (rooms.list.isNotEmpty() && rooms.list.size >= params.loadSize) currentPage + 1 else null
)
}
} catch (exception: Exception) {
// 捕获所有异常,包括 IOException、ServiceException 等
LoadResult.Error(exception)
}
}
override fun getRefreshKey(state: PagingState<Int, RoomEntity>): Int? {
return state.anchorPosition
// 更健壮的实现:根据 anchorPosition 计算刷新键
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}

View File

@@ -33,6 +33,8 @@ import com.aiosman.ravenow.event.MomentFavouriteChangeEvent
import com.aiosman.ravenow.event.MomentLikeChangeEvent
import com.aiosman.ravenow.event.MomentRemoveEvent
import com.aiosman.ravenow.data.PointService
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.launch
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
@@ -308,8 +310,9 @@ object MyProfileViewModel : ViewModel() {
* 加载房间列表
* @param filterType 筛选类型0=全部1=公开2=私有
* @param pullRefresh 是否下拉刷新
* @param ownerSessionId 创建者用户IDChatAIID用于过滤特定创建者的房间。如果为null则显示当前用户创建或加入的房间
*/
fun loadRooms(filterType: Int = 0, pullRefresh: Boolean = false) {
fun loadRooms(filterType: Int = 0, pullRefresh: Boolean = false, ownerSessionId: String? = null) {
// 游客模式下不加载房间列表
if (AppStore.isGuest) {
Log.d("MyProfileViewModel", "loadRooms: 游客模式下跳过加载房间列表")
@@ -331,43 +334,96 @@ object MyProfileViewModel : ViewModel() {
roomsCurrentPage
}
val response = when (filterType) {
0 -> {
// 全部:显示自己创建或加入的所有房间
apiClient.getRooms(
// 根据filterType确定roomType
val roomType = when (filterType) {
1 -> "public"
2 -> "private"
else -> null
}
// 构建API调用参数
if (ownerSessionId != null) {
// 查看其他用户的房间:显示该用户创建和加入的房间
// 1. 先快速获取该用户创建的房间(不需要 includeUsers减少数据量
val createdResponse = apiClient.getRooms(
page = currentPage,
pageSize = roomsPageSize,
roomType = roomType,
ownerSessionId = ownerSessionId,
includeUsers = false
)
val createdRooms = if (createdResponse.isSuccessful) {
createdResponse.body()?.list?.map { it.toRoomtEntity() } ?: emptyList()
} else {
emptyList()
}
// 先快速显示创建的房间,提升用户体验
if (pullRefresh || currentPage == 1) {
rooms = createdRooms
} else {
rooms = rooms + createdRooms
}
// 处理分页(基于创建的房间)
val total = createdResponse.body()?.total ?: 0L
roomsHasMore = rooms.size < total
if (roomsHasMore && !pullRefresh) {
roomsCurrentPage++
}
// 2. 后台异步获取该用户加入的房间(仅在非私有房间时获取,且只在第一页时获取以提升性能)
if (filterType != 2 && currentPage == 1) {
launch {
try {
// 获取公开房间,但限制数量以减少数据量
val joinedResponse = apiClient.getRooms(
page = 1,
pageSize = roomsPageSize,
roomType = if (filterType == 1) "public" else null,
includeUsers = true // 需要成员信息来判断
)
if (joinedResponse.isSuccessful) {
val joinedRooms = joinedResponse.body()?.list?.mapNotNull { room ->
try {
val entity = room.toRoomtEntity()
// 检查房间的创建者是否是该用户
val isCreatedByUser = entity.creator.profile.chatAIId == ownerSessionId
// 检查房间的成员是否包含该用户
val isMember = entity.users.any { it.profile.chatAIId == ownerSessionId }
// 如果用户是成员但不是创建者,则认为是加入的房间
if (isMember && !isCreatedByUser) {
entity
} else {
null
}
} catch (e: Exception) {
Log.e("MyProfileViewModel", "处理房间失败: ${e.message}")
null
}
} ?: emptyList()
// 合并并去重基于房间ID然后更新列表
val allRooms = (rooms + joinedRooms).distinctBy { it.id }
rooms = allRooms
}
} catch (e: Exception) {
Log.e("MyProfileViewModel", "获取加入的房间失败: ${e.message}")
}
}
}
} else {
// 查看自己的房间:显示创建或加入的房间
val response = apiClient.getRooms(
page = currentPage,
pageSize = roomsPageSize,
roomType = roomType,
showCreated = true,
showJoined = true
showJoined = if (filterType == 2) null else true // 私有房间不显示加入的
)
}
1 -> {
// 公开:显示公开房间中自己创建或加入的
apiClient.getRooms(
page = currentPage,
pageSize = roomsPageSize,
roomType = "public",
showCreated = true,
showJoined = true
)
}
2 -> {
// 私有:显示自己创建或加入的私有房间
apiClient.getRooms(
page = currentPage,
pageSize = roomsPageSize,
roomType = "private"
)
}
else -> {
apiClient.getRooms(
page = currentPage,
pageSize = roomsPageSize,
showCreated = true,
showJoined = true
)
}
}
if (response.isSuccessful) {
val roomList = response.body()?.list ?: emptyList()
@@ -386,6 +442,7 @@ object MyProfileViewModel : ViewModel() {
} else {
Log.e("MyProfileViewModel", "loadRooms failed: ${response.code()}")
}
}
} catch (e: Exception) {
Log.e("MyProfileViewModel", "loadRooms error: ", e)
} finally {
@@ -398,20 +455,22 @@ object MyProfileViewModel : ViewModel() {
/**
* 加载更多房间
* @param filterType 筛选类型0=全部1=公开2=私有
* @param ownerSessionId 创建者用户IDChatAIID用于过滤特定创建者的房间
*/
fun loadMoreRooms(filterType: Int = 0) {
fun loadMoreRooms(filterType: Int = 0, ownerSessionId: String? = null) {
if (roomsLoading || !roomsHasMore) return
loadRooms(filterType = filterType, pullRefresh = false)
loadRooms(filterType = filterType, pullRefresh = false, ownerSessionId = ownerSessionId)
}
/**
* 刷新房间列表
* @param filterType 筛选类型0=全部1=公开2=私有
* @param ownerSessionId 创建者用户IDChatAIID用于过滤特定创建者的房间
*/
fun refreshRooms(filterType: Int = 0) {
fun refreshRooms(filterType: Int = 0, ownerSessionId: String? = null) {
rooms = emptyList()
roomsCurrentPage = 1
roomsHasMore = true
loadRooms(filterType = filterType, pullRefresh = true)
loadRooms(filterType = filterType, pullRefresh = true, ownerSessionId = ownerSessionId)
}
}

View File

@@ -533,22 +533,29 @@ fun ProfileV3(
showNoMoreText = isSelf,
modifier = Modifier.fillMaxSize(),
state = listState,
nestedScrollConnection = nestedScrollConnection
nestedScrollConnection = nestedScrollConnection,
showSegments = isSelf // 只有查看自己的主页时才显示分段控制器
)
} else {
// 查看其他用户的主页时传递该用户的chatAIId以显示其创建的群聊查看自己的主页时传递null
GroupChatPlaceholder(
modifier = Modifier.fillMaxSize(),
listState = groupChatListState,
nestedScrollConnection = nestedScrollConnection
nestedScrollConnection = nestedScrollConnection,
ownerSessionId = if (!isSelf) profile?.chatAIId else null,
showSegments = isSelf // 只有查看自己的主页时才显示分段控制器
)
}
}
2 -> {
if (!isAiAccount) {
// 查看其他用户的主页时传递该用户的chatAIId以显示其创建的群聊查看自己的主页时传递null
GroupChatPlaceholder(
modifier = Modifier.fillMaxSize(),
listState = groupChatListState,
nestedScrollConnection = nestedScrollConnection
nestedScrollConnection = nestedScrollConnection,
ownerSessionId = if (!isSelf) profile?.chatAIId else null,
showSegments = isSelf // 只有查看自己的主页时才显示分段控制器
)
}
}
@@ -656,12 +663,16 @@ fun ProfileV3(
private fun GroupChatPlaceholder(
modifier: Modifier = Modifier,
listState: androidx.compose.foundation.lazy.LazyListState,
nestedScrollConnection: NestedScrollConnection? = null
nestedScrollConnection: NestedScrollConnection? = null,
ownerSessionId: String? = null, // 创建者用户IDChatAIID用于过滤特定创建者的房间。如果为null则显示当前用户创建或加入的房间
showSegments: Boolean = true // 是否显示分段控制器(全部、公开、私有)
) {
GroupChatEmptyContent(
modifier = modifier,
listState = listState,
nestedScrollConnection = nestedScrollConnection
nestedScrollConnection = nestedScrollConnection,
ownerSessionId = ownerSessionId,
showSegments = showSegments
)
}

View File

@@ -1,47 +1,47 @@
package com.aiosman.ravenow.ui.index.tabs.profile.composable
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.R
import androidx.compose.material3.Text
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.aiosman.ravenow.LocalAppTheme
import com.aiosman.ravenow.LocalNavController
import com.aiosman.ravenow.R
import com.aiosman.ravenow.entity.MomentEntity
import com.aiosman.ravenow.ui.composables.CustomAsyncImage
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import com.aiosman.ravenow.ui.navigateToPost
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import com.aiosman.ravenow.AppState
@@ -227,8 +227,20 @@ fun GalleryGrid(
.padding(bottom = 8.dp),
) {
itemsIndexed(moments) { idx, moment ->
if (moment != null && moment.images.isNotEmpty()) {
moment?.let { momentItem ->
val itemDebouncer = rememberDebouncer()
val isVideoMoment = momentItem.images.isEmpty() && !momentItem.videos.isNullOrEmpty()
val previewUrl = when {
momentItem.images.isNotEmpty() -> momentItem.images[0].thumbnail
isVideoMoment -> {
val firstVideo = momentItem.videos!!.first()
firstVideo.thumbnailDirectUrl
?: firstVideo.thumbnailUrl
?: firstVideo.directUrl
?: firstVideo.url
}
else -> null
}
Box(
modifier = Modifier
.fillMaxWidth()
@@ -237,31 +249,29 @@ fun GalleryGrid(
.noRippleClickable {
itemDebouncer {
navController.navigateToPost(
id = moment.id,
id = momentItem.id,
highlightCommentId = 0,
initImagePagerIndex = 0
)
}
}
) {
if (previewUrl != null) {
CustomAsyncImage(
imageUrl = moment.images[0].thumbnail,
imageUrl = previewUrl,
contentDescription = "",
modifier = Modifier.fillMaxSize(),
context = LocalContext.current
)
if (moment.images.size > 1) {
} else {
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 = "",
.fillMaxSize()
.background(
color = AppColors.basicMain.copy(alpha = 0.2f),
shape = RoundedCornerShape(10.dp)
)
)
}
}
}
}

View File

@@ -69,7 +69,9 @@ import android.util.Base64
fun GroupChatEmptyContent(
modifier: Modifier = Modifier,
listState: LazyListState,
nestedScrollConnection: NestedScrollConnection? = null
nestedScrollConnection: NestedScrollConnection? = null,
ownerSessionId: String? = null, // 创建者用户IDChatAIID用于过滤特定创建者的房间。如果为null则显示当前用户创建或加入的房间
showSegments: Boolean = true // 是否显示分段控制器(全部、公开、私有)
) {
var selectedSegment by remember { mutableStateOf(0) } // 0: 全部, 1: 公开, 2: 私有
val AppColors = LocalAppTheme.current
@@ -77,24 +79,19 @@ fun GroupChatEmptyContent(
val navController = LocalNavController.current
val viewModel = MyProfileViewModel
// 如果查看其他用户的房间固定使用全部类型filterType = 0
val filterType = if (showSegments) selectedSegment else 0
val state = rememberPullRefreshState(
refreshing = viewModel.roomsRefreshing,
onRefresh = {
viewModel.refreshRooms(filterType = selectedSegment)
viewModel.refreshRooms(filterType = filterType, ownerSessionId = ownerSessionId)
}
)
// 当分段改变时,重新加载数据
LaunchedEffect(selectedSegment) {
// 切换分段时重新加载
viewModel.refreshRooms(filterType = selectedSegment)
}
// 初始加载
LaunchedEffect(Unit) {
if (viewModel.rooms.isEmpty() && !viewModel.roomsLoading) {
viewModel.loadRooms(filterType = selectedSegment)
}
// 当分段或用户ID改变时,重新加载数据
LaunchedEffect(selectedSegment, ownerSessionId, showSegments) {
viewModel.refreshRooms(filterType = filterType, ownerSessionId = ownerSessionId)
}
val nestedScrollModifier = if (nestedScrollConnection != null) {
@@ -110,7 +107,8 @@ fun GroupChatEmptyContent(
) {
Spacer(modifier = Modifier.height(16.dp))
// 分段控制器
// 只在查看自己的房间时显示分段控制器
if (showSegments) {
SegmentedControl(
selectedIndex = selectedSegment,
onSegmentSelected = {
@@ -121,6 +119,7 @@ fun GroupChatEmptyContent(
)
Spacer(modifier = Modifier.height(8.dp))
}
Box(
modifier = nestedScrollModifier
@@ -201,7 +200,7 @@ fun GroupChatEmptyContent(
if (viewModel.roomsHasMore && !viewModel.roomsLoading) {
item {
LaunchedEffect(Unit) {
viewModel.loadMoreRooms(filterType = selectedSegment)
viewModel.loadMoreRooms(filterType = filterType, ownerSessionId = ownerSessionId)
}
}
}
@@ -372,7 +371,7 @@ private fun SegmentButton(
},
shape = RoundedCornerShape(1000.dp)
)
.clickable(onClick = onClick),
.noRippleClickable { onClick() },
contentAlignment = Alignment.Center
) {
Text(

View File

@@ -4,6 +4,7 @@ import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import com.aiosman.ravenow.ui.modifiers.noRippleClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -66,7 +67,8 @@ fun UserAgentsList(
showNoMoreText: Boolean = false,
modifier: Modifier = Modifier,
state: LazyListState,
nestedScrollConnection: NestedScrollConnection? = null
nestedScrollConnection: NestedScrollConnection? = null,
showSegments: Boolean = true // 是否显示分段控制器(全部、公开、私有)
) {
val AppColors = LocalAppTheme.current
val listModifier = if (nestedScrollConnection != null) {
@@ -80,7 +82,7 @@ fun UserAgentsList(
Box(
modifier = listModifier.fillMaxSize()
) {
AgentEmptyContentWithSegments()
AgentEmptyContentWithSegments(showSegments = showSegments)
}
} else {
LazyColumn(
@@ -248,7 +250,9 @@ fun UserAgentCard(
}
@Composable
fun AgentEmptyContentWithSegments() {
fun AgentEmptyContentWithSegments(
showSegments: Boolean = true // 是否显示分段控制器(全部、公开、私有)
) {
var selectedSegment by remember { mutableStateOf(0) } // 0: 全部, 1: 公开, 2: 私有
val AppColors = LocalAppTheme.current
val isNetworkAvailable = NetworkUtils.isNetworkAvailable(LocalContext.current)
@@ -260,7 +264,8 @@ fun AgentEmptyContentWithSegments() {
) {
Spacer(modifier = Modifier.height(16.dp))
// 分段控制器
// 只在查看自己的智能体时显示分段控制器
if (showSegments) {
AgentSegmentedControl(
selectedIndex = selectedSegment,
onSegmentSelected = { selectedSegment = it },
@@ -268,6 +273,7 @@ fun AgentEmptyContentWithSegments() {
)
Spacer(modifier = Modifier.height(8.dp))
}
// 空状态内容(与动态、群聊保持一致)
Column(
@@ -397,7 +403,7 @@ private fun AgentSegmentButton(
},
shape = RoundedCornerShape(1000.dp)
)
.clickable(onClick = onClick),
.noRippleClickable { onClick() },
contentAlignment = Alignment.Center
) {
Text(

View File

@@ -1,9 +1,12 @@
package com.aiosman.ravenow.ui.post
import android.Manifest
import android.content.pm.PackageManager
import android.net.Uri
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloatAsState
@@ -588,6 +591,34 @@ fun AddImageGrid() {
}
}
// 摄像头权限请求
val requestCameraPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
// 权限已授予,打开相机
if (model.imageList.size < 9) {
val photoFile = File(context.cacheDir, "photo.jpg")
val photoUri: Uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
photoFile
)
model.currentPhotoUri = photoUri
takePictureLauncher.launch(photoUri)
} else {
Toast.makeText(context, "最多只能选择9张图片", Toast.LENGTH_SHORT).show()
}
} else {
// 权限被拒绝,提示用户
Toast.makeText(
context,
"需要摄像头权限才能拍摄照片,请在设置中开启",
Toast.LENGTH_LONG
).show()
}
}
val addImageDebouncer = rememberDebouncer()
val canAddMoreImages = model.imageList.size < 9
@@ -642,6 +673,13 @@ fun AddImageGrid() {
.background(Color(0xFFFAF9FB))
.noRippleClickable {
if (model.imageList.size < 9) {
// 检查摄像头权限
when {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED -> {
// 已有权限,直接打开相机
val photoFile = File(context.cacheDir, "photo.jpg")
val photoUri: Uri = FileProvider.getUriForFile(
context,
@@ -650,6 +688,12 @@ fun AddImageGrid() {
)
model.currentPhotoUri = photoUri
takePictureLauncher.launch(photoUri)
}
else -> {
// 没有权限,请求权限
requestCameraPermissionLauncher.launch(Manifest.permission.CAMERA)
}
}
} else {
Toast.makeText(context, "最多只能选择9张图片", Toast.LENGTH_SHORT).show()
}