新增关注和粉丝列表

- 新增关注和粉丝列表页面
- 新增关注和粉丝列表ViewModel
- 更新UserService以支持获取关注和粉丝列表

- 更新RiderProAPI以支持获取关注和粉丝列表
- 更新Profile页面以支持跳转到关注和粉丝列表页面
- 更新Navi以支持关注和粉丝列表页面导航
- 更新UserInformationFollowers和UserInformationFollowing以支持跳转到关注和粉丝列表页面
- 更新MyProfileViewModel
以支持更新用户资料横幅
This commit is contained in:
2024-09-06 01:55:12 +08:00
parent 37dcd19227
commit e936f9cb77
12 changed files with 412 additions and 46 deletions

View File

@@ -36,12 +36,16 @@ interface UserService {
* @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
nickname: String? = null,
followerId: Int? = null,
followingId: Int? = null
): ListContainer<AccountProfileEntity>
}
@@ -66,15 +70,23 @@ class UserServiceImpl : UserService {
override suspend fun getUsers(
pageSize: Int,
page: Int,
nickname: String?
nickname: String?,
followerId: Int?,
followingId: Int?
): ListContainer<AccountProfileEntity> {
val resp = ApiClient.api.getUsers(page, pageSize, nickname)
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
pageSize = body.pageSize,
)
}
}

View File

@@ -85,6 +85,7 @@ data class RegisterMessageChannelRequestBody(
@SerializedName("identifier")
val identifier: String,
)
interface RiderProAPI {
@POST("register")
suspend fun register(@Body body: RegisterRequestBody): Response<Unit>
@@ -243,6 +244,8 @@ interface RiderProAPI {
@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")

View File

@@ -10,14 +10,18 @@ import java.io.IOException
*/
class AccountPagingSource(
private val userService: UserService,
private val nickname: String? = null
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
nickname = nickname,
followerId = followerId,
followingId = followingId
)
LoadResult.Page(
data = users.list,

View File

@@ -27,11 +27,12 @@ import androidx.navigation.navArgument
import com.aiosman.riderpro.LocalAnimatedContentScope
import com.aiosman.riderpro.LocalNavController
import com.aiosman.riderpro.LocalSharedTransitionScope
import com.aiosman.riderpro.ui.account.AccountEditScreen
import com.aiosman.riderpro.ui.account.AccountEditScreen2
import com.aiosman.riderpro.ui.comment.CommentsScreen
import com.aiosman.riderpro.ui.favourite.FavouriteScreen
import com.aiosman.riderpro.ui.follower.FollowerScreen
import com.aiosman.riderpro.ui.follower.FollowerListScreen
import com.aiosman.riderpro.ui.follower.FollowerNotificationScreen
import com.aiosman.riderpro.ui.follower.FollowingListScreen
import com.aiosman.riderpro.ui.gallery.OfficialGalleryScreen
import com.aiosman.riderpro.ui.gallery.OfficialPhotographerScreen
import com.aiosman.riderpro.ui.gallery.ProfileTimelineScreen
@@ -77,6 +78,8 @@ sealed class NavigationRoute(
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}")
}
@@ -158,7 +161,7 @@ fun NavigationController(
LikeScreen()
}
composable(route = NavigationRoute.Followers.route) {
FollowerScreen()
FollowerNotificationScreen()
}
composable(
route = NavigationRoute.NewPost.route,
@@ -228,6 +231,27 @@ fun NavigationController(
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")!!)
}
}
}

View File

@@ -0,0 +1,57 @@
package com.aiosman.riderpro.ui.follower
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
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.riderpro.R
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader
import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout
import kotlinx.coroutines.launch
@Composable
fun FollowerListScreen(userId: Int) {
val model = FollowerListViewModel
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
model.loadData(userId)
}
StatusBarMaskLayout(
modifier = Modifier.padding(horizontal = 16.dp)
) {
var dataFlow = model.usersFlow
var users = dataFlow.collectAsLazyPagingItems()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
NoticeScreenHeader(stringResource(R.string.followers_upper), moreIcon = false)
}
LazyColumn(
modifier = Modifier.weight(1f)
) {
items(users.itemCount) { index ->
users[index]?.let { user ->
FollowItem(
avatar = user.avatar,
nickname = user.nickName,
userId = user.id,
isFollowing = user.isFollowing
) {
scope.launch {
model.followUser(user.id)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
package com.aiosman.riderpro.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.riderpro.data.UserServiceImpl
import com.aiosman.riderpro.entity.AccountPagingSource
import com.aiosman.riderpro.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)
fun loadData(id: Int) {
if (userId == id) {
return
}
userId = id
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
AccountPagingSource(
userService,
followerId = id
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_usersFlow.value = it
}
}
}
private fun updateIsFollow(id: Int) {
val currentPagingData = usersFlow.value
val updatedPagingData = currentPagingData.map { user ->
if (user.id == id) {
user.copy(isFollowing = true)
} else {
user
}
}
_usersFlow.value = updatedPagingData
}
suspend fun followUser(userId: Int) {
userService.followUser(userId.toString())
updateIsFollow(userId)
}
}

View File

@@ -24,7 +24,7 @@ import kotlinx.coroutines.launch
/**
* 关注消息列表的 ViewModel
*/
object FollowerViewModel : ViewModel() {
object FollowerNoticeViewModel : ViewModel() {
private val accountService: AccountService = AccountServiceImpl()
private val userService: UserService = UserServiceImpl()
private val _followerItemsFlow =

View File

@@ -22,13 +22,11 @@ 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.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.riderpro.LocalNavController
import com.aiosman.riderpro.R
import com.aiosman.riderpro.data.AccountFollow
import com.aiosman.riderpro.ui.NavigationRoute
import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader
@@ -40,19 +38,18 @@ import kotlinx.coroutines.launch
* 关注消息列表
*/
@Composable
fun FollowerScreen() {
fun FollowerNotificationScreen() {
val scope = rememberCoroutineScope()
StatusBarMaskLayout(
modifier = Modifier.padding(horizontal = 16.dp)
) {
val model = FollowerViewModel
val model = FollowerNoticeViewModel
var dataFlow = model.followerItemsFlow
var followers = dataFlow.collectAsLazyPagingItems()
Box(
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp)
) {
NoticeScreenHeader(stringResource(R.string.followers_upper), moreIcon = false)
}
LaunchedEffect(Unit) {
model.updateNotice()
@@ -62,7 +59,12 @@ fun FollowerScreen() {
) {
items(followers.itemCount) { index ->
followers[index]?.let { follower ->
FollowerItem(follower) {
FollowItem(
avatar = follower.avatar,
nickname = follower.nickname,
userId = follower.userId,
isFollowing = follower.isFollowing
) {
scope.launch {
model.followUser(follower.userId)
}
@@ -75,8 +77,11 @@ fun FollowerScreen() {
@Composable
fun FollowerItem(
item: AccountFollow,
fun FollowItem(
avatar: String,
nickname: String,
userId: Int,
isFollowing: Boolean,
onFollow: () -> Unit = {}
) {
val context = LocalContext.current
@@ -90,15 +95,15 @@ fun FollowerItem(
) {
CustomAsyncImage(
context = context,
imageUrl = item.avatar,
contentDescription = item.nickname,
imageUrl = avatar,
contentDescription = nickname,
modifier = Modifier
.size(40.dp)
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
item.userId.toString()
userId.toString()
)
)
}
@@ -107,9 +112,9 @@ fun FollowerItem(
Column(
modifier = Modifier.weight(1f)
) {
Text(item.nickname, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Text(nickname, fontWeight = FontWeight.Bold, fontSize = 16.sp)
}
if (!item.isFollowing) {
if (!isFollowing) {
Box(
modifier = Modifier.noRippleClickable {
onFollow()

View File

@@ -0,0 +1,57 @@
package com.aiosman.riderpro.ui.follower
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
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.riderpro.R
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader
import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout
import kotlinx.coroutines.launch
@Composable
fun FollowingListScreen(userId: Int) {
val model = FollowerListViewModel
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
model.loadData(userId)
}
StatusBarMaskLayout(
modifier = Modifier.padding(horizontal = 16.dp)
) {
var dataFlow = model.usersFlow
var users = dataFlow.collectAsLazyPagingItems()
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp)
) {
NoticeScreenHeader(stringResource(R.string.following_upper), moreIcon = false)
}
LazyColumn(
modifier = Modifier.weight(1f)
) {
items(users.itemCount) { index ->
users[index]?.let { user ->
FollowItem(
avatar = user.avatar,
nickname = user.nickName,
userId = user.id,
isFollowing = user.isFollowing
) {
scope.launch {
model.followUser(user.id)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,63 @@
package com.aiosman.riderpro.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.riderpro.data.UserServiceImpl
import com.aiosman.riderpro.entity.AccountPagingSource
import com.aiosman.riderpro.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())
val usersFlow = _usersFlow.asStateFlow()
private var userId by mutableStateOf<Int?>(null)
fun loadData(id: Int) {
if (userId == id) {
return
}
userId = id
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
AccountPagingSource(
userService,
followerId = id
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_usersFlow.value = it
}
}
}
private fun updateIsFollow(id: Int) {
val currentPagingData = usersFlow.value
val updatedPagingData = currentPagingData.map { user ->
if (user.id == id) {
user.copy(isFollowing = true)
} else {
user
}
}
_usersFlow.value = updatedPagingData
}
suspend fun followUser(userId: Int) {
userService.followUser(userId.toString())
updateIsFollow(userId)
}
}

View File

@@ -1,5 +1,8 @@
package com.aiosman.riderpro.ui.index.tabs.profile
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@@ -13,12 +16,14 @@ import com.aiosman.riderpro.AppStore
import com.aiosman.riderpro.data.AccountService
import com.aiosman.riderpro.data.AccountServiceImpl
import com.aiosman.riderpro.data.MomentService
import com.aiosman.riderpro.data.UploadImage
import com.aiosman.riderpro.data.UserServiceImpl
import com.aiosman.riderpro.entity.AccountProfileEntity
import com.aiosman.riderpro.entity.MomentEntity
import com.aiosman.riderpro.entity.MomentPagingSource
import com.aiosman.riderpro.entity.MomentRemoteDataSource
import com.aiosman.riderpro.entity.MomentServiceImpl
import com.aiosman.riderpro.ui.post.NewPostViewModel.uriToFile
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
@@ -31,7 +36,7 @@ object MyProfileViewModel : ViewModel() {
var profile by mutableStateOf<AccountProfileEntity?>(null)
private var _momentsFlow = MutableStateFlow<PagingData<MomentEntity>>(PagingData.empty())
var momentsFlow = _momentsFlow.asStateFlow()
fun loadProfile(){
fun loadProfile() {
viewModelScope.launch {
profile = accountService.getMyAccountProfile()
val profile = accountService.getMyAccountProfile()
@@ -59,6 +64,34 @@ object MyProfileViewModel : ViewModel() {
}
fun updateUserProfileBanner(bannerImageUrl: Uri?, context: Context) {
viewModelScope.launch {
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
}
accountService.updateProfile(
banner = newBanner,
avatar = null,
nickName = null,
bio = null
)
profile = accountService.getMyAccountProfile()
}
}
val followerCount get() = profile?.followerCount ?: 0
val followingCount get() = profile?.followingCount ?: 0
val bio get() = profile?.bio ?: ""

View File

@@ -1,6 +1,10 @@
package com.aiosman.riderpro.ui.index.tabs.profile
import android.app.Activity
import android.content.Intent
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.DrawableRes
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.core.Animatable
@@ -92,7 +96,18 @@ fun ProfilePage() {
val moments = model.momentsFlow.collectAsLazyPagingItems()
val navController: NavController = LocalNavController.current
val scope = rememberCoroutineScope()
val context = LocalContext.current
val statusBarPaddingValues = WindowInsets.systemBars.asPaddingValues()
val pickBannerImageLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val uri = result.data?.data
uri?.let {
model.updateUserProfileBanner(it, context = context)
}
}
}
LazyColumn(
modifier = Modifier
.fillMaxSize()
@@ -110,28 +125,40 @@ fun ProfilePage() {
.fillMaxWidth()
) {
val banner = model.profile?.banner
if (banner != null) {
CustomAsyncImage(
LocalContext.current,
banner,
modifier = Modifier
.fillMaxWidth()
.height(400.dp),
contentDescription = "",
contentScale = ContentScale.Crop
)
} else {
Image(
painter = painterResource(id = R.drawable.rider_pro_moment_demo_2),
modifier = Modifier
.fillMaxWidth()
.height(400.dp),
contentDescription = "",
contentScale = ContentScale.Crop
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(400.dp)
.noRippleClickable {
Intent(Intent.ACTION_PICK).apply {
type = "image/*"
pickBannerImageLauncher.launch(this)
}
}
) {
val banner = model.profile?.banner
if (banner != null) {
CustomAsyncImage(
LocalContext.current,
banner,
modifier = Modifier
.fillMaxSize(),
contentDescription = "",
contentScale = ContentScale.Crop
)
} else {
Image(
painter = painterResource(id = R.drawable.rider_pro_moment_demo_2),
modifier = Modifier
.fillMaxSize(),
contentDescription = "",
contentScale = ContentScale.Crop
)
}
}
Box(
modifier = Modifier
.align(Alignment.TopEnd)
@@ -313,9 +340,19 @@ fun UserInformation(
@Composable
fun UserInformationFollowers(modifier: Modifier, accountProfileEntity: AccountProfileEntity) {
val navController = LocalNavController.current
Column(modifier = modifier.padding(top = 31.dp)) {
Text(
modifier = Modifier.padding(bottom = 5.dp),
modifier = Modifier
.padding(bottom = 5.dp)
.noRippleClickable {
navController.navigate(
NavigationRoute.FollowerList.route.replace(
"{id}",
accountProfileEntity.id.toString()
)
)
},
text = accountProfileEntity.followerCount.toString(),
fontSize = 24.sp,
color = Color.Black,
@@ -391,12 +428,20 @@ fun UserInformationBasic(modifier: Modifier, accountProfileEntity: AccountProfil
@Composable
fun UserInformationFollowing(modifier: Modifier, accountProfileEntity: AccountProfileEntity) {
val navController = LocalNavController.current
Column(
modifier = modifier.padding(top = 6.dp),
horizontalAlignment = Alignment.End
) {
Text(
modifier = Modifier.padding(bottom = 5.dp),
modifier = Modifier.padding(bottom = 5.dp).noRippleClickable {
navController.navigate(
NavigationRoute.FollowingList.route.replace(
"{id}",
accountProfileEntity.id.toString()
)
)
},
text = accountProfileEntity.followingCount.toString(),
fontSize = 24.sp,
color = Color.Black,