diff --git a/app/build.gradle b/app/build.gradle index 6d896a3..b014866 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -82,6 +82,7 @@ dependencies { implementation "com.google.dagger:hilt-android:$hilt_version" kapt "com.google.dagger:hilt-compiler:$hilt_version" implementation "androidx.paging:paging-runtime-ktx:$paging_version" + implementation "androidx.room:room-paging:$room_version" testImplementation 'junit:junit:4.13.2' implementation "androidx.arch.core:core-testing:$arch_version" diff --git a/app/src/main/java/ru/netology/nmedia/adapter/AdViewHolder.kt b/app/src/main/java/ru/netology/nmedia/adapter/AdViewHolder.kt new file mode 100644 index 0000000..4f0cede --- /dev/null +++ b/app/src/main/java/ru/netology/nmedia/adapter/AdViewHolder.kt @@ -0,0 +1,16 @@ +package ru.netology.nmedia.adapter + +import androidx.recyclerview.widget.RecyclerView +import ru.netology.nmedia.BuildConfig +import ru.netology.nmedia.databinding.CardAdBinding +import ru.netology.nmedia.model.Ad +import ru.netology.nmedia.view.load + +class AdViewHolder( + private val binding: CardAdBinding +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(ad: Ad) { + binding.image.load("${BuildConfig.BASE_URL}/media/${ad.image}") + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/netology/nmedia/adapter/PagingLoadStateAdapter.kt b/app/src/main/java/ru/netology/nmedia/adapter/PagingLoadStateAdapter.kt new file mode 100644 index 0000000..c4bde5e --- /dev/null +++ b/app/src/main/java/ru/netology/nmedia/adapter/PagingLoadStateAdapter.kt @@ -0,0 +1,47 @@ +package ru.netology.nmedia.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.view.isVisible +import androidx.paging.LoadState +import androidx.paging.LoadStateAdapter +import androidx.recyclerview.widget.RecyclerView +import ru.netology.nmedia.databinding.LoadStateBinding + +class PagingLoadStateAdapter( + private val onInteractionListener: OnInteractionListener +) : LoadStateAdapter() { + + interface OnInteractionListener { + fun onRetry() {} + } + + override fun onCreateViewHolder(parent: ViewGroup, loadState: LoadState): LoadStateViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return LoadStateViewHolder( + LoadStateBinding.inflate(layoutInflater, parent, false), + onInteractionListener + ) + } + + override fun onBindViewHolder(holder: LoadStateViewHolder, loadState: LoadState) { + holder.bind(loadState) + } + + class LoadStateViewHolder( + private val binding: LoadStateBinding, + private val onInteractionListener: OnInteractionListener + ) : RecyclerView.ViewHolder(binding.root) { + fun bind(loadState: LoadState) { + binding.apply { + progress.isVisible = loadState is LoadState.Loading + retry.isVisible = loadState is LoadState.Error + + retry.setOnClickListener { + onInteractionListener.onRetry() + } + } + } + } + +} diff --git a/app/src/main/java/ru/netology/nmedia/adapter/PostDiffCallback.kt b/app/src/main/java/ru/netology/nmedia/adapter/PostDiffCallback.kt new file mode 100644 index 0000000..0828409 --- /dev/null +++ b/app/src/main/java/ru/netology/nmedia/adapter/PostDiffCallback.kt @@ -0,0 +1,25 @@ +package ru.netology.nmedia.adapter + +import androidx.recyclerview.widget.DiffUtil +import ru.netology.nmedia.model.FeedItem +import ru.netology.nmedia.model.Post + +class PostDiffCallback : DiffUtil.ItemCallback(){ + override fun areItemsTheSame(oldItem: FeedItem, newItem: FeedItem): Boolean { + if (oldItem::class != newItem::class) { + return false + } + return oldItem.id == newItem.id + } + + override fun areContentsTheSame(oldItem: FeedItem, newItem: FeedItem): Boolean { + return oldItem == newItem + } + + override fun getChangePayload(oldItem: FeedItem, newItem: FeedItem): Any = + Payload( + likedByMe = (newItem as Post).likedByMe.takeIf { it != (oldItem as Post).likedByMe }, + likes = newItem.likes.takeIf { it != (oldItem as Post).likes }, + content = newItem.content.takeIf { it != (oldItem as Post).content } + ) +} diff --git a/app/src/main/java/ru/netology/nmedia/view/PostViewHolder.kt b/app/src/main/java/ru/netology/nmedia/adapter/PostViewHolder.kt similarity index 69% rename from app/src/main/java/ru/netology/nmedia/view/PostViewHolder.kt rename to app/src/main/java/ru/netology/nmedia/adapter/PostViewHolder.kt index 2427f2d..d45f194 100644 --- a/app/src/main/java/ru/netology/nmedia/view/PostViewHolder.kt +++ b/app/src/main/java/ru/netology/nmedia/adapter/PostViewHolder.kt @@ -1,13 +1,17 @@ -package ru.netology.nmedia.view +package ru.netology.nmedia.adapter +import android.animation.ObjectAnimator +import android.animation.PropertyValuesHolder import android.view.View import android.widget.PopupMenu import androidx.recyclerview.widget.RecyclerView -import com.bumptech.glide.Glide import ru.netology.nmedia.R import ru.netology.nmedia.databinding.CardPostBinding import ru.netology.nmedia.model.Post import ru.netology.nmedia.util.ViewUtils +import ru.netology.nmedia.view.OnInteractionListener +import ru.netology.nmedia.view.load +import ru.netology.nmedia.view.loadCircleCrop class PostViewHolder( private val binding: CardPostBinding, @@ -21,7 +25,7 @@ class PostViewHolder( fun bind(post: Post) { binding.apply { tvAuthor.text = post.author - tvPublished.text = post.published + tvPublished.text = post.published.toString() tvContent.text = post.content mbLike.isChecked = post.likedByMe mbLike.setOnClickListener { onInteractionListener.onLike(post) } @@ -54,22 +58,11 @@ class PostViewHolder( } val url = "$G_BASE_URL/avatars/${post.authorAvatar}" - Glide.with(ivAvatar) - .load(url) - .timeout(10_000) - .placeholder(R.drawable.ic_baseline_image_24) - .error(R.drawable.ic_baseline_cancel_24) - .circleCrop() - .into(ivAvatar) + ivAvatar.loadCircleCrop(url) if (post.attachment != null) { ivContent.visibility = View.VISIBLE - Glide.with(ivContent) - .load("$G_BASE_URL/media/${post.attachment.url}") - .timeout(10_000) - .placeholder(R.drawable.ic_baseline_image_24) - .error(R.drawable.ic_baseline_cancel_24) - .into(ivContent) + ivContent.load("$G_BASE_URL/media/${post.attachment.url}") } else { ivContent.visibility = View.GONE } @@ -82,12 +75,24 @@ class PostViewHolder( } } - fun bindOnLikeChanged(post: Post) { - with(binding.mbLike) { - isChecked = post.likedByMe - text = ViewUtils.formattedNumber(post.likes) - setOnClickListener { onInteractionListener.onLike(post) } + fun bind(payload: Payload) { + payload.likedByMe?.also { + binding.mbLike.isChecked = it + if (it) { + ObjectAnimator.ofPropertyValuesHolder( + binding.mbLike, + PropertyValuesHolder.ofFloat(View.SCALE_X, 1.0F, 1.2F, 1.0F), + PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.0F, 1.2F, 1.0F) + ) + } else { + ObjectAnimator.ofFloat(binding.mbLike, View.ROTATION, 0F, 360F) + }.start() + } + payload.likes?.also { + binding.mbLike.text = it.toString() + } + payload.content?.also { + binding.tvContent.text = it } - } } diff --git a/app/src/main/java/ru/netology/nmedia/adapter/PostsAdapter.kt b/app/src/main/java/ru/netology/nmedia/adapter/PostsAdapter.kt new file mode 100644 index 0000000..5874bcb --- /dev/null +++ b/app/src/main/java/ru/netology/nmedia/adapter/PostsAdapter.kt @@ -0,0 +1,103 @@ +package ru.netology.nmedia.adapter + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.RecyclerView +import ru.netology.nmedia.R +import ru.netology.nmedia.databinding.CardAdBinding +import ru.netology.nmedia.databinding.CardPostBinding +import ru.netology.nmedia.databinding.CardTimingSeparatorBinding +import ru.netology.nmedia.model.Ad +import ru.netology.nmedia.model.FeedItem +import ru.netology.nmedia.model.Post +import ru.netology.nmedia.model.TimingSeparator +import ru.netology.nmedia.view.OnInteractionListener + +class PostsAdapter( + private val onInteractionListener: OnInteractionListener + ) : PagingDataAdapter(PostDiffCallback()) { + + override fun getItemViewType(position: Int): Int = + when (getItem(position)) { + is Ad -> R.layout.card_ad + is Post -> R.layout.card_post + is TimingSeparator -> R.layout.card_timing_separator + null -> error("unknown item type") + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder = + when (viewType) { + R.layout.card_post -> { + val binding = CardPostBinding.inflate(LayoutInflater.from(parent.context), parent, false) + PostViewHolder(binding, onInteractionListener) + } + R.layout.card_ad -> { + val binding = CardAdBinding.inflate(LayoutInflater.from(parent.context), parent, false) + AdViewHolder(binding) + } + R.layout.card_timing_separator -> { + val binding = CardTimingSeparatorBinding.inflate(LayoutInflater.from(parent.context), parent, false) + TimingSeparatorViewHolder(binding) + } + else -> error("unknown view type: $viewType") + } + + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int + ) { + when (val item = getItem(position)) { + is Ad -> (holder as? AdViewHolder)?.bind(item) + is Post -> (holder as? PostViewHolder)?.bind(item) + is TimingSeparator -> (holder as? TimingSeparatorViewHolder)?.bind(item) + null -> error("unknown item type") + } + } + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + payloads: List + ) { + if (payloads.isEmpty()) { + onBindViewHolder(holder, position) + } else { + payloads.forEach { + (it as? Payload)?.let { payloads -> + (holder as PostViewHolder).bind(payloads) + } + } + } + } +} + +data class Payload( + val likedByMe: Boolean? = null, + val likes: Int? = null, + val content: String? = null, + +) + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/ru/netology/nmedia/adapter/TimingSeparatorViewHolder.kt b/app/src/main/java/ru/netology/nmedia/adapter/TimingSeparatorViewHolder.kt new file mode 100644 index 0000000..a2a6862 --- /dev/null +++ b/app/src/main/java/ru/netology/nmedia/adapter/TimingSeparatorViewHolder.kt @@ -0,0 +1,21 @@ +package ru.netology.nmedia.adapter + +import androidx.recyclerview.widget.RecyclerView +import ru.netology.nmedia.R +import ru.netology.nmedia.databinding.CardTimingSeparatorBinding +import ru.netology.nmedia.model.TimingSeparator + +class TimingSeparatorViewHolder( + private val binding: CardTimingSeparatorBinding +) : RecyclerView.ViewHolder(binding.root) { + + fun bind(timingSeparator: TimingSeparator) { + binding.tvSeparator.setText( + when(timingSeparator.title) { + TimingSeparator.Period.TODAY -> R.string.today + TimingSeparator.Period.YESTERDAY -> R.string.yesterday + TimingSeparator.Period.LAST_WEEK -> R.string.lastWeek + } + ) + } +} diff --git a/app/src/main/java/ru/netology/nmedia/api/ApiModule.kt b/app/src/main/java/ru/netology/nmedia/api/ApiModule.kt index 6f79cb4..9e033c6 100644 --- a/app/src/main/java/ru/netology/nmedia/api/ApiModule.kt +++ b/app/src/main/java/ru/netology/nmedia/api/ApiModule.kt @@ -19,7 +19,7 @@ import javax.inject.Singleton class ApiModule { companion object { - private const val BASE_URL = "${BuildConfig.BASE_URL}/api/" + private const val BASE_URL = "${BuildConfig.BASE_URL}/api/slow/" } @Provides diff --git a/app/src/main/java/ru/netology/nmedia/dao/PostDao.kt b/app/src/main/java/ru/netology/nmedia/dao/PostDao.kt index 38f65dd..ccc30d0 100644 --- a/app/src/main/java/ru/netology/nmedia/dao/PostDao.kt +++ b/app/src/main/java/ru/netology/nmedia/dao/PostDao.kt @@ -1,5 +1,6 @@ package ru.netology.nmedia.dao +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy @@ -13,6 +14,9 @@ interface PostDao { @Query("SELECT * FROM PostEntity WHERE shown = 1 ORDER BY id DESC") fun getAll(): Flow> + @Query("SELECT * FROM PostEntity ORDER BY id DESC") + fun getPagingSource(): PagingSource + @Query("SELECT COUNT(*) FROM PostEntity WHERE shown = 0") fun getInvisibleAmount(): Int @@ -45,6 +49,10 @@ interface PostDao { @Query("DELETE FROM PostEntity WHERE id = :id") suspend fun removeById(id: Long) + @Query("DELETE FROM PostEntity") + suspend fun clear() + + @Query("UPDATE PostEntity SET shown = 1 WHERE shown = 0") suspend fun showAll() } \ No newline at end of file diff --git a/app/src/main/java/ru/netology/nmedia/dao/PostRemoteKeyDao.kt b/app/src/main/java/ru/netology/nmedia/dao/PostRemoteKeyDao.kt new file mode 100644 index 0000000..dfab205 --- /dev/null +++ b/app/src/main/java/ru/netology/nmedia/dao/PostRemoteKeyDao.kt @@ -0,0 +1,27 @@ +package ru.netology.nmedia.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import ru.netology.nmedia.entity.PostRemoteKeyEntity + +@Dao +interface PostRemoteKeyDao { + + @Query("SELECT max(key) FROM PostRemoteKeyEntity") + suspend fun max(): Long? + + @Query("SELECT min(key) FROM PostRemoteKeyEntity") + suspend fun min(): Long? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(postRemoteKeyEntity: PostRemoteKeyEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(postRemoteKeyEntity: List) + + @Query("DELETE FROM PostRemoteKeyEntity") + suspend fun clear() + +} \ No newline at end of file diff --git a/app/src/main/java/ru/netology/nmedia/db/AppDb.kt b/app/src/main/java/ru/netology/nmedia/db/AppDb.kt index 3c62130..006e662 100644 --- a/app/src/main/java/ru/netology/nmedia/db/AppDb.kt +++ b/app/src/main/java/ru/netology/nmedia/db/AppDb.kt @@ -4,12 +4,17 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import ru.netology.nmedia.dao.PostDao +import ru.netology.nmedia.dao.PostRemoteKeyDao import ru.netology.nmedia.entity.Converters import ru.netology.nmedia.entity.PostEntity +import ru.netology.nmedia.entity.PostRemoteKeyEntity -@Database(entities = [PostEntity::class], version = 1, exportSchema = false) +@Database(entities = [PostEntity::class, PostRemoteKeyEntity::class], + version = 1, + exportSchema = false +) @TypeConverters(Converters::class) abstract class AppDb : RoomDatabase() { abstract fun postDao(): PostDao - + abstract fun postRemoteKeyDao(): PostRemoteKeyDao } \ No newline at end of file diff --git a/app/src/main/java/ru/netology/nmedia/db/DbModule.kt b/app/src/main/java/ru/netology/nmedia/db/DbModule.kt index e992047..c73322f 100644 --- a/app/src/main/java/ru/netology/nmedia/db/DbModule.kt +++ b/app/src/main/java/ru/netology/nmedia/db/DbModule.kt @@ -8,6 +8,7 @@ import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import ru.netology.nmedia.dao.PostDao +import ru.netology.nmedia.dao.PostRemoteKeyDao import javax.inject.Singleton @InstallIn(SingletonComponent::class) @@ -27,4 +28,7 @@ class DbModule { fun providePostDao( appDb: AppDb ): PostDao = appDb.postDao() + + @Provides + fun providePostRemoteKeyDao(db: AppDb): PostRemoteKeyDao = db.postRemoteKeyDao() } \ No newline at end of file diff --git a/app/src/main/java/ru/netology/nmedia/entity/PostEntity.kt b/app/src/main/java/ru/netology/nmedia/entity/PostEntity.kt index 5e0b341..6d29993 100644 --- a/app/src/main/java/ru/netology/nmedia/entity/PostEntity.kt +++ b/app/src/main/java/ru/netology/nmedia/entity/PostEntity.kt @@ -16,12 +16,13 @@ data class PostEntity( val author: String, val authorAvatar:String, val content: String, - val published: String, + val published: Long, val likes: Int, val likedByMe: Boolean = false, val shown: Boolean = true, @Embedded - var attachment: AttachmentEmbeddable? = null + var attachment: AttachmentEmbeddable? = null, + val ownedByMe: Boolean ) { fun toDto() = Post( id, @@ -34,7 +35,8 @@ data class PostEntity( 0, 0, likedByMe, - attachment = attachment?.toDto() + attachment = attachment?.toDto(), + ownedByMe = ownedByMe ) fun hide() = this.copy(shown = false) @@ -48,7 +50,8 @@ data class PostEntity( dto.published, dto.likes, dto.likedByMe, - attachment = AttachmentEmbeddable.fromDto(dto.attachment) + attachment = AttachmentEmbeddable.fromDto(dto.attachment), + ownedByMe = dto.ownedByMe ) } diff --git a/app/src/main/java/ru/netology/nmedia/entity/PostRemoteKeyEntity.kt b/app/src/main/java/ru/netology/nmedia/entity/PostRemoteKeyEntity.kt new file mode 100644 index 0000000..4554cee --- /dev/null +++ b/app/src/main/java/ru/netology/nmedia/entity/PostRemoteKeyEntity.kt @@ -0,0 +1,16 @@ +package ru.netology.nmedia.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class PostRemoteKeyEntity( + @PrimaryKey + val type: KeyType, + val key: Long?, +) { + enum class KeyType { + AFTER, + BEFORE + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/netology/nmedia/model/Post.kt b/app/src/main/java/ru/netology/nmedia/model/Post.kt index 9b2b7d4..707a9dc 100644 --- a/app/src/main/java/ru/netology/nmedia/model/Post.kt +++ b/app/src/main/java/ru/netology/nmedia/model/Post.kt @@ -1,21 +1,45 @@ package ru.netology.nmedia.model +import android.os.Build +import androidx.annotation.RequiresApi import ru.netology.nmedia.enumeration.AttachmentType +import java.time.Instant.now + +sealed interface FeedItem { + val id: Long +} data class Post( - val id: Long, + override val id: Long, val authorId: Long, val author: String, val authorAvatar: String, val content: String, - val published: String, + val published: Long, val likes: Int, val shares: Int = 0, val watches: Int = 0, val likedByMe: Boolean = false, val attachment: Attachment? = null, val ownedByMe: Boolean = false -) +) : FeedItem { + @RequiresApi(Build.VERSION_CODES.O) + fun ageDays(): Long = (now().epochSecond - published) / 86400 +} + +data class Ad( + override val id: Long, + val image: String, +) : FeedItem + +data class TimingSeparator( + override val id: Long, + val title: Period +) : FeedItem { + enum class Period { + TODAY, YESTERDAY, LAST_WEEK + } +} data class Attachment( val url: String, diff --git a/app/src/main/java/ru/netology/nmedia/repository/PostPagingSource.kt b/app/src/main/java/ru/netology/nmedia/repository/PostPagingSource.kt deleted file mode 100644 index 3e34535..0000000 --- a/app/src/main/java/ru/netology/nmedia/repository/PostPagingSource.kt +++ /dev/null @@ -1,40 +0,0 @@ -package ru.netology.nmedia.repository - -import androidx.paging.PagingSource -import androidx.paging.PagingState -import retrofit2.HttpException -import ru.netology.nmedia.api.ApiService -import ru.netology.nmedia.model.Post -import java.io.IOException - -class PostPagingSource( - private val apiService: ApiService -) : PagingSource() { - - override fun getRefreshKey(state: PagingState): Long? = null - - override suspend fun load(params: LoadParams): LoadResult { - try { - val result = when (params) { - is LoadParams.Refresh -> { - apiService.getLatest(params.loadSize) - } - is LoadParams.Append -> { - apiService.getBefore(id = params.key, count = params.loadSize) - } - is LoadParams.Prepend -> return LoadResult.Page( - data = emptyList(), nextKey = null, prevKey = params.key, - ) - } - - if (!result.isSuccessful) { - throw HttpException(result) - } - - val data = result.body().orEmpty() - return LoadResult.Page(data, prevKey = params.key, nextKey = data.lastOrNull()?.id) - } catch (e: IOException) { - return LoadResult.Error(e) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/netology/nmedia/repository/PostRemoteMediator.kt b/app/src/main/java/ru/netology/nmedia/repository/PostRemoteMediator.kt new file mode 100644 index 0000000..10ef8d1 --- /dev/null +++ b/app/src/main/java/ru/netology/nmedia/repository/PostRemoteMediator.kt @@ -0,0 +1,94 @@ +package ru.netology.nmedia.repository + +import androidx.paging.* +import androidx.room.withTransaction +import ru.netology.nmedia.api.ApiService +import ru.netology.nmedia.dao.PostDao +import ru.netology.nmedia.dao.PostRemoteKeyDao +import ru.netology.nmedia.db.AppDb +import ru.netology.nmedia.entity.PostEntity +import ru.netology.nmedia.entity.PostRemoteKeyEntity +import ru.netology.nmedia.error.ApiError +import java.io.IOException + +@OptIn(ExperimentalPagingApi::class) +class PostRemoteMediator( + private val apiService: ApiService, + private val postDao: PostDao, + private val postRemoteKeyDao: PostRemoteKeyDao, + private val appDb: AppDb +) : RemoteMediator() { + + override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { + try { + val id: Long? + val result = when (loadType) { + LoadType.REFRESH -> apiService.getLatest(state.config.initialLoadSize) + /*{ + id = postRemoteKeyDao.max() + if (id == null) { + apiService.getLatest(state.config.initialLoadSize) + } else { + apiService.getAfter(id, state.config.pageSize) + } + }*/ + LoadType.PREPEND -> { + id = postRemoteKeyDao.max() ?: return MediatorResult.Success(false) + apiService.getAfter(id, state.config.pageSize) + //return MediatorResult.Success(true) + } + LoadType.APPEND -> { + id = postRemoteKeyDao.min() ?: return MediatorResult.Success(false) + apiService.getBefore(id, state.config.pageSize) + } + } + + if (!result.isSuccessful) { + throw ApiError(result.code(), result.message()) + } + + val body = result.body() ?: throw ApiError(result.code(), result.message()) + + appDb.withTransaction { + when (loadType) { + LoadType.REFRESH -> { + postRemoteKeyDao.clear() + postRemoteKeyDao.insert( + listOf( + PostRemoteKeyEntity( + PostRemoteKeyEntity.KeyType.AFTER, + body.firstOrNull()?.id + ), + PostRemoteKeyEntity( + PostRemoteKeyEntity.KeyType.BEFORE, + body.lastOrNull()?.id + ) + ) + ) + postDao.clear() + } + LoadType.PREPEND -> { + postRemoteKeyDao.insert( + PostRemoteKeyEntity( + PostRemoteKeyEntity.KeyType.AFTER, + body.firstOrNull()?.id + ) + ) + } + LoadType.APPEND -> { + postRemoteKeyDao.insert( + PostRemoteKeyEntity( + PostRemoteKeyEntity.KeyType.BEFORE, + body.lastOrNull()?.id, + ) + ) + } + } + postDao.insert(body.map(PostEntity::fromDto)) + } + return MediatorResult.Success(body.isEmpty()) + } catch (e: IOException) { + return MediatorResult.Error(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/netology/nmedia/repository/PostRepository.kt b/app/src/main/java/ru/netology/nmedia/repository/PostRepository.kt index c6da38f..ef48bd9 100644 --- a/app/src/main/java/ru/netology/nmedia/repository/PostRepository.kt +++ b/app/src/main/java/ru/netology/nmedia/repository/PostRepository.kt @@ -17,6 +17,5 @@ interface PostRepository { fun getInvisibleAmount(): Int suspend fun saveWithAttachment(post: Post, uploadItem: MediaUpload) suspend fun upload(uploadItem: MediaUpload): Media - fun refreshData() } \ No newline at end of file diff --git a/app/src/main/java/ru/netology/nmedia/repository/PostRepositoryServerImpl.kt b/app/src/main/java/ru/netology/nmedia/repository/PostRepositoryServerImpl.kt index 5358d20..665e324 100644 --- a/app/src/main/java/ru/netology/nmedia/repository/PostRepositoryServerImpl.kt +++ b/app/src/main/java/ru/netology/nmedia/repository/PostRepositoryServerImpl.kt @@ -1,18 +1,15 @@ package ru.netology.nmedia.repository -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingData +import androidx.paging.* import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.catch -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.* import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.asRequestBody import ru.netology.nmedia.api.ApiService import ru.netology.nmedia.dao.PostDao +import ru.netology.nmedia.dao.PostRemoteKeyDao +import ru.netology.nmedia.db.AppDb import ru.netology.nmedia.entity.PostEntity import ru.netology.nmedia.entity.toEntity import ru.netology.nmedia.enumeration.AttachmentType @@ -20,10 +17,7 @@ import ru.netology.nmedia.error.ApiError import ru.netology.nmedia.error.AppError import ru.netology.nmedia.error.NetworkError import ru.netology.nmedia.error.UnknownError -import ru.netology.nmedia.model.Attachment -import ru.netology.nmedia.model.Media -import ru.netology.nmedia.model.MediaUpload -import ru.netology.nmedia.model.Post +import ru.netology.nmedia.model.* import java.io.IOException import javax.inject.Inject import javax.inject.Singleton @@ -31,20 +25,23 @@ import javax.inject.Singleton @Singleton class PostRepositoryServerImpl @Inject constructor( private val postDao: PostDao, - private val apiService: ApiService - ) : PostRepository { + private val apiService: ApiService, + postRemoteKeyDao: PostRemoteKeyDao, + appDb: AppDb +) : PostRepository { - override var data = getNewPager() - - override fun refreshData() { - data = getNewPager() - } - private fun getNewPager() : Flow> = Pager( + @OptIn(ExperimentalPagingApi::class) + override var data = Pager( config = PagingConfig(pageSize = 10, enablePlaceholders = false), - pagingSourceFactory = { - PostPagingSource(apiService) - } - ).flow + pagingSourceFactory = { postDao.getPagingSource() }, + remoteMediator = PostRemoteMediator( + apiService = apiService, + postDao = postDao, + postRemoteKeyDao = postRemoteKeyDao, + appDb = appDb + ) + ).flow.map { it.map(PostEntity::toDto) } + override suspend fun getAll(): List { try { diff --git a/app/src/main/java/ru/netology/nmedia/view/FeedFragment.kt b/app/src/main/java/ru/netology/nmedia/view/FeedFragment.kt index f2489cc..fdbecce 100644 --- a/app/src/main/java/ru/netology/nmedia/view/FeedFragment.kt +++ b/app/src/main/java/ru/netology/nmedia/view/FeedFragment.kt @@ -2,22 +2,28 @@ package ru.netology.nmedia.view import android.annotation.SuppressLint import android.content.Intent +import android.os.Build import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.Toast +import androidx.annotation.RequiresApi import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.paging.LoadState +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.collectLatest import ru.netology.nmedia.R +import ru.netology.nmedia.adapter.PagingLoadStateAdapter +import ru.netology.nmedia.adapter.PostsAdapter import ru.netology.nmedia.databinding.FragmentFeedBinding import ru.netology.nmedia.model.FeedModelState import ru.netology.nmedia.model.Post @@ -36,6 +42,7 @@ class FeedFragment : Fragment() { val viewModel: PostViewModel by activityViewModels() + @RequiresApi(Build.VERSION_CODES.O) @SuppressLint("SetTextI18n") override fun onCreateView( inflater: LayoutInflater, @@ -85,18 +92,46 @@ class FeedFragment : Fragment() { } ) - binding.rvList.adapter = adapter + binding.rvList.adapter = adapter.withLoadStateHeaderAndFooter( + header = PagingLoadStateAdapter(object : PagingLoadStateAdapter.OnInteractionListener { + override fun onRetry() { + adapter.retry() + } + }), + footer = PagingLoadStateAdapter(object : PagingLoadStateAdapter.OnInteractionListener { + override fun onRetry() { + adapter.retry() + } + }) + ) + + ItemTouchHelper(object : ItemTouchHelper.SimpleCallback( + 0, ItemTouchHelper.START or ItemTouchHelper.END + ) { + override fun onMove( + recyclerView: RecyclerView, + viewHolder: RecyclerView.ViewHolder, + target: RecyclerView.ViewHolder + ): Boolean { + TODO("Not yet implemented") + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + println("DO SOMETHING") + } + }).attachToRecyclerView(binding.rvList) lifecycleScope.launchWhenCreated { viewModel.data.collectLatest { adapter.submitData(it) } } - +/* +проверка на работоспособность без обновления при log in/out viewModel.authChanged.observe(viewLifecycleOwner) { viewModel.refreshData() } - +*/ // FIXME: Сломалось при переходе на paging // viewModel.data.observe(viewLifecycleOwner) { state -> // adapter.submitList(state.posts) { @@ -135,8 +170,9 @@ class FeedFragment : Fragment() { lifecycleScope.launchWhenCreated { adapter.loadStateFlow.collectLatest { binding.swiper.isRefreshing = it.refresh is LoadState.Loading - || it.append is LoadState.Loading - || it.prepend is LoadState.Loading +// Removed when LoadStateAdapter added +// || it.append is LoadState.Loading +// || it.prepend is LoadState.Loading } } diff --git a/app/src/main/java/ru/netology/nmedia/view/OpenPhotoFragment.kt b/app/src/main/java/ru/netology/nmedia/view/OpenPhotoFragment.kt index 0e771a6..d76e552 100644 --- a/app/src/main/java/ru/netology/nmedia/view/OpenPhotoFragment.kt +++ b/app/src/main/java/ru/netology/nmedia/view/OpenPhotoFragment.kt @@ -1,11 +1,10 @@ package ru.netology.nmedia.view import android.os.Bundle -import androidx.fragment.app.Fragment import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.net.toUri +import androidx.fragment.app.Fragment import com.bumptech.glide.Glide import ru.netology.nmedia.R import ru.netology.nmedia.databinding.OpenPhotoBinding diff --git a/app/src/main/java/ru/netology/nmedia/view/OpenPostFragment.kt b/app/src/main/java/ru/netology/nmedia/view/OpenPostFragment.kt index 3f984d7..87421bd 100644 --- a/app/src/main/java/ru/netology/nmedia/view/OpenPostFragment.kt +++ b/app/src/main/java/ru/netology/nmedia/view/OpenPostFragment.kt @@ -10,14 +10,13 @@ import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController -import com.bumptech.glide.Glide import kotlinx.coroutines.ExperimentalCoroutinesApi import ru.netology.nmedia.R +import ru.netology.nmedia.adapter.PostViewHolder.Companion.G_BASE_URL import ru.netology.nmedia.databinding.FragmentOpenPostBinding import ru.netology.nmedia.model.Post import ru.netology.nmedia.util.StringArg import ru.netology.nmedia.util.ViewUtils -import ru.netology.nmedia.view.PostViewHolder.Companion.G_BASE_URL import ru.netology.nmedia.viewmodel.PostViewModel @OptIn(ExperimentalCoroutinesApi::class) @@ -41,10 +40,10 @@ class OpenPostFragment : Fragment() { val post: Post = viewModel.postToOpen ?: throw RuntimeException() - viewModel.postToOpen = null +// viewModel.postToOpen = null binding.apply { tvAuthor.text = post.author - tvPublished.text = post.published + tvPublished.text = post.published.toString() tvContent.text = post.content mbLike.isChecked = post.likedByMe mbLike.text = ViewUtils.formattedNumber(post.likes) @@ -86,22 +85,11 @@ class OpenPostFragment : Fragment() { } val url = "${G_BASE_URL}/avatars/${post.authorAvatar}" - Glide.with(ivAvatar) - .load(url) - .timeout(10_000) - .placeholder(R.drawable.ic_baseline_image_24) - .error(R.drawable.ic_baseline_cancel_24) - .circleCrop() - .into(ivAvatar) + ivAvatar.loadCircleCrop(url) if (post.attachment != null) { ivContent.visibility = View.VISIBLE - Glide.with(ivContent) - .load("$G_BASE_URL/media/${post.attachment.url}") - .timeout(10_000) - .placeholder(R.drawable.ic_baseline_image_24) - .error(R.drawable.ic_baseline_cancel_24) - .into(ivContent) + ivContent.load("$G_BASE_URL/media/${post.attachment.url}") } else { ivContent.visibility = View.GONE } diff --git a/app/src/main/java/ru/netology/nmedia/view/PostDiffCallback.kt b/app/src/main/java/ru/netology/nmedia/view/PostDiffCallback.kt deleted file mode 100644 index 926906c..0000000 --- a/app/src/main/java/ru/netology/nmedia/view/PostDiffCallback.kt +++ /dev/null @@ -1,18 +0,0 @@ -package ru.netology.nmedia.view - -import androidx.recyclerview.widget.DiffUtil -import ru.netology.nmedia.model.Post - -class PostDiffCallback : DiffUtil.ItemCallback(){ - override fun areItemsTheSame(oldItem: Post, newItem: Post): Boolean { - return oldItem.id == newItem.id - } - - override fun areContentsTheSame(oldItem: Post, newItem: Post): Boolean { - return oldItem == newItem - } - - override fun getChangePayload(oldItem: Post, newItem: Post): Any? { - return if (oldItem.likedByMe != newItem.likedByMe) true else null - } -} diff --git a/app/src/main/java/ru/netology/nmedia/view/PostsAdapter.kt b/app/src/main/java/ru/netology/nmedia/view/PostsAdapter.kt deleted file mode 100644 index 3225715..0000000 --- a/app/src/main/java/ru/netology/nmedia/view/PostsAdapter.kt +++ /dev/null @@ -1,37 +0,0 @@ -package ru.netology.nmedia.view - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.paging.PagingDataAdapter -import ru.netology.nmedia.databinding.CardPostBinding -import ru.netology.nmedia.model.Post - -class PostsAdapter( - private val onInteractionListener: OnInteractionListener - ) : PagingDataAdapter(PostDiffCallback()) { - - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostViewHolder { - val binding = CardPostBinding.inflate(LayoutInflater.from(parent.context), parent, false) - return PostViewHolder(binding, onInteractionListener) - } - - override fun onBindViewHolder(holder: PostViewHolder, position: Int) { - val post = getItem(position) ?: return - holder.bind(post) - } - - override fun onBindViewHolder( - holder: PostViewHolder, - position: Int, - payloads: MutableList - ) { - if (payloads.isEmpty()) { - super.onBindViewHolder(holder, position, payloads) - } else { - if (payloads[0] == true) { - holder.bindOnLikeChanged(getItem(position)!!) - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/ru/netology/nmedia/view/ViewExtensions.kt b/app/src/main/java/ru/netology/nmedia/view/ViewExtensions.kt new file mode 100644 index 0000000..b1ee817 --- /dev/null +++ b/app/src/main/java/ru/netology/nmedia/view/ViewExtensions.kt @@ -0,0 +1,16 @@ +package ru.netology.nmedia.view + +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation +import com.bumptech.glide.load.resource.bitmap.CircleCrop + +fun ImageView.load(url: String, vararg transforms: BitmapTransformation = emptyArray()) = + Glide.with(this) + .load(url) + .timeout(10_000) + .transform(*transforms) + .into(this) + +fun ImageView.loadCircleCrop(url: String,vararg transforms: BitmapTransformation = emptyArray()) = + load(url, CircleCrop(), *transforms) \ No newline at end of file diff --git a/app/src/main/java/ru/netology/nmedia/viewmodel/PostViewModel.kt b/app/src/main/java/ru/netology/nmedia/viewmodel/PostViewModel.kt index 98b1457..2ef9ae6 100644 --- a/app/src/main/java/ru/netology/nmedia/viewmodel/PostViewModel.kt +++ b/app/src/main/java/ru/netology/nmedia/viewmodel/PostViewModel.kt @@ -1,24 +1,27 @@ package ru.netology.nmedia.viewmodel import android.net.Uri +import android.os.Build +import androidx.annotation.RequiresApi import androidx.lifecycle.* import androidx.paging.PagingData -import androidx.paging.map +import androidx.paging.cachedIn +import androidx.paging.insertSeparators import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import ru.netology.nmedia.auth.AppAuth -import ru.netology.nmedia.model.FeedModelState -import ru.netology.nmedia.model.MediaUpload -import ru.netology.nmedia.model.PhotoModel -import ru.netology.nmedia.model.Post +import ru.netology.nmedia.model.* import ru.netology.nmedia.repository.PostRepository import ru.netology.nmedia.util.SingleLiveEvent import java.io.File import javax.inject.Inject +import kotlin.random.Random private val empty = Post( id = 0, @@ -26,7 +29,7 @@ private val empty = Post( author = "", authorAvatar = "", content = "", - published = "", + published = 0, likes = 0, shares = 0, watches = 0, @@ -48,17 +51,40 @@ class PostViewModel @Inject constructor( var postToOpen: Post? = null - val data: Flow> = appAuth + @RequiresApi(Build.VERSION_CODES.O) + private val cached: Flow> = repository + .data + .map { pagingData -> + pagingData.insertSeparators { before, after -> + if (after == null) { + null + } else if ( + before == null + || after.ageDays() - before.ageDays() in 1..2 + ) { + TimingSeparator( + Random.nextLong(), + when (after.ageDays()) { + 0L -> TimingSeparator.Period.TODAY + 1L -> TimingSeparator.Period.YESTERDAY + else -> TimingSeparator.Period.LAST_WEEK + } + ) + } else if (before.id.rem(5) == 0L) { + Ad( + Random.nextLong(), + "figma.jpg" + ) + } else null + } + }.cachedIn(viewModelScope) + + @RequiresApi(Build.VERSION_CODES.O) + val data: Flow> = appAuth .authStateFlow - .flatMapLatest { (myId, _) -> - repository - .data - .map { posts -> - posts.map { it.copy(ownedByMe = it.authorId == myId) } - } - }.flowOn(Dispatchers.Default) + .flatMapLatest { cached } - val authChanged = appAuth.authStateFlow.asLiveData() + val authChanged = appAuth.authStateFlow.asLiveData() private val _state = SingleLiveEvent() val state: LiveData @@ -78,7 +104,7 @@ class PostViewModel @Inject constructor( private val scope = MainScope() - var onScroll = false + private var onScroll = false private lateinit var lastFailArgs: Pair @@ -89,7 +115,7 @@ class PostViewModel @Inject constructor( private fun loadPosts(onRefresh: Boolean = false) = viewModelScope.launch { try { _state.value = if (onRefresh) FeedModelState.Refreshing else FeedModelState.Loading - repository.getAll() + // repository.getAll() _state.value = FeedModelState.Idle } catch (e: Exception) { lastFailArgs = ErrorType.LOAD to Any() @@ -195,7 +221,4 @@ class PostViewModel @Inject constructor( _photo.value = PhotoModel(uri, file) } - fun refreshData() { - repository.refreshData() - } } \ No newline at end of file diff --git a/app/src/main/res/layout/card_ad.xml b/app/src/main/res/layout/card_ad.xml new file mode 100644 index 0000000..b28aa80 --- /dev/null +++ b/app/src/main/res/layout/card_ad.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/card_timing_separator.xml b/app/src/main/res/layout/card_timing_separator.xml new file mode 100644 index 0000000..7046017 --- /dev/null +++ b/app/src/main/res/layout/card_timing_separator.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml index d33bfd8..8581edc 100644 --- a/app/src/main/res/layout/fragment_feed.xml +++ b/app/src/main/res/layout/fragment_feed.xml @@ -5,71 +5,71 @@ xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"> - - - + android:id="@+id/swiper"> - - - + tools:context=".view.FeedFragment"> - + + - + - - + - + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/load_state.xml b/app/src/main/res/layout/load_state.xml new file mode 100644 index 0000000..256705e --- /dev/null +++ b/app/src/main/res/layout/load_state.xml @@ -0,0 +1,22 @@ + + + + + +