diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 362d915..6d9c63f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -48,6 +48,8 @@ + 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 fdd5706..8d1bd04 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 @@ -28,6 +28,7 @@ import androidx.paging.LoadState 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 @@ -79,29 +80,31 @@ class TimelineFragment : DaggerFragment(R.layout.fragment_timeline), TimeLineAct adapter.addLoadStateListener { if (it.refresh != LoadState.Loading) { binding.timelineSwipeRefresh.isRefreshing = false - } } - } - companion object { - fun newInstance() = TimelineFragment() + override fun onFavorite(status: StatusEntity) { + viewModel.onFavorite(status) } - override fun onFavorite(post: StatusEntity) { - viewModel.onFavorite(post) - } - - override fun onBoost(post: StatusEntity) { - viewModel.onBoost(post) + override fun onBoost(status: StatusEntity) { + viewModel.onBoost(status) } override fun onReply(status: StatusEntity) { - TODO("Not yet implemented") + startActivity(DetailActivity.newIntent(requireContext(), status.actionableId, reply = true)) } override fun onMediaVisibilityChanged(status: StatusEntity) { viewModel.onMediaVisibilityChanged(status) } + + override fun onDetailsOpened(status: StatusEntity) { + startActivity(DetailActivity.newIntent(requireContext(), status.actionableId)) + } + + companion object { + fun newInstance() = TimelineFragment() + } } 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 850f647..71471d6 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,13 +31,15 @@ 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 { - fun onFavorite(post: StatusEntity) - fun onBoost(post: StatusEntity) + fun onFavorite(status: StatusEntity) + fun onBoost(status: 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() - } - }.maxOrNull()?.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) + } +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineUseCases.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineUseCases.kt new file mode 100644 index 0000000..1eae3a3 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineUseCases.kt @@ -0,0 +1,58 @@ +package at.connyduck.pixelcat.components.timeline + +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.model.Status +import at.connyduck.pixelcat.network.FediverseApi +import at.connyduck.pixelcat.network.calladapter.NetworkResponse +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class TimelineUseCases @Inject constructor( + val api: FediverseApi, + val db: AppDatabase, + val accountManager: AccountManager +) { + + suspend fun onFavorite(status: StatusEntity) { + val alreadyFavourited = status.favourited + if (alreadyFavourited) { + api.unfavouriteStatus(status.actionableId) + } else { + api.favouriteStatus(status.actionableId) + }.updateStatusInDb() + } + + suspend fun onBoost(status: StatusEntity) { + val alreadyBoosted = status.reblogged + if (alreadyBoosted) { + api.unreblogStatus(status.actionableId) + } else { + api.reblogStatus(status.actionableId) + }.updateStatusInDb() + } + + suspend fun onMediaVisibilityChanged(status: StatusEntity) { + db.statusDao().changeMediaVisibility( + !status.mediaVisible, + status.id, + accountManager.activeAccount()?.id!! + ) + } + + private suspend fun NetworkResponse.updateStatusInDb() { + fold( + { updatedStatus -> + val accountId = accountManager.activeAccount()?.id!! + val updatedStatusEntity = updatedStatus.toEntity(accountId) + db.statusDao().insertOrReplace(updatedStatusEntity) + }, + { + // Todo + } + ) + } +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineViewModel.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineViewModel.kt index 9449a85..3e604b9 100644 --- a/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineViewModel.kt +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/TimelineViewModel.kt @@ -28,10 +28,7 @@ import androidx.paging.cachedIn 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.model.Status import at.connyduck.pixelcat.network.FediverseApi -import at.connyduck.pixelcat.network.calladapter.NetworkResponse import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.flatMapConcat @@ -39,9 +36,10 @@ import kotlinx.coroutines.launch import javax.inject.Inject class TimelineViewModel @Inject constructor( - private val accountManager: AccountManager, + accountManager: AccountManager, private val db: AppDatabase, - private val fediverseApi: FediverseApi + private val fediverseApi: FediverseApi, + private val useCases: TimelineUseCases ) : ViewModel() { @OptIn(FlowPreview::class) @@ -58,46 +56,19 @@ class TimelineViewModel @Inject constructor( fun onFavorite(status: StatusEntity) { viewModelScope.launch { - val alreadyFavourited = status.favourited - if (alreadyFavourited) { - fediverseApi.unfavouriteStatus(status.actionableId) - } else { - fediverseApi.favouriteStatus(status.actionableId) - }.updateStatusInDb() + useCases.onFavorite(status) } } fun onBoost(status: StatusEntity) { viewModelScope.launch { - val alreadyBoosted = status.reblogged - if (alreadyBoosted) { - fediverseApi.unreblogStatus(status.actionableId) - } else { - fediverseApi.reblogStatus(status.actionableId) - }.updateStatusInDb() + useCases.onBoost(status) } } fun onMediaVisibilityChanged(status: StatusEntity) { viewModelScope.launch { - db.statusDao().changeMediaVisibility( - !status.mediaVisible, - status.id, - accountManager.activeAccount()?.id!! - ) + useCases.onMediaVisibilityChanged(status) } } - - private suspend fun NetworkResponse.updateStatusInDb() { - fold( - { updatedStatus -> - val accountId = accountManager.activeAccount()?.id!! - val updatedStatusEntity = updatedStatus.toEntity(accountId) - db.statusDao().insertOrReplace(updatedStatusEntity) - }, - { - // Todo - } - ) - } } 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..9af28ae --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailActivity.kt @@ -0,0 +1,159 @@ +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.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat +import androidx.recyclerview.widget.ConcatAdapter +import at.connyduck.pixelcat.R +import at.connyduck.pixelcat.components.general.BaseActivity +import at.connyduck.pixelcat.components.timeline.TimeLineActionListener +import at.connyduck.pixelcat.components.util.Error +import at.connyduck.pixelcat.components.util.Loading +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.components.util.getColorForAttr +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 com.google.android.material.bottomsheet.BottomSheetBehavior +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 -> + binding.root.setPadding(0, insets.systemWindowInsetTop, 0, 0) + + WindowInsetsCompat.Builder(WindowInsetsCompat.toWindowInsetsCompat(insets)) + .setSystemWindowInsets(Insets.of(insets.systemWindowInsetLeft, 0, insets.systemWindowInsetRight, insets.systemWindowInsetBottom)) + .build() + .toWindowInsets() + } + + binding.detailSwipeRefresh.setColorSchemeColors( + getColorForAttr(R.attr.pixelcat_gradient_color_start), + getColorForAttr(R.attr.pixelcat_gradient_color_end) + ) + + binding.detailSwipeRefresh.setOnRefreshListener { + viewModel.reload(false) + } + + binding.detailStatus.setOnRetryListener { + viewModel.reload(true) + } + + viewModel.setStatusId(intent.getStringExtra(EXTRA_STATUS_ID)!!) + + val displayWidth = getDisplayWidthInPx() + + statusAdapter = DetailStatusAdapter(displayWidth, this) + repliesAdapter = DetailReplyAdapter(this) + + binding.detailRecyclerView.adapter = ConcatAdapter(statusAdapter, repliesAdapter) + + viewModel.currentStatus.observe( + this, + { + when (it) { + is Success -> { + binding.detailSwipeRefresh.show() + binding.detailStatus.hide() + binding.detailProgress.hide() + binding.detailSwipeRefresh.isRefreshing = false + binding.detailRecyclerView.show() + statusAdapter.submitList(listOf(it.data)) + it.data?.let { status -> + if (intent.getBooleanExtra(EXTRA_REPLY, false)) { + intent.removeExtra(EXTRA_REPLY) + onReply(status) + } + } + } + is Loading -> { + binding.detailSwipeRefresh.hide() + binding.detailStatus.hide() + binding.detailProgress.show() + } + is Error -> { + binding.detailSwipeRefresh.hide() + binding.detailStatus.show() + binding.detailProgress.hide() + binding.detailStatus.showGeneralError() + } + } + } + ) + + viewModel.replies.observe( + this, + { + if (it is Success) { + repliesAdapter.submitList(it.data) + } + } + ) + } + + override fun onFavorite(status: StatusEntity) { + viewModel.onFavorite(status) + } + + override fun onBoost(status: StatusEntity) { + viewModel.onBoost(status) + } + + override fun onReply(status: StatusEntity) { + val replyBottomsheet = BottomSheetBehavior.from(binding.detailReplyBottomSheet) + replyBottomsheet.state = BottomSheetBehavior.STATE_EXPANDED + + binding.detailReplyingTo.text = getString(R.string.status_details_replying_to, "@" + status.account.username) + binding.detailReply.requestFocus() + binding.detailReply.setText("@" + status.account.username + " ") + binding.detailReply.setSelection(binding.detailReply.text?.length ?: 0) + + binding.detailReplyLayout.setEndIconOnClickListener { + viewModel.onReply(status, binding.detailReply.text?.toString().orEmpty()) + } + } + + override fun onMediaVisibilityChanged(status: StatusEntity) { + viewModel.onMediaVisibilityChanged(status) + } + + override fun onDetailsOpened(status: StatusEntity) { + // nothing to do, we already are in details + } + + companion object { + private const val EXTRA_STATUS_ID = "STATUS_ID" + private const val EXTRA_REPLY = "REPLY" + + fun newIntent(context: Context, statusId: String, reply: Boolean = false): Intent { + return Intent(context, DetailActivity::class.java).apply { + putExtra(EXTRA_STATUS_ID, statusId) + putExtra(EXTRA_REPLY, reply) + } + } + } +} 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..5242af9 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailReplyAdapter.kt @@ -0,0 +1,56 @@ +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..9d2482c --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailStatusAdapter.kt @@ -0,0 +1,38 @@ +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..62b0879 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/timeline/detail/DetailViewModel.kt @@ -0,0 +1,143 @@ +/* + * 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.timeline.TimelineUseCases +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.model.NewStatus +import at.connyduck.pixelcat.network.FediverseApi +import kotlinx.coroutines.launch +import java.util.Locale +import javax.inject.Inject + +class DetailViewModel @Inject constructor( + private val api: FediverseApi, + private val db: AppDatabase, + private val accountManager: AccountManager, + private val useCases: TimelineUseCases, + private val fediverseApi: FediverseApi +) : 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(showLoading: Boolean) { + if (showLoading) { + 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) + } + ) + } + + fun onFavorite(status: StatusEntity) { + viewModelScope.launch { + useCases.onFavorite(status) + } + } + + fun onBoost(status: StatusEntity) { + viewModelScope.launch { + useCases.onBoost(status) + } + } + + fun onMediaVisibilityChanged(status: StatusEntity) { + viewModelScope.launch { + useCases.onMediaVisibilityChanged(status) + } + } + + fun onReply(statusToReply: StatusEntity, replyText: String) { + viewModelScope.launch { + + fediverseApi.reply( + NewStatus( + status = replyText, + inReplyToId = statusToReply.actionableId, + visibility = statusToReply.visibility.name.toLowerCase(Locale.ROOT), + sensitive = statusToReply.sensitive, + mediaIds = null + ) + ) + } + } +} diff --git a/app/src/main/kotlin/at/connyduck/pixelcat/components/view/StatusView.kt b/app/src/main/kotlin/at/connyduck/pixelcat/components/view/StatusView.kt new file mode 100644 index 0000000..ff7a679 --- /dev/null +++ b/app/src/main/kotlin/at/connyduck/pixelcat/components/view/StatusView.kt @@ -0,0 +1,60 @@ +/* + * 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.view + +import android.content.Context +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.widget.LinearLayout +import at.connyduck.pixelcat.R +import at.connyduck.pixelcat.components.util.extension.getColorForAttr +import at.connyduck.pixelcat.databinding.ViewStatusBinding + +class StatusView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + val binding: ViewStatusBinding + + init { + binding = ViewStatusBinding.inflate(LayoutInflater.from(context), this) + gravity = Gravity.CENTER + setBackgroundColor(context.getColorForAttr(R.attr.colorSurface)) + orientation = VERTICAL + } + + fun setOnRetryListener(listener: (View) -> Unit) { + binding.statusButton.setOnClickListener(listener) + } + + fun showGeneralError() { + binding.statusMessage.setText(R.string.status_general_error) + binding.statusMessage.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.ic_alert_triangle, 0, 0) + } + + fun showNetworkError() { + binding.statusMessage.setText(R.string.status_network_error) + binding.statusMessage.setCompoundDrawablesWithIntrinsicBounds(0, R.drawable.ic_wifi_off, 0, 0) + } +} 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/NetworkModule.kt b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/NetworkModule.kt index 3d39a2f..3a12c3a 100644 --- a/app/src/main/kotlin/at/connyduck/pixelcat/dagger/NetworkModule.kt +++ b/app/src/main/kotlin/at/connyduck/pixelcat/dagger/NetworkModule.kt @@ -56,7 +56,7 @@ class NetworkModule { if (BuildConfig.DEBUG) { okHttpClientBuilder.addInterceptor( HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BASIC + level = HttpLoggingInterceptor.Level.BODY } ) } 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..0c09df8 --- /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..a7c64ba 100644 --- a/app/src/main/kotlin/at/connyduck/pixelcat/network/FediverseApi.kt +++ b/app/src/main/kotlin/at/connyduck/pixelcat/network/FediverseApi.kt @@ -26,6 +26,7 @@ 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.StatusContext import at.connyduck.pixelcat.network.calladapter.NetworkResponse import okhttp3.MultipartBody import retrofit2.http.Body @@ -164,6 +165,11 @@ interface FediverseApi { @Body status: NewStatus ): NetworkResponse + @POST("api/v1/statuses") + suspend fun reply( + @Body status: NewStatus + ): NetworkResponse + @POST("api/v1/statuses/{id}/favourite") suspend fun favouriteStatus( @Path("id") statusId: String @@ -183,4 +189,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/drawable/ic_alert_triangle.xml b/app/src/main/res/drawable/ic_alert_triangle.xml new file mode 100644 index 0000000..ac29b98 --- /dev/null +++ b/app/src/main/res/drawable/ic_alert_triangle.xml @@ -0,0 +1,14 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_send.xml b/app/src/main/res/drawable/ic_send.xml new file mode 100644 index 0000000..8c81c92 --- /dev/null +++ b/app/src/main/res/drawable/ic_send.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/ic_wifi_off.xml b/app/src/main/res/drawable/ic_wifi_off.xml new file mode 100644 index 0000000..720b3ae --- /dev/null +++ b/app/src/main/res/drawable/ic_wifi_off.xml @@ -0,0 +1,30 @@ + + + + + + + + + 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..5905606 --- /dev/null +++ b/app/src/main/res/layout/activity_detail.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 diff --git a/app/src/main/res/layout/view_status.xml b/app/src/main/res/layout/view_status.xml new file mode 100644 index 0000000..a18a348 --- /dev/null +++ b/app/src/main/res/layout/view_status.xml @@ -0,0 +1,28 @@ + + + + + +