Status details

* add status details

* add pull to refresh

* add like/boost functionality

* add error handling

* fix formatting

* add reply BottomSheet

* make replying work

* make "replying to" string translateable
This commit is contained in:
Konrad Pozniak 2020-09-04 18:47:14 +02:00 committed by GitHub
parent 8317470c8e
commit ea0bbffbc5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 947 additions and 109 deletions

View File

@ -48,6 +48,8 @@
<activity android:name=".components.about.licenses.LicenseActivity"/>
<activity android:name="at.connyduck.pixelcat.components.compose.ComposeActivity" />
<activity android:name=".components.profile.ProfileActivity" />
<activity android:name=".components.timeline.detail.DetailActivity"
android:windowSoftInputMode="adjustResize|stateVisible"/>
<service android:name=".components.compose.SendStatusService" />

View File

@ -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()
}
}

View File

@ -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<StatusEntity>() {
@ -66,65 +68,71 @@ class TimelineListAdapter(
}
override fun onBindViewHolder(holder: BindingHolder<ItemStatusBinding>, 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<ItemStatusBinding>.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)
}
}

View File

@ -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<Status>.updateStatusInDb() {
fold<Any?>(
{ updatedStatus ->
val accountId = accountManager.activeAccount()?.id!!
val updatedStatusEntity = updatedStatus.toEntity(accountId)
db.statusDao().insertOrReplace(updatedStatusEntity)
},
{
// Todo
}
)
}
}

View File

@ -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<Status>.updateStatusInDb() {
fold<Any?>(
{ updatedStatus ->
val accountId = accountManager.activeAccount()?.id!!
val updatedStatusEntity = updatedStatus.toEntity(accountId)
db.statusDao().insertOrReplace(updatedStatusEntity)
},
{
// Todo
}
)
}
}

View File

@ -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)
}
}
}
}

View File

@ -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<StatusEntity, BindingHolder<ItemReplyBinding>>(TimelineDiffUtil) {
private val dateTimeFormatter = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.SHORT, SimpleDateFormat.SHORT)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemReplyBinding> {
val binding = ItemReplyBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return BindingHolder(binding)
}
override fun onBindViewHolder(holder: BindingHolder<ItemReplyBinding>, 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)
}
}
}
}

View File

@ -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<StatusEntity, BindingHolder<ItemStatusBinding>>(TimelineDiffUtil) {
private val dateTimeFormatter = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.SHORT, SimpleDateFormat.SHORT)
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int
): BindingHolder<ItemStatusBinding> {
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<ItemStatusBinding>, position: Int) {
getItem(position)?.let { status ->
holder.bind(status, displayWidth, listener, dateTimeFormatter)
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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<UiState<StatusEntity>>()
val replies = MutableLiveData<UiState<List<StatusEntity>>>()
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
)
)
}
}
}

View File

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@ -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
}

View File

@ -56,7 +56,7 @@ class NetworkModule {
if (BuildConfig.DEBUG) {
okHttpClientBuilder.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
level = HttpLoggingInterceptor.Level.BODY
}
)
}

View File

@ -17,13 +17,12 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// 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
}

View File

@ -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<Int, StatusEntity>

View File

@ -0,0 +1,9 @@
package at.connyduck.pixelcat.model
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class StatusContext(
val ancestors: List<Status>,
val descendants: List<Status>
)

View File

@ -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<Status>
@POST("api/v1/statuses")
suspend fun reply(
@Body status: NewStatus
): NetworkResponse<Status>
@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<Status>
@GET("api/v1/statuses/{id}")
suspend fun status(
@Path("id") statusId: String
): NetworkResponse<Status>
@GET("api/v1/statuses/{id}/context")
suspend fun statusContext(
@Path("id") statusId: String
): NetworkResponse<StatusContext>
}

View File

@ -0,0 +1,14 @@
<vector android:height="96dp" android:viewportHeight="24"
android:viewportWidth="24" android:width="96dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000"
android:pathData="M10.29,3.86L1.82,18a2,2 0,0 0,1.71 3h16.94a2,2 0,0 0,1.71 -3L13.71,3.86a2,2 0,0 0,-3.42 0z"
android:strokeColor="?android:attr/textColorTertiary" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:pathData="M12,9L12,13"
android:strokeColor="?android:attr/textColorTertiary" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000"
android:pathData="M12,17L12.01,17"
android:strokeColor="?android:attr/textColorTertiary" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>

View File

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M22,2L11,13"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="?android:attr/textColorTertiary"
android:strokeLineCap="round"/>
<path
android:pathData="M22,2l-7,20l-4,-9l-9,-4l20,-7z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="?android:attr/textColorTertiary"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,30 @@
<vector android:height="96dp" android:viewportHeight="24"
android:viewportWidth="24" android:width="96dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000" android:pathData="M1,1L23,23"
android:strokeColor="?android:attr/textColorTertiary" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000"
android:pathData="M16.72,11.06A10.94,10.94 0,0 1,19 12.55"
android:strokeColor="?android:attr/textColorTertiary" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000"
android:pathData="M5,12.55a10.94,10.94 0,0 1,5.17 -2.39"
android:strokeColor="?android:attr/textColorTertiary" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000"
android:pathData="M10.71,5.05A16,16 0,0 1,22.58 9"
android:strokeColor="?android:attr/textColorTertiary" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000"
android:pathData="M1.42,9a15.91,15.91 0,0 1,4.7 -2.88"
android:strokeColor="?android:attr/textColorTertiary" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000"
android:pathData="M8.53,16.11a6,6 0,0 1,6.95 0"
android:strokeColor="?android:attr/textColorTertiary" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000"
android:pathData="M12,20L12.01,20"
android:strokeColor="?android:attr/textColorTertiary" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>

View File

@ -0,0 +1,104 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/pixelcat_gradient"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/detailAppbar"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/detailToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlways"
app:navigationIcon="@drawable/ic_cat_small"
app:title="@string/app_name"
app:titleTextAppearance="@style/TextAppearanceToolbar"
app:titleTextColor="?attr/colorPrimary" />
</com.google.android.material.appbar.AppBarLayout>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/detailSwipeRefresh"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="?android:attr/windowBackground"
app:layout_behavior="@string/appbar_scrolling_view_behavior">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/detailRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<at.connyduck.pixelcat.components.view.StatusView
android:id="@+id/detailStatus"
android:layout_width="match_parent"
android:layout_height="match_parent" />
<FrameLayout
android:id="@+id/detailProgress"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center" />
</FrameLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/detailReplyBottomSheet"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@drawable/background_bottom_sheet"
android:elevation="4dp"
app:behavior_hideable="true"
app:behavior_peekHeight="0dp"
app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior">
<TextView
android:id="@+id/detailReplyingTo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="replying to @ConnyDuck" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/detailReplyLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:layout_marginBottom="16dp"
app:endIconDrawable="@drawable/ic_send"
app:endIconMode="custom"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/detailReplyingTo">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/detailReply"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:sparkbutton="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:clipChildren="false"
android:paddingBottom="8dp">
<ImageView
android:id="@+id/postAvatar"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:importantForAccessibility="no"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:background="#f00" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/postDisplayName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:ellipsize="end"
android:lines="1"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/postName"
app:layout_constraintStart_toEndOf="@id/postAvatar"
app:layout_constraintTop_toTopOf="parent"
tools:text="Conny Duck" />
<TextView
android:id="@+id/postName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:lines="1"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/postDisplayName"
app:layout_constraintTop_toTopOf="parent"
tools:text="\@connyduck\@chaos.social" />
<androidx.emoji.widget.EmojiTextView
android:id="@+id/postDescription"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
app:layout_constraintStart_toEndOf="@+id/postAvatar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/postDisplayName"
tools:text="This is the caption" />
<TextView
android:id="@+id/postDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:textSize="12sp"
app:layout_constraintStart_toEndOf="@+id/postAvatar"
app:layout_constraintTop_toBottomOf="@id/postDescription"
tools:text="4 hours ago" />
<at.connyduck.sparkbutton.SparkButton
android:id="@+id/postLikeButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
app:layout_constraintStart_toEndOf="@id/postDate"
app:layout_constraintTop_toBottomOf="@id/postDescription"
sparkbutton:activeImage="@drawable/ic_heart_filled"
sparkbutton:iconSize="24dp"
sparkbutton:inactiveImage="@drawable/ic_heart"
sparkbutton:primaryColor="@color/heart_button_primary"
sparkbutton:secondaryColor="@color/heart_button_secondary" />
<ImageButton
android:id="@+id/postReplyButton"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_margin="8dp"
android:layout_marginStart="16dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="16dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:src="@drawable/ic_message"
app:layout_constraintStart_toEndOf="@id/postLikeButton"
app:layout_constraintTop_toBottomOf="@id/postDescription" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:background="?attr/colorSurface"
tools:gravity="center"
tools:orientation="vertical"
tools:parentTag="android.widget.LinearLayout">
<TextView
android:id="@+id/statusMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:drawablePadding="16dp"
android:lineSpacingMultiplier="1.1"
android:textAlignment="center"
tools:drawableTop="@drawable/ic_wifi_off"
tools:text="An unexpected error occured" />
<Button
android:id="@+id/statusButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp"
android:text="@string/action_retry" />
</merge>

View File

@ -76,4 +76,10 @@
<string name="sensitive_media">Sensitive media</string>
<string name="about_license_description">Pixelcat is free and open-source software. It is licensed under the GNU General Public License Version 3.</string>
<string name="status_general_error">An unexpected error occurred</string>
<string name="status_network_error">Failed to connect. Please check your internet connection-</string>
<string name="status_details_replying_to">Replying to $1%s</string>
</resources>