更新消息功能

This commit is contained in:
2024-08-20 19:48:12 +08:00
parent 6137e1c3b5
commit 5228fde035
18 changed files with 1077 additions and 259 deletions

View File

@@ -7,8 +7,9 @@ import com.aiosman.riderpro.data.api.ApiClient
import com.aiosman.riderpro.data.api.ChangePasswordRequestBody import com.aiosman.riderpro.data.api.ChangePasswordRequestBody
import com.aiosman.riderpro.data.api.LoginUserRequestBody import com.aiosman.riderpro.data.api.LoginUserRequestBody
import com.aiosman.riderpro.data.api.RegisterRequestBody import com.aiosman.riderpro.data.api.RegisterRequestBody
import com.aiosman.riderpro.model.MomentEntity import com.aiosman.riderpro.data.api.UpdateNoticeRequestBody
import com.aiosman.riderpro.test.TestDatabase import com.aiosman.riderpro.test.TestDatabase
import com.google.gson.annotations.SerializedName
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
import okhttp3.RequestBody import okhttp3.RequestBody
@@ -16,7 +17,18 @@ import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.util.Date
data class AccountLikeEntity(
val post: NoticePostEntity,
val user: NoticeUserEntity,
val likeTime: Date,
)
data class AccountFavouriteEntity(
val post: NoticePostEntity,
val user: NoticeUserEntity,
val favoriteTime: Date,
)
data class AccountProfileEntity( data class AccountProfileEntity(
val id: Int, val id: Int,
val followerCount: Int, val followerCount: Int,
@@ -28,14 +40,6 @@ data class AccountProfileEntity(
val isFollowing: Boolean val isFollowing: Boolean
) )
//{
// "id": 1,
// "username": "root",
// "nickname": "rider_4351",
// "avatar": "/api/v1/public/default_avatar.jpeg",
// "followingCount": 1,
// "followerCount": 0
//}
data class AccountProfile( data class AccountProfile(
val id: Int, val id: Int,
val username: String, val username: String,
@@ -59,6 +63,219 @@ data class AccountProfile(
} }
} }
data class NoticePostEntity(
val id: Int,
val textContent: String,
val images: List<Image>,
val time: Date,
)
data class NoticePost(
@SerializedName("id")
val id: Int,
@SerializedName("textContent")
val textContent: String,
@SerializedName("images")
val images: List<Image>,
@SerializedName("time")
val time: String,
) {
fun toNoticePostEntity(): NoticePostEntity {
return NoticePostEntity(
id = id,
textContent = textContent,
images = images.map {
it.copy(
url = ApiClient.BASE_SERVER + it.url + "?token=${AppStore.token}",
thumbnail = ApiClient.BASE_SERVER + it.thumbnail + "?token=${AppStore.token}",
)
},
time = ApiClient.dateFromApiString(time)
)
}
}
data class NoticeUserEntity(
val id: Int,
val nickName: String,
val avatar: String,
)
data class NoticeUser(
@SerializedName("id")
val id: Int,
@SerializedName("nickName")
val nickName: String,
@SerializedName("avatar")
val avatar: String,
) {
fun toNoticeUserEntity(): NoticeUserEntity {
return NoticeUserEntity(
id = id,
nickName = nickName,
avatar = ApiClient.BASE_SERVER + avatar + "?token=${AppStore.token}",
)
}
}
data class AccountLike(
@SerializedName("isUnread")
val isUnread: Boolean,
@SerializedName("post")
val post: NoticePost,
@SerializedName("user")
val user: NoticeUser,
@SerializedName("likeTime")
val likeTime: String,
) {
fun toAccountLikeEntity(): AccountLikeEntity {
return AccountLikeEntity(
post = post.toNoticePostEntity(),
user = user.toNoticeUserEntity(),
likeTime = ApiClient.dateFromApiString(likeTime)
)
}
}
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,
)
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 + "?token=${AppStore.token}",
)
},
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
}
}
interface AccountService { interface AccountService {
suspend fun getMyAccountProfile(): AccountProfileEntity suspend fun getMyAccountProfile(): AccountProfileEntity
suspend fun getAccountProfileById(id: Int): AccountProfileEntity suspend fun getAccountProfileById(id: Int): AccountProfileEntity
@@ -69,6 +286,11 @@ interface AccountService {
suspend fun updateProfile(avatar: UploadImage?, nickName: String?, bio: String?) suspend fun updateProfile(avatar: UploadImage?, nickName: String?, bio: String?)
suspend fun registerUserWithPassword(loginName: String, password: String) suspend fun registerUserWithPassword(loginName: String, password: String)
suspend fun changeAccountPassword(oldPassword: String, newPassword: String) suspend fun changeAccountPassword(oldPassword: String, newPassword: String)
suspend fun getMyLikeNotice(page: Int, pageSize: Int): ListContainer<AccountLike>
suspend fun getMyFollowNotice(page: Int, pageSize: Int): ListContainer<AccountFollow>
suspend fun getMyFavouriteNotice(page: Int, pageSize: Int): ListContainer<AccountFavourite>
suspend fun getMyNoticeInfo(): AccountNotice
suspend fun updateNotice(payload: UpdateNoticeRequestBody)
} }
class TestAccountServiceImpl : AccountService { class TestAccountServiceImpl : AccountService {
@@ -130,4 +352,36 @@ class TestAccountServiceImpl : AccountService {
override suspend fun changeAccountPassword(oldPassword: String, newPassword: String) { override suspend fun changeAccountPassword(oldPassword: String, newPassword: String) {
ApiClient.api.changePassword(ChangePasswordRequestBody(oldPassword, newPassword)) ApiClient.api.changePassword(ChangePasswordRequestBody(oldPassword, newPassword))
} }
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)
}
} }

View File

@@ -1,2 +0,0 @@
package com.aiosman.riderpro.data

View File

@@ -9,27 +9,23 @@ import com.aiosman.riderpro.test.TestDatabase
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import java.io.IOException import java.io.IOException
import java.util.Calendar import java.util.Calendar
import java.util.Date
import kotlin.math.min import kotlin.math.min
interface CommentService { interface CommentService {
suspend fun getComments(pageNumber: Int, postId: Int? = null): ListContainer<CommentEntity> suspend fun getComments(
pageNumber: Int,
postId: Int? = null,
postUser: Int? = null,
selfNotice: Boolean? = null
): ListContainer<CommentEntity>
suspend fun createComment(postId: Int, content: String) suspend fun createComment(postId: Int, content: String)
suspend fun likeComment(commentId: Int) suspend fun likeComment(commentId: Int)
suspend fun dislikeComment(commentId: Int) suspend fun dislikeComment(commentId: Int)
suspend fun updateReadStatus(commentId: Int)
} }
//{
// "id": 2,
// "content": "123",
// "User": {
// "id": 1,
// "nickName": "",
// "avatar": "/api/v1/public/default_avatar.jpeg"
//},
// "likeCount": 1,
// "isLiked": true,
// "createdAt": "2024-08-05 02:53:48"
//}
data class Comment( data class Comment(
@SerializedName("id") @SerializedName("id")
val id: Int, val id: Int,
@@ -42,20 +38,37 @@ data class Comment(
@SerializedName("isLiked") @SerializedName("isLiked")
val isLiked: Boolean, val isLiked: Boolean,
@SerializedName("createdAt") @SerializedName("createdAt")
val createdAt: String val createdAt: String,
@SerializedName("postId")
val postId: Int,
@SerializedName("post")
val post: NoticePost?,
@SerializedName("isUnread")
val isUnread: Boolean
) { ) {
fun toCommentEntity(): CommentEntity { fun toCommentEntity(): CommentEntity {
return CommentEntity( return CommentEntity(
id = id, id = id,
name = user.nickName, name = user.nickName,
comment = content, comment = content,
date = createdAt, date = ApiClient.dateFromApiString(createdAt),
likes = likeCount, likes = likeCount,
replies = emptyList(), replies = emptyList(),
postId = 0, postId = postId,
avatar = ApiClient.BASE_SERVER + user.avatar + "?token=${AppStore.token}", avatar = ApiClient.BASE_SERVER + user.avatar + "?token=${AppStore.token}",
author = user.id, author = user.id,
liked = isLiked liked = isLiked,
unread = isUnread,
post = post?.let {
it.copy(
images = it.images.map {
it.copy(
url = ApiClient.BASE_SERVER + it.url + "?token=${AppStore.token}",
thumbnail = ApiClient.BASE_SERVER + it.thumbnail + "?token=${AppStore.token}"
)
}
)
}
) )
} }
} }
@@ -64,25 +77,31 @@ data class CommentEntity(
val id: Int, val id: Int,
val name: String, val name: String,
val comment: String, val comment: String,
val date: String, val date: Date,
val likes: Int, val likes: Int,
val replies: List<CommentEntity>, val replies: List<CommentEntity>,
val postId: Int = 0, val postId: Int = 0,
val avatar: String, val avatar: String,
val author: Long, val author: Long,
var liked: Boolean, var liked: Boolean,
var unread: Boolean = false,
var post: NoticePost?
) )
class CommentPagingSource( class CommentPagingSource(
private val remoteDataSource: CommentRemoteDataSource, private val remoteDataSource: CommentRemoteDataSource,
private val postId: Int? = null private val postId: Int? = null,
private val postUser: Int? = null,
private val selfNotice: Boolean? = null
) : PagingSource<Int, CommentEntity>() { ) : PagingSource<Int, CommentEntity>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CommentEntity> { override suspend fun load(params: LoadParams<Int>): LoadResult<Int, CommentEntity> {
return try { return try {
val currentPage = params.key ?: 1 val currentPage = params.key ?: 1
val comments = remoteDataSource.getComments( val comments = remoteDataSource.getComments(
pageNumber = currentPage, pageNumber = currentPage,
postId = postId postId = postId,
postUser = postUser,
selfNotice = selfNotice
) )
LoadResult.Page( LoadResult.Page(
data = comments.list, data = comments.list,
@@ -103,15 +122,37 @@ class CommentPagingSource(
class CommentRemoteDataSource( class CommentRemoteDataSource(
private val commentService: CommentService, private val commentService: CommentService,
) { ) {
suspend fun getComments(pageNumber: Int, postId: Int?): ListContainer<CommentEntity> { suspend fun getComments(
return commentService.getComments(pageNumber, postId) pageNumber: Int,
postId: Int?,
postUser: Int?,
selfNotice: Boolean?
): ListContainer<CommentEntity> {
return commentService.getComments(
pageNumber,
postId,
postUser = postUser,
selfNotice = selfNotice
)
} }
} }
class TestCommentServiceImpl : CommentService { class TestCommentServiceImpl : CommentService {
override suspend fun getComments(pageNumber: Int, postId: Int?): ListContainer<CommentEntity> { override suspend fun getComments(
val resp = ApiClient.api.getComments(pageNumber, postId) pageNumber: Int,
postId: Int?,
postUser: Int?,
selfNotice: Boolean?
): ListContainer<CommentEntity> {
val resp = ApiClient.api.getComments(
pageNumber,
postId,
postUser = postUser,
selfNotice = selfNotice?.let {
if (it) 1 else 0
}
)
val body = resp.body() ?: throw ServiceException("Failed to get comments") val body = resp.body() ?: throw ServiceException("Failed to get comments")
return ListContainer( return ListContainer(
list = body.list.map { it.toCommentEntity() }, list = body.list.map { it.toCommentEntity() },
@@ -136,6 +177,11 @@ class TestCommentServiceImpl : CommentService {
return return
} }
override suspend fun updateReadStatus(commentId: Int) {
val resp = ApiClient.api.updateReadStatus(commentId)
return
}
companion object { companion object {
const val DataBatchSize = 5 const val DataBatchSize = 5
} }

View File

@@ -1,5 +1,6 @@
package com.aiosman.riderpro.data.api package com.aiosman.riderpro.data.api
import android.icu.text.SimpleDateFormat
import com.aiosman.riderpro.AppStore import com.aiosman.riderpro.AppStore
import com.aiosman.riderpro.ConstVars import com.aiosman.riderpro.ConstVars
import okhttp3.Interceptor import okhttp3.Interceptor
@@ -8,6 +9,8 @@ import okhttp3.Response
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.security.cert.CertificateException import java.security.cert.CertificateException
import java.util.Date
import java.util.Locale
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
@@ -17,10 +20,18 @@ fun getUnsafeOkHttpClient(): OkHttpClient {
// Create a trust manager that does not validate certificate chains // Create a trust manager that does not validate certificate chains
val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager { val trustAllCerts = arrayOf<TrustManager>(object : X509TrustManager {
@Throws(CertificateException::class) @Throws(CertificateException::class)
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) {} override fun checkClientTrusted(
chain: Array<java.security.cert.X509Certificate>,
authType: String
) {
}
@Throws(CertificateException::class) @Throws(CertificateException::class)
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>, authType: String) {} override fun checkServerTrusted(
chain: Array<java.security.cert.X509Certificate>,
authType: String
) {
}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf() override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
}) })
@@ -40,6 +51,7 @@ fun getUnsafeOkHttpClient(): OkHttpClient {
throw RuntimeException(e) throw RuntimeException(e)
} }
} }
class AuthInterceptor() : Interceptor { class AuthInterceptor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
val requestBuilder = chain.request().newBuilder() val requestBuilder = chain.request().newBuilder()
@@ -52,6 +64,7 @@ object ApiClient {
const val BASE_SERVER = ConstVars.BASE_SERVER const val BASE_SERVER = ConstVars.BASE_SERVER
const val BASE_API_URL = "${BASE_SERVER}/api/v1" const val BASE_API_URL = "${BASE_SERVER}/api/v1"
const val RETROFIT_URL = "${BASE_API_URL}/" const val RETROFIT_URL = "${BASE_API_URL}/"
const val TIME_FORMAT = "yyyy-MM-dd HH:mm:ss"
private val okHttpClient: OkHttpClient by lazy { private val okHttpClient: OkHttpClient by lazy {
getUnsafeOkHttpClient() getUnsafeOkHttpClient()
} }
@@ -62,9 +75,19 @@ object ApiClient {
.addConverterFactory(GsonConverterFactory.create()) .addConverterFactory(GsonConverterFactory.create())
.build() .build()
} }
val api: RiderProAPI by lazy { val api: RiderProAPI by lazy {
retrofit.create(RiderProAPI::class.java) 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 = ApiClient.TIME_FORMAT
val simpleDateFormat = SimpleDateFormat(timeFormat, Locale.getDefault())
val date = simpleDateFormat.parse(apiString)
return date
}
} }

View File

@@ -1,5 +1,9 @@
package com.aiosman.riderpro.data.api package com.aiosman.riderpro.data.api
import com.aiosman.riderpro.data.AccountFavourite
import com.aiosman.riderpro.data.AccountFollow
import com.aiosman.riderpro.data.AccountLike
import com.aiosman.riderpro.data.AccountNotice
import com.aiosman.riderpro.data.AccountProfile import com.aiosman.riderpro.data.AccountProfile
import com.aiosman.riderpro.data.AccountProfileEntity import com.aiosman.riderpro.data.AccountProfileEntity
import com.aiosman.riderpro.data.Comment import com.aiosman.riderpro.data.Comment
@@ -59,6 +63,15 @@ data class ChangePasswordRequestBody(
val newPassword: String = "" val newPassword: String = ""
) )
data class UpdateNoticeRequestBody(
@SerializedName("lastLookLikeTime")
val lastLookLikeTime: String? = null,
@SerializedName("lastLookFollowTime")
val lastLookFollowTime: String? = null,
@SerializedName("lastLookFavoriteTime")
val lastLookFavouriteTime: String? = null
)
interface RiderProAPI { interface RiderProAPI {
@POST("register") @POST("register")
suspend fun register(@Body body: RegisterRequestBody): Response<Unit> suspend fun register(@Body body: RegisterRequestBody): Response<Unit>
@@ -76,6 +89,7 @@ interface RiderProAPI {
@Query("timelineId") timelineId: Int? = null, @Query("timelineId") timelineId: Int? = null,
@Query("authorId") authorId: Int? = null, @Query("authorId") authorId: Int? = null,
@Query("contentSearch") contentSearch: String? = null, @Query("contentSearch") contentSearch: String? = null,
@Query("postUser") postUser: Int? = null,
): Response<ListContainer<Moment>> ): Response<ListContainer<Moment>>
@Multipart @Multipart
@@ -126,12 +140,19 @@ interface RiderProAPI {
@Path("id") id: Int @Path("id") id: Int
): Response<Unit> ): Response<Unit>
@POST("comment/{id}/read")
suspend fun updateReadStatus(
@Path("id") id: Int
): Response<Unit>
@GET("comments") @GET("comments")
suspend fun getComments( suspend fun getComments(
@Query("page") page: Int = 1, @Query("page") page: Int = 1,
@Query("postId") postId: Int? = null, @Query("postId") postId: Int? = null,
@Query("pageSize") pageSize: Int = 20, @Query("pageSize") pageSize: Int = 20,
@Query("postUser") postUser: Int? = null,
@Query("selfNotice") selfNotice: Int? = 0,
): Response<ListContainer<Comment>> ): Response<ListContainer<Comment>>
@GET("account/my") @GET("account/my")
@@ -149,6 +170,33 @@ interface RiderProAPI {
@Body body: ChangePasswordRequestBody @Body body: ChangePasswordRequestBody
): Response<Unit> ): 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>
@GET("profile/{id}") @GET("profile/{id}")
suspend fun getAccountProfileById( suspend fun getAccountProfileById(
@Path("id") id: Int @Path("id") id: Int

View File

@@ -0,0 +1,25 @@
package com.aiosman.riderpro.exp
import android.icu.text.SimpleDateFormat
import com.aiosman.riderpro.data.api.ApiClient
import java.util.Date
import java.util.Locale
fun Date.timeAgo(): 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 -> "$seconds seconds ago"
minutes < 60 -> "$minutes minutes ago"
hours < 24 -> "$hours hours ago"
days < 365 -> "$days days ago"
else -> "$years years ago"
}
}

View File

@@ -9,6 +9,7 @@ import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import io.github.serpro69.kfaker.faker import io.github.serpro69.kfaker.faker
import java.io.File import java.io.File
import java.util.Calendar
object TestDatabase { object TestDatabase {
var momentData = emptyList<MomentEntity>() var momentData = emptyList<MomentEntity>()
@@ -90,14 +91,16 @@ object TestDatabase {
var newCommentEntity = CommentEntity( var newCommentEntity = CommentEntity(
name = commentPerson.nickName, name = commentPerson.nickName,
comment = "this is comment ${commentIdCounter}", comment = "this is comment ${commentIdCounter}",
date = "2023-02-02 11:23", date = Calendar.getInstance().time,
likes = 0, likes = 0,
replies = emptyList(), replies = emptyList(),
postId = momentIdCounter, postId = momentIdCounter,
avatar = commentPerson.avatar, avatar = commentPerson.avatar,
author = commentPerson.id.toLong(), author = commentPerson.id.toLong(),
id = commentIdCounter, id = commentIdCounter,
liked = false liked = false,
unread = false,
post = null
) )
// generate like comment list // generate like comment list
for (likeIdx in 0..faker.random.nextInt(0, 5)) { for (likeIdx in 0..faker.random.nextInt(0, 5)) {

View File

@@ -25,6 +25,7 @@ import com.aiosman.riderpro.LocalNavController
import com.aiosman.riderpro.LocalSharedTransitionScope import com.aiosman.riderpro.LocalSharedTransitionScope
import com.aiosman.riderpro.ui.account.AccountEditScreen import com.aiosman.riderpro.ui.account.AccountEditScreen
import com.aiosman.riderpro.ui.comment.CommentsScreen 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.FollowerScreen
import com.aiosman.riderpro.ui.gallery.OfficialGalleryScreen import com.aiosman.riderpro.ui.gallery.OfficialGalleryScreen
import com.aiosman.riderpro.ui.gallery.OfficialPhotographerScreen import com.aiosman.riderpro.ui.gallery.OfficialPhotographerScreen
@@ -66,6 +67,7 @@ sealed class NavigationRoute(
data object AccountEdit : NavigationRoute("AccountEditScreen") data object AccountEdit : NavigationRoute("AccountEditScreen")
data object ImageViewer : NavigationRoute("ImageViewer") data object ImageViewer : NavigationRoute("ImageViewer")
data object ChangePasswordScreen : NavigationRoute("ChangePasswordScreen") data object ChangePasswordScreen : NavigationRoute("ChangePasswordScreen")
data object FavouritesScreen : NavigationRoute("FavouritesScreen")
} }
@@ -189,6 +191,9 @@ fun NavigationController(
composable(route = NavigationRoute.ChangePasswordScreen.route) { composable(route = NavigationRoute.ChangePasswordScreen.route) {
ChangePasswordScreen() ChangePasswordScreen()
} }
composable(route = NavigationRoute.FavouritesScreen.route) {
FavouriteScreen()
}
} }

View File

@@ -0,0 +1,53 @@
package com.aiosman.riderpro.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.riderpro.data.AccountFavourite
import com.aiosman.riderpro.data.AccountFavouriteEntity
import com.aiosman.riderpro.data.AccountLike
import com.aiosman.riderpro.data.AccountService
import com.aiosman.riderpro.data.FavoriteItemPagingSource
import com.aiosman.riderpro.data.LikeItemPagingSource
import com.aiosman.riderpro.data.TestAccountServiceImpl
import com.aiosman.riderpro.data.api.ApiClient
import com.aiosman.riderpro.data.api.UpdateNoticeRequestBody
import com.aiosman.riderpro.ui.like.LikePageViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object FavouritePageViewModel : ViewModel() {
private val accountService: AccountService = TestAccountServiceImpl()
private val _favouriteItemsFlow =
MutableStateFlow<PagingData<AccountFavouriteEntity>>(PagingData.empty())
val favouriteItemsFlow = _favouriteItemsFlow.asStateFlow()
init {
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)
)
)
}
}

View File

@@ -0,0 +1,93 @@
package com.aiosman.riderpro.ui.favourite
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.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.Color
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 androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.riderpro.LocalNavController
import com.aiosman.riderpro.R
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader
import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout
import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder
import com.aiosman.riderpro.ui.like.ActionNoticeItem
import com.aiosman.riderpro.ui.like.LikePageViewModel
@Preview
@Composable
fun FavouriteScreen() {
val model = FavouritePageViewModel
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
var dataFlow = model.favouriteItemsFlow
var favourites = dataFlow.collectAsLazyPagingItems()
LaunchedEffect(Unit) {
model.updateNotice()
}
StatusBarMaskLayout(
darkIcons = true,
maskBoxBackgroundColor = Color(0xFFFFFFFF)
) {
Column(
modifier = Modifier
.weight(1f)
.background(color = Color(0xFFFFFFFF))
.padding(horizontal = 16.dp)
) {
NoticeScreenHeader(
"FAVOURITE",
moreIcon = false
)
Spacer(modifier = Modifier.height(28.dp))
LazyColumn(
modifier = Modifier.weight(1f),
state = listState,
) {
items(favourites.itemCount) {
val favouriteItem = favourites[it]
if (favouriteItem != null) {
ActionNoticeItem(
avatar = favouriteItem.user.avatar,
nickName = favouriteItem.user.nickName,
likeTime = favouriteItem.favoriteTime,
thumbnail = favouriteItem.post.images[0].thumbnail,
action = "favourite",
userId = favouriteItem.user.id,
postId = favouriteItem.post.id,
)
}
}
item {
BottomNavigationPlaceholder()
}
}
}
}
}

View File

@@ -13,32 +13,53 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.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.composables.StatusBarMaskLayout
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader import com.aiosman.riderpro.ui.comment.NoticeScreenHeader
import com.aiosman.riderpro.ui.composables.CustomAsyncImage
import com.aiosman.riderpro.ui.modifiers.noRippleClickable
import kotlinx.coroutines.launch
@Preview @Preview
@Composable @Composable
fun FollowerScreen() { fun FollowerScreen() {
val scope = rememberCoroutineScope()
StatusBarMaskLayout( StatusBarMaskLayout(
modifier = Modifier.padding(horizontal = 16.dp) modifier = Modifier.padding(horizontal = 16.dp)
) { ) {
val model = FollowerViewModel
var dataFlow = model.followerItemsFlow
var followers = dataFlow.collectAsLazyPagingItems()
NoticeScreenHeader("FOLLOWERS") NoticeScreenHeader("FOLLOWERS")
Spacer(modifier = Modifier.height(28.dp)) Spacer(modifier = Modifier.height(28.dp))
LaunchedEffect(Unit) {
model.updateNotice()
}
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
item { items(followers.itemCount) { index ->
repeat(20) { followers[index]?.let { follower ->
FollowerItem() FollowerItem(follower) {
scope.launch {
model.followUser(follower.userId)
}
}
} }
} }
} }
@@ -47,7 +68,12 @@ fun FollowerScreen() {
@Composable @Composable
fun FollowerItem() { fun FollowerItem(
item: AccountFollow,
onFollow: () -> Unit = {}
) {
val context = LocalContext.current
val navController = LocalNavController.current
Box( Box(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
) { ) {
@@ -55,23 +81,36 @@ fun FollowerItem() {
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Image( CustomAsyncImage(
painter = painterResource(id = R.drawable.default_avatar), context = context,
contentDescription = "Follower", imageUrl = item.avatar,
modifier = Modifier.size(40.dp) contentDescription = item.nickname,
modifier = Modifier
.size(40.dp)
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
item.userId.toString()
)
)
}
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column( Column(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text("Username", fontWeight = FontWeight.Bold, fontSize = 16.sp) Text(item.nickname, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Spacer(modifier = Modifier.height(5.dp))
Text("Username", fontSize = 12.sp, color = Color(0x99000000))
} }
Box { if (!item.isFollowing) {
Box(
modifier = Modifier.noRippleClickable {
onFollow()
}
) {
Image( Image(
painter = painterResource(id = R.drawable.follow_bg), painter = painterResource(id = R.drawable.follow_bg),
contentDescription = "Like", contentDescription = "Follow",
modifier = Modifier modifier = Modifier
.width(79.dp) .width(79.dp)
.height(24.dp) .height(24.dp)
@@ -85,6 +124,8 @@ fun FollowerItem() {
) )
) )
} }
}
} }
} }

View File

@@ -0,0 +1,70 @@
package com.aiosman.riderpro.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.riderpro.data.AccountFollow
import com.aiosman.riderpro.data.AccountService
import com.aiosman.riderpro.data.FollowItemPagingSource
import com.aiosman.riderpro.data.TestAccountServiceImpl
import com.aiosman.riderpro.data.TestUserServiceImpl
import com.aiosman.riderpro.data.UserService
import com.aiosman.riderpro.data.api.ApiClient
import com.aiosman.riderpro.data.api.UpdateNoticeRequestBody
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object FollowerViewModel : ViewModel() {
private val accountService: AccountService = TestAccountServiceImpl()
private val userService: UserService = TestUserServiceImpl()
private val _followerItemsFlow =
MutableStateFlow<PagingData<AccountFollow>>(PagingData.empty())
val followerItemsFlow = _followerItemsFlow.asStateFlow()
init {
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)
)
)
}
}

View File

@@ -1,7 +1,7 @@
package com.aiosman.riderpro.ui.like package com.aiosman.riderpro.ui.like
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -12,47 +12,40 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.aiosman.riderpro.R import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.riderpro.LocalNavController
import com.aiosman.riderpro.data.AccountLike
import com.aiosman.riderpro.exp.timeAgo
import com.aiosman.riderpro.ui.NavigationRoute
import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout
import com.aiosman.riderpro.ui.comment.NoticeScreenHeader import com.aiosman.riderpro.ui.comment.NoticeScreenHeader
import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder
import com.aiosman.riderpro.ui.composables.CustomAsyncImage
import com.aiosman.riderpro.ui.modifiers.noRippleClickable
import java.util.Date
@Preview @Preview
@Composable @Composable
fun LikeScreen() { fun LikeScreen() {
val model = LikePageViewModel val model = LikePageViewModel
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState() val listState = rememberLazyListState()
var dataFlow = model.likeItemsFlow
// observe list scrolling var likes = dataFlow.collectAsLazyPagingItems()
val reachedBottom: Boolean by remember {
derivedStateOf {
val lastVisibleItem = listState.layoutInfo.visibleItemsInfo.lastOrNull()
lastVisibleItem?.index != 0 && lastVisibleItem?.index == listState.layoutInfo.totalItemsCount - 3
}
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
LikePageViewModel.loader.loadData() model.updateNotice()
}
LaunchedEffect(reachedBottom) {
if (reachedBottom) LikePageViewModel.loader.loadMore()
} }
StatusBarMaskLayout( StatusBarMaskLayout(
darkIcons = true, darkIcons = true,
@@ -64,69 +57,97 @@ fun LikeScreen() {
.background(color = Color(0xFFFFFFFF)) .background(color = Color(0xFFFFFFFF))
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
) { ) {
NoticeScreenHeader("LIKES") NoticeScreenHeader(
"LIKES",
moreIcon = false
)
Spacer(modifier = Modifier.height(28.dp)) Spacer(modifier = Modifier.height(28.dp))
LazyColumn( LazyColumn(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
state = listState, state = listState,
) { ) {
items(LikePageViewModel.loader.list, key = { it.id }) { items(likes.itemCount) {
LikeItem(it) val likeItem = likes[it]
if (likeItem != null) {
ActionNoticeItem(
avatar = likeItem.user.avatar,
nickName = likeItem.user.nickName,
likeTime = likeItem.likeTime,
thumbnail = likeItem.post.images[0].thumbnail,
action = "like",
userId = likeItem.user.id,
postId = likeItem.post.id
)
}
} }
item { item {
BottomNavigationPlaceholder() BottomNavigationPlaceholder()
} }
} }
} }
} }
} }
@Composable @Composable
fun LikeItem(itemData: LikeItemData) { fun ActionNoticeItem(
avatar: String,
nickName: String,
likeTime: Date,
thumbnail: String,
action: String,
userId: Int,
postId: Int
) {
val context = LocalContext.current
val navController = LocalNavController.current
Box( Box(
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp) modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
) { ) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.Top,
) { ) {
Image( CustomAsyncImage(
painter = painterResource(id = R.drawable.default_avatar), context,
contentDescription = "Like", imageUrl = avatar,
modifier = Modifier.size(40.dp) modifier = Modifier
.size(40.dp)
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
userId.toString()
)
)
},
contentDescription = action,
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column( Column(
modifier = Modifier.weight(1f)
) {
Text(itemData.name, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Spacer(modifier = Modifier.height(5.dp))
Text("Username", fontSize = 12.sp, color = Color(0x99000000))
}
Box {
Image(
painter = painterResource(id = R.drawable.follow_bg),
contentDescription = "Like",
modifier = Modifier modifier = Modifier
.width(79.dp) .weight(1f)
.height(24.dp) .noRippleClickable {
) navController.navigate(
Text( NavigationRoute.Post.route.replace(
"FOLLOW", "{id}",
fontSize = 14.sp, postId.toString()
color = Color(0xFFFFFFFF),
modifier = Modifier.align(
Alignment.Center
) )
) )
} }
) {
Text(nickName, fontWeight = FontWeight.Bold, fontSize = 16.sp)
Spacer(modifier = Modifier.height(5.dp))
Row {
Text(likeTime.timeAgo(), fontSize = 12.sp, color = Color(0x99000000))
}
}
CustomAsyncImage(
context,
imageUrl = thumbnail,
modifier = Modifier.size(64.dp),
contentDescription = action,
)
} }
} }
} }

View File

@@ -1,68 +1,51 @@
package com.aiosman.riderpro.ui.like package com.aiosman.riderpro.ui.like
import androidx.compose.runtime.getValue import android.icu.util.Calendar
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import com.aiosman.riderpro.test.MockDataContainer import androidx.lifecycle.viewModelScope
import com.aiosman.riderpro.test.MockDataSource import androidx.paging.Pager
import com.aiosman.riderpro.test.MockListContainer import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.aiosman.riderpro.data.AccountLike
import com.aiosman.riderpro.data.AccountLikeEntity
import com.aiosman.riderpro.data.AccountService
import com.aiosman.riderpro.data.LikeItemPagingSource
import com.aiosman.riderpro.data.TestAccountServiceImpl
import com.aiosman.riderpro.data.api.ApiClient
import com.aiosman.riderpro.data.api.UpdateNoticeRequestBody
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
class LikeDataSource : MockDataSource<LikeItemData>() {
init {
var newList = mutableListOf<LikeItemData>()
for (i in 0..999) {
newList.add(LikeItemData(i, "Like $i"))
}
list = newList
}
}
abstract class DataLoader<T> {
var list = mutableListOf<T>()
var page by mutableStateOf(1)
var pageSize by mutableStateOf(20)
var total by mutableStateOf(0)
var noMoreData by mutableStateOf(false)
suspend fun loadData() {
val resp = fetchData(page, pageSize)
if (resp.success) {
resp.data?.let {
total = it.total
list = it.list.toMutableList()
if (it.list.size < pageSize) {
noMoreData = true
}
}
}
}
suspend fun loadMore(){
if (list.size >= total) return
page++
val resp = fetchData(page, pageSize)
if (resp.success) {
resp.data?.let {
total = it.total
list.addAll(it.list)
if (it.list.size < pageSize) {
noMoreData = true
}
}
}
}
abstract suspend fun fetchData(page: Int, pageSize: Int): MockDataContainer<MockListContainer<T>>
}
class LikeListLoader : DataLoader<LikeItemData>() {
var mockDataSource = LikeDataSource()
override suspend fun fetchData(page: Int, pageSize: Int): MockDataContainer<MockListContainer<LikeItemData>> {
return mockDataSource.fetchData(page, pageSize)
}
}
object LikePageViewModel : ViewModel() { object LikePageViewModel : ViewModel() {
val loader = LikeListLoader() private val accountService: AccountService = TestAccountServiceImpl()
} private val _likeItemsFlow = MutableStateFlow<PagingData<AccountLikeEntity>>(PagingData.empty())
val likeItemsFlow = _likeItemsFlow.asStateFlow()
data class LikeItemData( init {
val id: Int, viewModelScope.launch {
val name: String, Pager(
) config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
LikeItemPagingSource(
accountService
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_likeItemsFlow.value = it
}
}
}
suspend fun updateNotice() {
var now = Calendar.getInstance().time
accountService.updateNotice(
UpdateNoticeRequestBody(
lastLookLikeTime = ApiClient.formatTime(now)
)
)
}
}

View File

@@ -2,7 +2,6 @@ package com.aiosman.riderpro.ui.message
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -24,29 +23,43 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.paging.compose.collectAsLazyPagingItems
import com.aiosman.riderpro.LocalNavController import com.aiosman.riderpro.LocalNavController
import com.aiosman.riderpro.R import com.aiosman.riderpro.R
import com.aiosman.riderpro.data.Comment
import com.aiosman.riderpro.data.CommentEntity
import com.aiosman.riderpro.exp.timeAgo
import com.aiosman.riderpro.ui.NavigationRoute
import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout import com.aiosman.riderpro.ui.composables.StatusBarMaskLayout
import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder import com.aiosman.riderpro.ui.composables.BottomNavigationPlaceholder
import com.aiosman.riderpro.ui.composables.CustomAsyncImage
import com.aiosman.riderpro.ui.modifiers.noRippleClickable
import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Preview(showBackground = true) @Preview(showBackground = true)
@Composable @Composable
fun NotificationsScreen() { fun NotificationsScreen() {
val model = MessageListViewModel
val navController = LocalNavController.current val navController = LocalNavController.current
val systemUiController = rememberSystemUiController() val systemUiController = rememberSystemUiController()
var dataFlow = model.commentItemsFlow
var comments = dataFlow.collectAsLazyPagingItems()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
systemUiController.setNavigationBarColor(Color.Transparent) systemUiController.setNavigationBarColor(Color.Transparent)
model.initData()
} }
StatusBarMaskLayout(darkIcons = true) { StatusBarMaskLayout(darkIcons = true) {
Column( Column(
modifier = Modifier.fillMaxWidth().weight(1f) modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
@@ -65,42 +78,47 @@ fun NotificationsScreen() {
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
) { ) {
NotificationIndicator(10, R.drawable.rider_pro_like, "LIKE") { NotificationIndicator(model.likeNoticeCount, R.drawable.rider_pro_like, "LIKE") {
navController.navigate("Likes") navController.navigate(NavigationRoute.Likes.route)
} }
NotificationIndicator(10, R.drawable.rider_pro_followers, "FOLLOWERS"){ NotificationIndicator(
navController.navigate("Followers") model.followNoticeCount,
R.drawable.rider_pro_followers,
"FOLLOWERS"
) {
navController.navigate(NavigationRoute.Followers.route)
} }
NotificationIndicator(10, R.drawable.rider_pro_comments, "COMMENTS"){ NotificationIndicator(
navController.navigate("Comments") model.favouriteNoticeCount,
R.drawable.rider_pro_favoriate,
"Favourites"
) {
navController.navigate(NavigationRoute.FavouritesScreen.route)
} }
} }
HorizontalDivider(color = Color(0xFFEbEbEb), modifier = Modifier.padding(16.dp)) HorizontalDivider(color = Color(0xFFEbEbEb), modifier = Modifier.padding(16.dp))
NotificationCounterItem(24) NotificationCounterItem(model.commentNoticeCount)
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxSize() .fillMaxSize()
) { ) {
items(comments.itemCount) { index ->
comments[index]?.let { comment ->
CommentItem(comment) {
model.updateReadStatus(comment.id)
navController.navigate(
NavigationRoute.Post.route.replace(
"{id}",
comment.postId.toString()
)
)
}
}
}
item { item {
repeat(20) {
MessageItem(
MessageItemData(
userName = "Onyama Limba",
message = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
timeAgo = "3 days ago",
profileImage = R.drawable.default_avatar
)
)
}
BottomNavigationPlaceholder() BottomNavigationPlaceholder()
} }
} }
} }
} }
@@ -121,7 +139,7 @@ fun NotificationIndicator(
modifier = Modifier modifier = Modifier
.padding(16.dp) .padding(16.dp)
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.clickable { .noRippleClickable {
onClick() onClick()
} }
) { ) {
@@ -204,38 +222,54 @@ fun NotificationCounterItem(count: Int) {
} }
} }
data class MessageItemData(
val userName: String,
val message: String,
val timeAgo: String,
val profileImage: Int
)
@Composable @Composable
fun MessageItem(messageItemData: MessageItemData) { fun CommentItem(
commentItem: CommentEntity,
onPostClick: () -> Unit = {},
) {
val navController = LocalNavController.current
val context = LocalContext.current
Row( Row(
modifier = Modifier.padding(16.dp) modifier = Modifier.padding(16.dp)
) { ) {
Box() { Box {
Image( CustomAsyncImage(
painter = painterResource(id = R.drawable.default_avatar), context = context,
contentDescription = "", imageUrl = commentItem.avatar,
contentDescription = commentItem.name,
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.clip(RoundedCornerShape(4.dp)) .clip(RoundedCornerShape(4.dp))
.noRippleClickable {
navController.navigate(
NavigationRoute.AccountProfile.route.replace(
"{id}",
commentItem.author.toString()
)
) )
} }
)
}
Row(
modifier = Modifier
.weight(1f)
.padding(start = 16.dp)
.noRippleClickable {
onPostClick()
}
) {
Column( Column(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) { ) {
Text( Text(
text = "Onyama Limba", text = commentItem.name,
fontSize = 16.sp, fontSize = 16.sp,
modifier = Modifier.padding(start = 16.dp) modifier = Modifier.padding(start = 16.dp)
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", text = commentItem.comment,
fontSize = 14.sp, fontSize = 14.sp,
modifier = Modifier.padding(start = 16.dp), modifier = Modifier.padding(start = 16.dp),
maxLines = 1, maxLines = 1,
@@ -246,12 +280,8 @@ fun MessageItem(messageItemData: MessageItemData) {
Column( Column(
horizontalAlignment = Alignment.End horizontalAlignment = Alignment.End
) { ) {
Text( Row {
text = "3 days ago", if (commentItem.unread) {
fontSize = 14.sp,
color = Color(0x66000000)
)
Spacer(modifier = Modifier.height(6.dp))
Box( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
@@ -259,13 +289,34 @@ fun MessageItem(messageItemData: MessageItemData) {
.padding(4.dp) .padding(4.dp)
) { ) {
Text( Text(
text = "24", text = "new",
color = Color.White, color = Color.White,
fontSize = 12.sp, fontSize = 12.sp,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)
) )
} }
Spacer(modifier = Modifier.width(8.dp))
} }
Text(
text = commentItem.date.timeAgo(),
fontSize = 14.sp,
color = Color(0x66000000)
)
}
Spacer(modifier = Modifier.height(4.dp))
commentItem.post?.let {
CustomAsyncImage(
context = context,
imageUrl = it.images[0].thumbnail,
contentDescription = "Post Image",
modifier = Modifier
.size(32.dp)
.clip(RoundedCornerShape(4.dp)),
)
}
}
}
} }
} }

View File

@@ -0,0 +1,82 @@
package com.aiosman.riderpro.ui.message
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.AccountNotice
import com.aiosman.riderpro.data.AccountService
import com.aiosman.riderpro.data.CommentEntity
import com.aiosman.riderpro.data.CommentPagingSource
import com.aiosman.riderpro.data.CommentRemoteDataSource
import com.aiosman.riderpro.data.CommentService
import com.aiosman.riderpro.data.TestAccountServiceImpl
import com.aiosman.riderpro.data.TestCommentServiceImpl
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
object MessageListViewModel : ViewModel() {
val accountService: AccountService = TestAccountServiceImpl()
var noticeInfo by mutableStateOf<AccountNotice?>(null)
private val commentService: CommentService = TestCommentServiceImpl()
private val _commentItemsFlow = MutableStateFlow<PagingData<CommentEntity>>(PagingData.empty())
val commentItemsFlow = _commentItemsFlow.asStateFlow()
suspend fun initData() {
val info = accountService.getMyNoticeInfo()
noticeInfo = info
}
val likeNoticeCount
get() = noticeInfo?.likeCount ?: 0
val followNoticeCount
get() = noticeInfo?.followCount ?: 0
val favouriteNoticeCount
get() = noticeInfo?.favoriteCount ?: 0
val commentNoticeCount
get() = noticeInfo?.commentCount ?: 0
init {
viewModelScope.launch {
Pager(
config = PagingConfig(pageSize = 5, enablePlaceholders = false),
pagingSourceFactory = {
CommentPagingSource(
CommentRemoteDataSource(commentService),
selfNotice = true
)
}
).flow.cachedIn(viewModelScope).collectLatest {
_commentItemsFlow.value = it
}
}
}
private fun updateIsRead(id: Int) {
val currentPagingData = _commentItemsFlow.value
val updatedPagingData = currentPagingData.map { commentEntity ->
if (commentEntity.id == id) {
commentEntity.copy(unread = false)
} else {
commentEntity
}
}
_commentItemsFlow.value = updatedPagingData
}
fun updateReadStatus(id: Int) {
viewModelScope.launch {
commentService.updateReadStatus(id)
updateIsRead(id)
}
}
}

View File

@@ -87,6 +87,7 @@ import com.aiosman.riderpro.data.TestAccountServiceImpl
import com.aiosman.riderpro.data.TestMomentServiceImpl import com.aiosman.riderpro.data.TestMomentServiceImpl
import com.aiosman.riderpro.data.TestUserServiceImpl import com.aiosman.riderpro.data.TestUserServiceImpl
import com.aiosman.riderpro.data.UserService import com.aiosman.riderpro.data.UserService
import com.aiosman.riderpro.exp.timeAgo
import com.aiosman.riderpro.model.MomentEntity import com.aiosman.riderpro.model.MomentEntity
import com.aiosman.riderpro.model.MomentImageEntity import com.aiosman.riderpro.model.MomentImageEntity
import com.aiosman.riderpro.ui.NavigationRoute import com.aiosman.riderpro.ui.NavigationRoute
@@ -571,7 +572,7 @@ fun CommentItem(commentEntity: CommentEntity, onLike: () -> Unit = {}) {
Column { Column {
Text(text = commentEntity.name, fontWeight = FontWeight.Bold) Text(text = commentEntity.name, fontWeight = FontWeight.Bold)
Text(text = commentEntity.comment) Text(text = commentEntity.comment)
Text(text = commentEntity.date, fontSize = 12.sp, color = Color.Gray) Text(text = commentEntity.date.timeAgo(), fontSize = 12.sp, color = Color.Gray)
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {

View File

@@ -4,6 +4,8 @@ import android.content.Context
import coil.ImageLoader import coil.ImageLoader
import coil.request.CachePolicy import coil.request.CachePolicy
import com.aiosman.riderpro.data.api.getUnsafeOkHttpClient import com.aiosman.riderpro.data.api.getUnsafeOkHttpClient
import java.util.Date
import java.util.concurrent.TimeUnit
object Utils { object Utils {
fun generateRandomString(length: Int): String { fun generateRandomString(length: Int): String {
@@ -12,6 +14,7 @@ object Utils {
.map { allowedChars.random() } .map { allowedChars.random() }
.joinToString("") .joinToString("")
} }
fun getImageLoader(context: Context): ImageLoader { fun getImageLoader(context: Context): ImageLoader {
val okHttpClient = getUnsafeOkHttpClient() val okHttpClient = getUnsafeOkHttpClient()
return ImageLoader.Builder(context) return ImageLoader.Builder(context)
@@ -21,7 +24,25 @@ object Utils {
.components { .components {
}.build()
}
fun getTimeAgo(date: Date): String {
val now = Date()
val diffInMillis = now.time - date.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 -> "$seconds seconds ago"
minutes < 60 -> "$minutes minutes ago"
hours < 24 -> "$hours hours ago"
days < 365 -> "$days days ago"
else -> "$years years ago"
} }
.build()
} }
} }