diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 362d915..ce59b6c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,6 +48,7 @@ + diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineFragment.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineFragment.kt index 3aab419..d2e64f9 100644 --- a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineFragment.kt +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineFragment.kt @@ -27,6 +27,7 @@ import androidx.paging.ExperimentalPagingApi import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.SimpleItemAnimator import at.connyduck.pixelcat.R +import at.connyduck.pixelcat.components.timeline.detail.DetailActivity import at.connyduck.pixelcat.components.util.extension.getDisplayWidthInPx import at.connyduck.pixelcat.components.util.getColorForAttr import at.connyduck.pixelcat.dagger.ViewModelFactory @@ -101,4 +102,8 @@ class TimelineFragment : DaggerFragment(R.layout.fragment_timeline), TimeLineAct override fun onMediaVisibilityChanged(status: StatusEntity) { viewModel.onMediaVisibilityChanged(status) } + + override fun onDetailsOpened(status: StatusEntity) { + startActivity(DetailActivity.newIntent(requireContext(), status.actionableId)) + } } diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineListAdapter.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineListAdapter.kt index 1a97eba..8242435 100644 --- a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineListAdapter.kt +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineListAdapter.kt @@ -31,6 +31,7 @@ import at.connyduck.pixelcat.databinding.ItemStatusBinding import at.connyduck.pixelcat.db.entitity.StatusEntity import coil.api.load import coil.transform.RoundedCornersTransformation +import java.text.DateFormat import java.text.SimpleDateFormat interface TimeLineActionListener { @@ -38,6 +39,7 @@ interface TimeLineActionListener { fun onBoost(post: StatusEntity) fun onReply(status: StatusEntity) fun onMediaVisibilityChanged(status: StatusEntity) + fun onDetailsOpened(status: StatusEntity) } object TimelineDiffUtil : DiffUtil.ItemCallback() { @@ -66,65 +68,71 @@ class TimelineListAdapter( } override fun onBindViewHolder(holder: BindingHolder, position: Int) { - getItem(position)?.let { status -> - - // TODO order the stuff here - - (holder.binding.postImages.adapter as TimelineImageAdapter).images = status.attachments - - val maxImageRatio = status.attachments.map { - if(it.meta?.small?.width == null || it.meta.small.height == null) { - 1f - } else { - it.meta.small.height.toFloat() / it.meta.small.width.toFloat() - } - }.max()?.coerceAtMost(1f) ?: 1f - - holder.binding.postImages.layoutParams.height = (displayWidth * maxImageRatio).toInt() - - holder.binding.postAvatar.load(status.account.avatar) { - transformations(RoundedCornersTransformation(25f)) - } - - holder.binding.postAvatar.setOnClickListener { - holder.binding.root.context.startActivity(ProfileActivity.newIntent(holder.binding.root.context, status.account.id)) - } - - holder.binding.postDisplayName.text = status.account.displayName - holder.binding.postName.text = "@${status.account.username}" - - holder.binding.postLikeButton.isChecked = status.favourited - - holder.binding.postLikeButton.setEventListener { _, _ -> - listener.onFavorite(status) - true - } - - holder.binding.postBoostButton.isChecked = status.reblogged - - holder.binding.postBoostButton.setEventListener { _, _ -> - listener.onBoost(status) - true - } - - holder.binding.postReplyButton.setOnClickListener { - listener.onReply(status) - } - - holder.binding.postIndicator.visible = status.attachments.size > 1 - - holder.binding.postImages.visible = status.attachments.isNotEmpty() - - holder.binding.postDescription.text = status.content.parseAsHtml().trim() - - holder.binding.postDate.text = dateTimeFormatter.format(status.createdAt) - - holder.binding.postSensitiveMediaOverlay.visible = status.attachments.isNotEmpty() && !status.mediaVisible - - holder.binding.postSensitiveMediaOverlay.setOnClickListener { - listener.onMediaVisibilityChanged(status) - } + holder.bind(status, displayWidth, listener, dateTimeFormatter) } } } + +fun BindingHolder.bind(status: StatusEntity, displayWidth: Int, listener: TimeLineActionListener, dateTimeFormatter: DateFormat) { + // TODO order the stuff here + + (binding.postImages.adapter as TimelineImageAdapter).images = status.attachments + + val maxImageRatio = status.attachments.map { + if(it.meta?.small?.width == null || it.meta.small.height == null) { + 1f + } else { + it.meta.small.height.toFloat() / it.meta.small.width.toFloat() + } + }.max()?.coerceAtMost(1f) ?: 1f + + binding.postImages.layoutParams.height = (displayWidth * maxImageRatio).toInt() + + binding.postAvatar.load(status.account.avatar) { + transformations(RoundedCornersTransformation(25f)) + } + + binding.postAvatar.setOnClickListener { + binding.root.context.startActivity(ProfileActivity.newIntent(binding.root.context, status.account.id)) + } + + binding.postDisplayName.text = status.account.displayName + binding.postName.text = "@${status.account.username}" + + binding.postLikeButton.isChecked = status.favourited + + binding.postLikeButton.setEventListener { _, _ -> + listener.onFavorite(status) + true + } + + binding.postBoostButton.isChecked = status.reblogged + + binding.postBoostButton.setEventListener { _, _ -> + listener.onBoost(status) + true + } + + binding.postReplyButton.setOnClickListener { + listener.onReply(status) + } + + binding.postIndicator.visible = status.attachments.size > 1 + + binding.postImages.visible = status.attachments.isNotEmpty() + + binding.postDescription.text = status.content.parseAsHtml().trim() + + binding.postDate.text = dateTimeFormatter.format(status.createdAt) + + binding.postSensitiveMediaOverlay.visible = status.attachments.isNotEmpty() && !status.mediaVisible + + binding.postSensitiveMediaOverlay.setOnClickListener { + listener.onMediaVisibilityChanged(status) + } + + binding.root.setOnClickListener { + listener.onDetailsOpened(status) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailActivity.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailActivity.kt new file mode 100644 index 0000000..99c247f --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailActivity.kt @@ -0,0 +1,102 @@ +package at.connyduck.pixelcat.components.timeline.detail + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.viewModels +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.MergeAdapter +import at.connyduck.pixelcat.components.general.BaseActivity +import at.connyduck.pixelcat.components.timeline.TimeLineActionListener +import at.connyduck.pixelcat.components.util.Success +import at.connyduck.pixelcat.components.util.extension.getDisplayWidthInPx +import at.connyduck.pixelcat.components.util.extension.hide +import at.connyduck.pixelcat.components.util.extension.show +import at.connyduck.pixelcat.dagger.ViewModelFactory +import at.connyduck.pixelcat.databinding.ActivityDetailBinding +import at.connyduck.pixelcat.db.entitity.StatusEntity +import at.connyduck.pixelcat.util.viewBinding +import javax.inject.Inject + +class DetailActivity: BaseActivity(), TimeLineActionListener { + + @Inject + lateinit var viewModelFactory: ViewModelFactory + + private val viewModel: DetailViewModel by viewModels { viewModelFactory } + + private val binding by viewBinding(ActivityDetailBinding::inflate) + + private lateinit var statusAdapter: DetailStatusAdapter + + private lateinit var repliesAdapter: DetailReplyAdapter + + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + + binding.root.setOnApplyWindowInsetsListener { _, insets -> + val top = insets.systemWindowInsetTop + + binding.root.setPadding(0, top, 0, 0) + + insets.consumeSystemWindowInsets() + } + + viewModel.setStatusId(intent.getStringExtra(EXTRA_STATUS_ID)!!) + + val displayWidth = getDisplayWidthInPx() + + statusAdapter = DetailStatusAdapter(displayWidth, this) + repliesAdapter = DetailReplyAdapter(this) + + binding.detailRecyclerView.adapter = MergeAdapter(statusAdapter, repliesAdapter) + + viewModel.currentStatus.observe(this, Observer { + if(it is Success) { + binding.detailProgress.hide() + binding.detailRecyclerView.show() + statusAdapter.submitList(listOf(it.data)) + } + }) + + viewModel.replies.observe(this, Observer { + if(it is Success) { + repliesAdapter.submitList(it.data) + } + }) + + } + + override fun onFavorite(post: StatusEntity) { + TODO("Not yet implemented") + } + + override fun onBoost(post: StatusEntity) { + TODO("Not yet implemented") + } + + override fun onReply(status: StatusEntity) { + TODO("Not yet implemented") + } + + override fun onMediaVisibilityChanged(status: StatusEntity) { + TODO("Not yet implemented") + } + + override fun onDetailsOpened(status: StatusEntity) { + // nothing to do, we already are in details + } + + companion object { + private const val EXTRA_STATUS_ID = "STATUS_ID" + + fun newIntent(context: Context, statusId: String): Intent { + return Intent(context, DetailActivity::class.java).apply { + putExtra(EXTRA_STATUS_ID, statusId) + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailReplyAdapter.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailReplyAdapter.kt new file mode 100644 index 0000000..2aefe82 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailReplyAdapter.kt @@ -0,0 +1,58 @@ +package at.connyduck.pixelcat.components.timeline.detail + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.core.text.parseAsHtml +import androidx.recyclerview.widget.ListAdapter +import at.connyduck.pixelcat.components.timeline.TimeLineActionListener +import at.connyduck.pixelcat.components.timeline.TimelineDiffUtil +import at.connyduck.pixelcat.components.util.BindingHolder +import at.connyduck.pixelcat.databinding.ItemReplyBinding +import at.connyduck.pixelcat.db.entitity.StatusEntity +import coil.api.load +import coil.transform.RoundedCornersTransformation +import java.text.SimpleDateFormat + +class DetailReplyAdapter( + private val listener: TimeLineActionListener +): ListAdapter>(TimelineDiffUtil) { + + private val dateTimeFormatter = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.SHORT, SimpleDateFormat.SHORT) + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemReplyBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return BindingHolder(binding) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + getItem(position)?.let { status -> + + holder.binding.postAvatar.load(status.account.avatar) { + transformations(RoundedCornersTransformation(25f)) + } + + holder.binding.postDisplayName.text = status.account.displayName + holder.binding.postName.text = "@${status.account.username}" + + holder.binding.postDescription.text = status.content.parseAsHtml().trim() + + holder.binding.postDate.text = dateTimeFormatter.format(status.createdAt) + + holder.binding.postLikeButton.isChecked = status.favourited + + holder.binding.postLikeButton.setEventListener { _, _ -> + listener.onFavorite(status) + true + } + + holder.binding.postReplyButton.setOnClickListener { + listener.onReply(status) + } + + } + } + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailStatusAdapter.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailStatusAdapter.kt new file mode 100644 index 0000000..cc01752 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailStatusAdapter.kt @@ -0,0 +1,40 @@ +package at.connyduck.pixelcat.components.timeline.detail + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.ListAdapter +import at.connyduck.pixelcat.components.timeline.TimeLineActionListener +import at.connyduck.pixelcat.components.timeline.TimelineDiffUtil +import at.connyduck.pixelcat.components.timeline.TimelineImageAdapter +import at.connyduck.pixelcat.components.timeline.bind +import at.connyduck.pixelcat.components.util.BindingHolder +import at.connyduck.pixelcat.databinding.ItemStatusBinding +import at.connyduck.pixelcat.db.entitity.StatusEntity +import java.text.SimpleDateFormat + + +class DetailStatusAdapter( + private val displayWidth: Int, + private val listener: TimeLineActionListener +): ListAdapter>(TimelineDiffUtil) { + + private val dateTimeFormatter = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.SHORT, SimpleDateFormat.SHORT) + + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int + ): BindingHolder { + val binding = ItemStatusBinding.inflate(LayoutInflater.from(parent.context), parent, false) + binding.postImages.adapter = TimelineImageAdapter() + binding.postIndicator.setViewPager(binding.postImages) + (binding.postImages.adapter as TimelineImageAdapter).registerAdapterDataObserver(binding.postIndicator.adapterDataObserver) + return BindingHolder(binding) + } + + override fun onBindViewHolder(holder: BindingHolder, position: Int) { + getItem(position)?.let { status -> + holder.bind(status, displayWidth, listener, dateTimeFormatter) + } + } + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailViewModel.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailViewModel.kt new file mode 100644 index 0000000..776a3e0 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailViewModel.kt @@ -0,0 +1,97 @@ +/* + * Copyright (C) 2020 Conny Duck + * + * This file is part of Pixelcat. + * + * Pixelcat is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Pixelcat is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package at.connyduck.pixelcat.components.timeline.detail + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import at.connyduck.pixelcat.components.util.Loading +import at.connyduck.pixelcat.components.util.Success +import at.connyduck.pixelcat.components.util.UiState +import at.connyduck.pixelcat.components.util.Error +import at.connyduck.pixelcat.db.AccountManager +import at.connyduck.pixelcat.db.AppDatabase +import at.connyduck.pixelcat.db.entitity.StatusEntity +import at.connyduck.pixelcat.db.entitity.toEntity +import at.connyduck.pixelcat.network.FediverseApi +import kotlinx.coroutines.launch +import javax.inject.Inject + +class DetailViewModel @Inject constructor( + val api: FediverseApi, + val db: AppDatabase, + val accountManager: AccountManager +) : ViewModel() { + + val currentStatus = MutableLiveData>() + val replies = MutableLiveData>>() + + private var statusId = "" + + fun setStatusId(statusId: String) { + this.statusId = statusId + + currentStatus.value = Loading() + replies.value = Loading() + + viewModelScope.launch { + db.statusDao().status(statusId, accountManager.activeAccount()?.id!!)?.let { + currentStatus.value = Success(it) + } + loadStatus() + } + viewModelScope.launch { + loadReplies() + } + } + + fun reload() { + currentStatus.value = Loading() + replies.value = Loading() + viewModelScope.launch { + loadStatus() + } + viewModelScope.launch { + loadReplies() + } + } + + + private suspend fun loadStatus() { + api.status(statusId).fold({ + val statusEntity = it.toEntity(accountManager.activeAccount()?.id!!) + db.statusDao().insertOrReplace(statusEntity) + currentStatus.value = Success(statusEntity) + }, { + currentStatus.value = Error(cause = it) + }) + } + + private suspend fun loadReplies() { + api.statusContext(statusId).fold({ + replies.value = Success(it.descendants.map{ + descendant -> descendant.toEntity(accountManager.activeAccount()?.id!!) + }) + }, { + replies.value = Error(cause = it) + }) + } + +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ActivityModule.kt b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ActivityModule.kt index 1a54c30..bdac11a 100644 --- a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ActivityModule.kt +++ b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ActivityModule.kt @@ -23,6 +23,7 @@ import at.connyduck.pixelcat.components.main.MainActivity import at.connyduck.pixelcat.components.about.AboutActivity import at.connyduck.pixelcat.components.about.licenses.LicenseActivity import at.connyduck.pixelcat.components.compose.ComposeActivity +import at.connyduck.pixelcat.components.timeline.detail.DetailActivity import at.connyduck.pixelcat.components.login.LoginActivity import at.connyduck.pixelcat.components.profile.ProfileActivity import at.connyduck.pixelcat.components.settings.SettingsActivity @@ -57,4 +58,7 @@ abstract class ActivityModule { @ContributesAndroidInjector abstract fun contributesComposeActivity(): ComposeActivity + + @ContributesAndroidInjector + abstract fun contributesDetailActivity(): DetailActivity } diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ViewModelFactory.kt b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ViewModelFactory.kt index d9c2b4f..9a0f565 100644 --- a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ViewModelFactory.kt +++ b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/ViewModelFactory.kt @@ -17,13 +17,12 @@ * along with this program. If not, see . */ -// from https://proandroiddev.com/viewmodel-with-dagger2-architecture-components-2e06f06c9455 - package at.connyduck.pixelcat.dagger import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import at.connyduck.pixelcat.components.compose.ComposeViewModel +import at.connyduck.pixelcat.components.timeline.detail.DetailViewModel import at.connyduck.pixelcat.components.login.LoginViewModel import at.connyduck.pixelcat.components.main.MainViewModel import at.connyduck.pixelcat.components.notifications.NotificationsViewModel @@ -90,5 +89,10 @@ abstract class ViewModelModule { @IntoMap @ViewModelKey(ComposeViewModel::class) internal abstract fun composeViewModel(viewModel: ComposeViewModel): ViewModel + + @Binds + @IntoMap + @ViewModelKey(DetailViewModel::class) + internal abstract fun detailViewModel(viewModel: DetailViewModel): ViewModel // Add more ViewModels here } diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/db/TimelineDao.kt b/app/src/main/kotlin/at/connyduck/pixelcat/db/TimelineDao.kt index 9e64da8..ce4ca4f 100644 --- a/app/src/main/kotlin/at/connyduck/pixelcat/db/TimelineDao.kt +++ b/app/src/main/kotlin/at/connyduck/pixelcat/db/TimelineDao.kt @@ -39,6 +39,9 @@ interface TimelineDao { @Delete suspend fun delete(status: StatusEntity) + @Query("SELECT * FROM StatusEntity WHERE id = :statusId AND accountId = :accountId") + suspend fun status(statusId: String, accountId: Long): StatusEntity? + @Query("SELECT * FROM StatusEntity WHERE accountId = :accountId ORDER BY LENGTH(id) DESC, id DESC") fun statuses(accountId: Long): PagingSource diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/model/StatusContext.kt b/app/src/main/kotlin/at/connyduck/pixelcat/model/StatusContext.kt new file mode 100644 index 0000000..60c0992 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/model/StatusContext.kt @@ -0,0 +1,9 @@ +package at.connyduck.pixelcat.model + +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class StatusContext ( + val ancestors: List, + val descendants: List +) diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/network/FediverseApi.kt b/app/src/main/kotlin/at/connyduck/pixelcat/network/FediverseApi.kt index 1b5c42f..e1b9cfa 100644 --- a/app/src/main/kotlin/at/connyduck/pixelcat/network/FediverseApi.kt +++ b/app/src/main/kotlin/at/connyduck/pixelcat/network/FediverseApi.kt @@ -19,13 +19,7 @@ package at.connyduck.pixelcat.network -import at.connyduck.pixelcat.model.AccessToken -import at.connyduck.pixelcat.model.Account -import at.connyduck.pixelcat.model.AppCredentials -import at.connyduck.pixelcat.model.Attachment -import at.connyduck.pixelcat.model.NewStatus -import at.connyduck.pixelcat.model.Relationship -import at.connyduck.pixelcat.model.Status +import at.connyduck.pixelcat.model.* import at.connyduck.pixelcat.network.calladapter.NetworkResponse import okhttp3.MultipartBody import retrofit2.http.Body @@ -183,4 +177,14 @@ interface FediverseApi { suspend fun unreblogStatus( @Path("id") statusId: String ): NetworkResponse + + @GET("api/v1/statuses/{id}") + suspend fun status( + @Path("id") statusId: String + ): NetworkResponse + + @GET("api/v1/statuses/{id}/context") + suspend fun statusContext( + @Path("id") statusId: String + ): NetworkResponse } diff --git a/app/src/main/res/layout/activity_detail.xml b/app/src/main/res/layout/activity_detail.xml new file mode 100644 index 0000000..a61b003 --- /dev/null +++ b/app/src/main/res/layout/activity_detail.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_reply.xml b/app/src/main/res/layout/item_reply.xml new file mode 100644 index 0000000..c2c693d --- /dev/null +++ b/app/src/main/res/layout/item_reply.xml @@ -0,0 +1,102 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file