Drafts v2 (#2032)
* cleanup warnings, reorganize some code * move ComposeAutoCompleteAdapter to compose package * composeOptions doesn't need to be a class member * add DraftsActivity and DraftsViewModel * drafts * remove unnecessary Unit in ComposeViewModel * add schema/25.json * fix db migration * drafts * cleanup code * fix compose activity rotation bug * fix media descriptions getting lost when restoring a draft * improve deleting drafts * fix ComposeActivityTest * improve draft layout for almost empty drafts * reformat code * show toast when opening reply to deleted toot * improve item_draft layoutmain
parent
72865a0138
commit
e952b6c627
@ -0,0 +1,161 @@ |
||||
/* Copyright 2021 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.drafts |
||||
|
||||
import android.content.Context |
||||
import android.net.Uri |
||||
import android.util.Log |
||||
import android.webkit.MimeTypeMap |
||||
import androidx.core.content.FileProvider |
||||
import androidx.core.net.toUri |
||||
import com.keylesspalace.tusky.BuildConfig |
||||
import com.keylesspalace.tusky.db.AppDatabase |
||||
import com.keylesspalace.tusky.db.DraftAttachment |
||||
import com.keylesspalace.tusky.db.DraftEntity |
||||
import com.keylesspalace.tusky.entity.NewPoll |
||||
import com.keylesspalace.tusky.entity.Status |
||||
import com.keylesspalace.tusky.util.IOUtils |
||||
import io.reactivex.Completable |
||||
import io.reactivex.Single |
||||
import io.reactivex.schedulers.Schedulers |
||||
import java.io.File |
||||
import java.text.SimpleDateFormat |
||||
import java.util.* |
||||
import javax.inject.Inject |
||||
|
||||
class DraftHelper @Inject constructor( |
||||
val context: Context, |
||||
db: AppDatabase |
||||
) { |
||||
|
||||
private val draftDao = db.draftDao() |
||||
|
||||
fun saveDraft( |
||||
draftId: Int, |
||||
accountId: Long, |
||||
inReplyToId: String?, |
||||
content: String?, |
||||
contentWarning: String?, |
||||
sensitive: Boolean, |
||||
visibility: Status.Visibility, |
||||
mediaUris: List<String>, |
||||
mediaDescriptions: List<String?>, |
||||
poll: NewPoll?, |
||||
formattingSyntax: String, |
||||
failedToSend: Boolean |
||||
): Completable { |
||||
return Single.fromCallable { |
||||
|
||||
val draftDirectory = context.getExternalFilesDir("Tusky") |
||||
|
||||
if (draftDirectory == null || !(draftDirectory.exists())) { |
||||
Log.e("DraftHelper", "Error obtaining directory to save media.") |
||||
throw Exception() |
||||
} |
||||
|
||||
val uris = mediaUris.map { uriString -> |
||||
uriString.toUri() |
||||
}.map { uri -> |
||||
if (uri.isNotInFolder(draftDirectory)) { |
||||
uri.copyToFolder(draftDirectory) |
||||
} else { |
||||
uri |
||||
} |
||||
} |
||||
|
||||
val types = uris.map { uri -> |
||||
val mimeType = context.contentResolver.getType(uri) |
||||
when (mimeType?.substring(0, mimeType.indexOf('/'))) { |
||||
"video" -> DraftAttachment.Type.VIDEO |
||||
"image" -> DraftAttachment.Type.IMAGE |
||||
"audio" -> DraftAttachment.Type.AUDIO |
||||
else -> throw IllegalStateException("unknown media type") |
||||
} |
||||
} |
||||
|
||||
val attachments: MutableList<DraftAttachment> = mutableListOf() |
||||
for (i in mediaUris.indices) { |
||||
attachments.add( |
||||
DraftAttachment( |
||||
uriString = uris[i].toString(), |
||||
description = mediaDescriptions[i], |
||||
type = types[i] |
||||
) |
||||
) |
||||
} |
||||
|
||||
DraftEntity( |
||||
id = draftId, |
||||
accountId = accountId, |
||||
inReplyToId = inReplyToId, |
||||
content = content, |
||||
contentWarning = contentWarning, |
||||
sensitive = sensitive, |
||||
visibility = visibility, |
||||
attachments = attachments, |
||||
poll = poll, |
||||
formattingSyntax = formattingSyntax, |
||||
failedToSend = failedToSend |
||||
) |
||||
|
||||
}.flatMapCompletable { draft -> |
||||
draftDao.insertOrReplace(draft) |
||||
}.subscribeOn(Schedulers.io()) |
||||
} |
||||
|
||||
fun deleteDraftAndAttachments(draftId: Int): Completable { |
||||
return draftDao.find(draftId) |
||||
.flatMapCompletable { draft -> |
||||
deleteDraftAndAttachments(draft) |
||||
} |
||||
} |
||||
|
||||
fun deleteDraftAndAttachments(draft: DraftEntity): Completable { |
||||
return deleteAttachments(draft) |
||||
.andThen(draftDao.delete(draft.id)) |
||||
} |
||||
|
||||
fun deleteAttachments(draft: DraftEntity): Completable { |
||||
return Completable.fromCallable { |
||||
draft.attachments.forEach { attachment -> |
||||
if (context.contentResolver.delete(attachment.uri, null, null) == 0) { |
||||
Log.e("DraftHelper", "Did not delete file ${attachment.uriString}") |
||||
} |
||||
} |
||||
}.subscribeOn(Schedulers.io()) |
||||
} |
||||
|
||||
private fun Uri.isNotInFolder(folder: File): Boolean { |
||||
val filePath = path ?: return true |
||||
return File(filePath).parentFile == folder |
||||
} |
||||
|
||||
private fun Uri.copyToFolder(folder: File): Uri { |
||||
val contentResolver = context.contentResolver |
||||
|
||||
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date()) |
||||
|
||||
val mimeType = contentResolver.getType(this) |
||||
val map = MimeTypeMap.getSingleton() |
||||
val fileExtension = map.getExtensionFromMimeType(mimeType) |
||||
|
||||
val filename = String.format("Tusky_Draft_Media_%s.%s", timeStamp, fileExtension) |
||||
val file = File(folder, filename) |
||||
IOUtils.copyToFile(contentResolver, this, file) |
||||
return FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileprovider", file) |
||||
} |
||||
|
||||
} |
@ -0,0 +1,81 @@ |
||||
/* Copyright 2020 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.drafts |
||||
|
||||
import android.view.ViewGroup |
||||
import android.widget.ImageView |
||||
import androidx.appcompat.widget.AppCompatImageView |
||||
import androidx.constraintlayout.widget.ConstraintLayout |
||||
import androidx.recyclerview.widget.DiffUtil |
||||
import androidx.recyclerview.widget.ListAdapter |
||||
import androidx.recyclerview.widget.RecyclerView |
||||
import com.bumptech.glide.Glide |
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.db.DraftAttachment |
||||
|
||||
class DraftMediaAdapter( |
||||
private val attachmentClick: () -> Unit |
||||
) : ListAdapter<DraftAttachment, DraftMediaAdapter.DraftMediaViewHolder>( |
||||
object: DiffUtil.ItemCallback<DraftAttachment>() { |
||||
override fun areItemsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { |
||||
return oldItem == newItem |
||||
} |
||||
|
||||
override fun areContentsTheSame(oldItem: DraftAttachment, newItem: DraftAttachment): Boolean { |
||||
return oldItem == newItem |
||||
} |
||||
|
||||
} |
||||
) { |
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DraftMediaViewHolder { |
||||
return DraftMediaViewHolder(AppCompatImageView(parent.context)) |
||||
} |
||||
|
||||
override fun onBindViewHolder(holder: DraftMediaViewHolder, position: Int) { |
||||
getItem(position)?.let { attachment -> |
||||
if (attachment.type == DraftAttachment.Type.AUDIO) { |
||||
holder.imageView.setImageResource(R.drawable.ic_music_box_preview_24dp) |
||||
} else { |
||||
Glide.with(holder.itemView.context) |
||||
.load(attachment.uri) |
||||
.diskCacheStrategy(DiskCacheStrategy.NONE) |
||||
.dontAnimate() |
||||
.into(holder.imageView) |
||||
} |
||||
} |
||||
} |
||||
|
||||
inner class DraftMediaViewHolder(val imageView: ImageView) |
||||
: RecyclerView.ViewHolder(imageView) { |
||||
init { |
||||
val thumbnailViewSize = |
||||
imageView.context.resources.getDimensionPixelSize(R.dimen.compose_media_preview_size) |
||||
val layoutParams = ConstraintLayout.LayoutParams(thumbnailViewSize, thumbnailViewSize) |
||||
val margin = itemView.context.resources |
||||
.getDimensionPixelSize(R.dimen.compose_media_preview_margin) |
||||
val marginBottom = itemView.context.resources |
||||
.getDimensionPixelSize(R.dimen.compose_media_preview_margin_bottom) |
||||
layoutParams.setMargins(margin, 0, margin, marginBottom) |
||||
imageView.layoutParams = layoutParams |
||||
imageView.scaleType = ImageView.ScaleType.CENTER_CROP |
||||
imageView.setOnClickListener { |
||||
attachmentClick() |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,197 @@ |
||||
/* Copyright 2020 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.drafts |
||||
|
||||
import android.content.Context |
||||
import android.content.Intent |
||||
import android.os.Bundle |
||||
import android.util.Log |
||||
import android.view.Menu |
||||
import android.view.MenuItem |
||||
import android.widget.LinearLayout |
||||
import android.widget.Toast |
||||
import androidx.activity.viewModels |
||||
import androidx.lifecycle.Lifecycle |
||||
import androidx.recyclerview.widget.DividerItemDecoration |
||||
import androidx.recyclerview.widget.LinearLayoutManager |
||||
import com.google.android.material.bottomsheet.BottomSheetBehavior |
||||
import com.google.android.material.snackbar.Snackbar |
||||
import com.keylesspalace.tusky.BaseActivity |
||||
import com.keylesspalace.tusky.R |
||||
import com.keylesspalace.tusky.SavedTootActivity |
||||
import com.keylesspalace.tusky.components.compose.ComposeActivity |
||||
import com.keylesspalace.tusky.databinding.ActivityDraftsBinding |
||||
import com.keylesspalace.tusky.db.DraftEntity |
||||
import com.keylesspalace.tusky.di.ViewModelFactory |
||||
import com.keylesspalace.tusky.util.hide |
||||
import com.keylesspalace.tusky.util.show |
||||
import com.uber.autodispose.android.lifecycle.autoDispose |
||||
import io.reactivex.android.schedulers.AndroidSchedulers |
||||
import io.reactivex.schedulers.Schedulers |
||||
import retrofit2.HttpException |
||||
import javax.inject.Inject |
||||
|
||||
class DraftsActivity : BaseActivity(), DraftActionListener { |
||||
|
||||
@Inject |
||||
lateinit var viewModelFactory: ViewModelFactory |
||||
|
||||
private val viewModel: DraftsViewModel by viewModels { viewModelFactory } |
||||
|
||||
private lateinit var binding: ActivityDraftsBinding |
||||
private lateinit var bottomSheet: BottomSheetBehavior<LinearLayout> |
||||
|
||||
private var oldDraftsButton: MenuItem? = null |
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) { |
||||
|
||||
super.onCreate(savedInstanceState) |
||||
|
||||
binding = ActivityDraftsBinding.inflate(layoutInflater) |
||||
setContentView(binding.root) |
||||
|
||||
setSupportActionBar(binding.includedToolbar.toolbar) |
||||
supportActionBar?.apply { |
||||
title = getString(R.string.title_drafts) |
||||
setDisplayHomeAsUpEnabled(true) |
||||
setDisplayShowHomeEnabled(true) |
||||
} |
||||
|
||||
binding.draftsErrorMessageView.setup(R.drawable.elephant_friend_empty, R.string.no_saved_status) |
||||
|
||||
val adapter = DraftsAdapter(this) |
||||
|
||||
binding.draftsRecyclerView.adapter = adapter |
||||
binding.draftsRecyclerView.layoutManager = LinearLayoutManager(this) |
||||
binding.draftsRecyclerView.addItemDecoration(DividerItemDecoration(this, DividerItemDecoration.VERTICAL)) |
||||
|
||||
bottomSheet = BottomSheetBehavior.from(binding.bottomSheet.root) |
||||
|
||||
viewModel.drafts.observe(this) { draftList -> |
||||
if (draftList.isEmpty()) { |
||||
binding.draftsRecyclerView.hide() |
||||
binding.draftsErrorMessageView.show() |
||||
} else { |
||||
binding.draftsRecyclerView.show() |
||||
binding.draftsErrorMessageView.hide() |
||||
adapter.submitList(draftList) |
||||
} |
||||
} |
||||
} |
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean { |
||||
menuInflater.inflate(R.menu.drafts, menu) |
||||
oldDraftsButton = menu.findItem(R.id.action_old_drafts) |
||||
viewModel.showOldDraftsButton() |
||||
.subscribeOn(Schedulers.io()) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.autoDispose(this, Lifecycle.Event.ON_DESTROY) |
||||
.subscribe { showOldDraftsButton -> |
||||
oldDraftsButton?.isVisible = showOldDraftsButton |
||||
} |
||||
return true |
||||
} |
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean { |
||||
when (item.itemId) { |
||||
android.R.id.home -> { |
||||
onBackPressed() |
||||
return true |
||||
} |
||||
R.id.action_old_drafts -> { |
||||
val intent = Intent(this, SavedTootActivity::class.java) |
||||
startActivityWithSlideInAnimation(intent) |
||||
return true |
||||
} |
||||
} |
||||
return super.onOptionsItemSelected(item) |
||||
} |
||||
|
||||
override fun onOpenDraft(draft: DraftEntity) { |
||||
|
||||
if (draft.inReplyToId != null) { |
||||
bottomSheet.state = BottomSheetBehavior.STATE_COLLAPSED |
||||
viewModel.getToot(draft.inReplyToId) |
||||
.observeOn(AndroidSchedulers.mainThread()) |
||||
.autoDispose(this) |
||||
.subscribe({ status -> |
||||
val composeOptions = ComposeActivity.ComposeOptions( |
||||
draftId = draft.id, |
||||
tootText = draft.content, |
||||
contentWarning = draft.contentWarning, |
||||
inReplyToId = draft.inReplyToId, |
||||
replyingStatusContent = status.content.toString(), |
||||
replyingStatusAuthor = status.account.localUsername, |
||||
draftAttachments = draft.attachments, |
||||
poll = draft.poll, |
||||
sensitive = draft.sensitive, |
||||
visibility = draft.visibility |
||||
) |
||||
|
||||
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN |
||||
|
||||
startActivity(ComposeActivity.startIntent(this, composeOptions)) |
||||
|
||||
}, { throwable -> |
||||
|
||||
bottomSheet.state = BottomSheetBehavior.STATE_HIDDEN |
||||
|
||||
Log.w(TAG, "failed loading reply information", throwable) |
||||
|
||||
if (throwable is HttpException && throwable.code() == 404) { |
||||
// the original status to which a reply was drafted has been deleted |
||||
// let's open the ComposeActivity without reply information |
||||
Toast.makeText(this, getString(R.string.drafts_toot_reply_removed), Toast.LENGTH_LONG).show() |
||||
openDraftWithoutReply(draft) |
||||
} else { |
||||
Snackbar.make(binding.root, getString(R.string.drafts_failed_loading_reply), Snackbar.LENGTH_SHORT) |
||||
.show() |
||||
} |
||||
}) |
||||
} else { |
||||
openDraftWithoutReply(draft) |
||||
} |
||||
} |
||||
|
||||
private fun openDraftWithoutReply(draft: DraftEntity) { |
||||
val composeOptions = ComposeActivity.ComposeOptions( |
||||
draftId = draft.id, |
||||
tootText = draft.content, |
||||
contentWarning = draft.contentWarning, |
||||
draftAttachments = draft.attachments, |
||||
poll = draft.poll, |
||||
sensitive = draft.sensitive, |
||||
visibility = draft.visibility |
||||
) |
||||
|
||||
startActivity(ComposeActivity.startIntent(this, composeOptions)) |
||||
} |
||||
|
||||
override fun onDeleteDraft(draft: DraftEntity) { |
||||
viewModel.deleteDraft(draft) |
||||
Snackbar.make(binding.root, getString(R.string.draft_deleted), Snackbar.LENGTH_LONG) |
||||
.setAction(R.string.action_undo) { |
||||
viewModel.restoreDraft(draft) |
||||
} |
||||
.show() |
||||
} |
||||
|
||||
companion object { |
||||
const val TAG = "DraftsActivity" |
||||
|
||||
fun newIntent(context: Context) = Intent(context, DraftsActivity::class.java) |
||||
} |
||||
} |
@ -0,0 +1,92 @@ |
||||
/* Copyright 2021 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.drafts |
||||
|
||||
import android.view.LayoutInflater |
||||
import android.view.ViewGroup |
||||
import androidx.paging.PagedListAdapter |
||||
import androidx.recyclerview.widget.DiffUtil |
||||
import androidx.recyclerview.widget.LinearLayoutManager |
||||
import androidx.recyclerview.widget.RecyclerView |
||||
import com.keylesspalace.tusky.databinding.ItemDraftBinding |
||||
import com.keylesspalace.tusky.db.DraftEntity |
||||
import com.keylesspalace.tusky.util.BindingViewHolder |
||||
import com.keylesspalace.tusky.util.hide |
||||
import com.keylesspalace.tusky.util.show |
||||
import com.keylesspalace.tusky.util.visible |
||||
|
||||
interface DraftActionListener { |
||||
fun onOpenDraft(draft: DraftEntity) |
||||
fun onDeleteDraft(draft: DraftEntity) |
||||
} |
||||
|
||||
class DraftsAdapter( |
||||
private val listener: DraftActionListener |
||||
) : PagedListAdapter<DraftEntity, BindingViewHolder<ItemDraftBinding>>( |
||||
object : DiffUtil.ItemCallback<DraftEntity>() { |
||||
override fun areItemsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { |
||||
return oldItem.id == newItem.id |
||||
} |
||||
|
||||
override fun areContentsTheSame(oldItem: DraftEntity, newItem: DraftEntity): Boolean { |
||||
return oldItem == newItem |
||||
} |
||||
} |
||||
) { |
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BindingViewHolder<ItemDraftBinding> { |
||||
|
||||
val binding = ItemDraftBinding.inflate(LayoutInflater.from(parent.context), parent, false) |
||||
|
||||
val viewHolder = BindingViewHolder(binding) |
||||
|
||||
binding.draftMediaPreview.layoutManager = LinearLayoutManager(binding.root.context, RecyclerView.HORIZONTAL, false) |
||||
binding.draftMediaPreview.adapter = DraftMediaAdapter { |
||||
getItem(viewHolder.adapterPosition)?.let { draft -> |
||||
listener.onOpenDraft(draft) |
||||
} |
||||
} |
||||
|
||||
return viewHolder |
||||
} |
||||
|
||||
override fun onBindViewHolder(holder: BindingViewHolder<ItemDraftBinding>, position: Int) { |
||||
getItem(position)?.let { draft -> |
||||
holder.binding.root.setOnClickListener { |
||||
listener.onOpenDraft(draft) |
||||
} |
||||
holder.binding.deleteButton.setOnClickListener { |
||||
listener.onDeleteDraft(draft) |
||||
} |
||||
holder.binding.draftSendingInfo.visible(draft.failedToSend) |
||||
|
||||
holder.binding.contentWarning.visible(!draft.contentWarning.isNullOrEmpty()) |
||||
holder.binding.contentWarning.text = draft.contentWarning |
||||
holder.binding.content.text = draft.content |
||||
|
||||
holder.binding.draftMediaPreview.visible(draft.attachments.isNotEmpty()) |
||||
(holder.binding.draftMediaPreview.adapter as DraftMediaAdapter).submitList(draft.attachments) |
||||
|
||||
if (draft.poll != null) { |
||||
holder.binding.draftPoll.show() |
||||
holder.binding.draftPoll.setPoll(draft.poll) |
||||
} else { |
||||
holder.binding.draftPoll.hide() |
||||
} |
||||
} |
||||
|
||||
} |
||||
} |
@ -0,0 +1,69 @@ |
||||
/* Copyright 2020 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.components.drafts |
||||
|
||||
import androidx.lifecycle.ViewModel |
||||
import androidx.paging.toLiveData |
||||
import com.keylesspalace.tusky.db.AccountManager |
||||
import com.keylesspalace.tusky.db.AppDatabase |
||||
import com.keylesspalace.tusky.db.DraftEntity |
||||
import com.keylesspalace.tusky.entity.Status |
||||
import com.keylesspalace.tusky.network.MastodonApi |
||||
import io.reactivex.Observable |
||||
import io.reactivex.Single |
||||
import javax.inject.Inject |
||||
|
||||
class DraftsViewModel @Inject constructor( |
||||
val database: AppDatabase, |
||||
val accountManager: AccountManager, |
||||
val api: MastodonApi, |
||||
val draftHelper: DraftHelper |
||||
) : ViewModel() { |
||||
|
||||
val drafts = database.draftDao().loadDrafts(accountManager.activeAccount?.id!!).toLiveData(pageSize = 20) |
||||
|
||||
private val deletedDrafts: MutableList<DraftEntity> = mutableListOf() |
||||
|
||||
fun showOldDraftsButton(): Observable<Boolean> { |
||||
return database.tootDao().savedTootCount() |
||||
.map { count -> count > 0 } |
||||
} |
||||
|
||||
fun deleteDraft(draft: DraftEntity) { |
||||
// this does not immediately delete media files to avoid unnecessary file operations |
||||
// in case the user decides to restore the draft |
||||
database.draftDao().delete(draft.id) |
||||
.subscribe() |
||||
deletedDrafts.add(draft) |
||||
} |
||||
|
||||
fun restoreDraft(draft: DraftEntity) { |
||||
database.draftDao().insertOrReplace(draft) |
||||
.subscribe() |
||||
deletedDrafts.remove(draft) |
||||
} |
||||
|
||||
fun getToot(tootId: String): Single<Status> { |
||||
return api.statusSingle(tootId) |
||||
} |
||||
|
||||
override fun onCleared() { |
||||
deletedDrafts.forEach { |
||||
draftHelper.deleteAttachments(it).subscribe() |
||||
} |
||||
} |
||||
|
||||
} |
@ -0,0 +1,40 @@ |
||||
/* Copyright 2020 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.db |
||||
|
||||
import androidx.paging.DataSource |
||||
import androidx.room.Dao |
||||
import androidx.room.Insert |
||||
import androidx.room.OnConflictStrategy |
||||
import androidx.room.Query |
||||
import io.reactivex.Completable |
||||
import io.reactivex.Single |
||||
|
||||
@Dao |
||||
interface DraftDao { |
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) |
||||
fun insertOrReplace(draft: DraftEntity): Completable |
||||
|
||||
@Query("SELECT * FROM DraftEntity WHERE accountId = :accountId ORDER BY id ASC") |
||||
fun loadDrafts(accountId: Long): DataSource.Factory<Int, DraftEntity> |
||||
|
||||
@Query("DELETE FROM DraftEntity WHERE id = :id") |
||||
fun delete(id: Int): Completable |
||||
|
||||
@Query("SELECT * FROM DraftEntity WHERE id = :id") |
||||
fun find(id: Int): Single<DraftEntity?> |
||||
} |
@ -0,0 +1,55 @@ |
||||
/* Copyright 2020 Tusky Contributors |
||||
* |
||||
* This file is a part of Tusky. |
||||
* |
||||
* This program 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. |
||||
* |
||||
* Tusky 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 Tusky; if not, |
||||
* see <http://www.gnu.org/licenses>. */ |
||||
|
||||
package com.keylesspalace.tusky.db |
||||
|
||||
import android.net.Uri |
||||
import android.os.Parcelable |
||||
import androidx.core.net.toUri |
||||
import androidx.room.Entity |
||||
import androidx.room.PrimaryKey |
||||
import androidx.room.TypeConverters |
||||
import com.keylesspalace.tusky.entity.NewPoll |
||||
import com.keylesspalace.tusky.entity.Status |
||||
import kotlinx.android.parcel.Parcelize |
||||
|
||||
@Entity |
||||
@TypeConverters(Converters::class) |
||||
data class DraftEntity( |
||||
@PrimaryKey(autoGenerate = true) val id: Int = 0, |
||||
val accountId: Long, |
||||
val inReplyToId: String?, |
||||
val content: String?, |
||||
val contentWarning: String?, |
||||
val sensitive: Boolean, |
||||
val visibility: Status.Visibility, |
||||
val attachments: List<DraftAttachment>, |
||||
val poll: NewPoll?, |
||||
val failedToSend: Boolean |
||||
) |
||||
|
||||
@Parcelize |
||||
data class DraftAttachment( |
||||
val uriString: String, |
||||
val description: String?, |
||||
val type: Type |
||||
): Parcelable { |
||||
val uri: Uri |
||||
get() = uriString.toUri() |
||||
|
||||
enum class Type { |
||||
IMAGE, VIDEO, AUDIO; |
||||
} |
||||
} |
@ -0,0 +1,8 @@ |
||||
package com.keylesspalace.tusky.util |
||||
|
||||
import androidx.recyclerview.widget.RecyclerView |
||||
import androidx.viewbinding.ViewBinding |
||||
|
||||
class BindingViewHolder<T : ViewBinding>( |
||||
val binding: T |
||||
) : RecyclerView.ViewHolder(binding.root) |
@ -0,0 +1,8 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
||||
android:height="24dp" |
||||
android:width="24dp" |
||||
android:viewportWidth="24" |
||||
android:viewportHeight="24"> |
||||
<path android:fillColor="#000" android:pathData="M11,15H13V17H11V15M11,7H13V13H11V7M12,2C6.47,2 2,6.5 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20Z" /> |
||||
</vector> |
@ -0,0 +1,34 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:tools="http://schemas.android.com/tools" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
tools:context=".components.drafts.DraftsActivity"> |
||||
|
||||
<include |
||||
android:id="@+id/includedToolbar" |
||||
layout="@layout/toolbar_basic" /> |
||||
|
||||
<androidx.recyclerview.widget.RecyclerView |
||||
android:id="@+id/draftsRecyclerView" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="match_parent" |
||||
app:layout_behavior="@string/appbar_scrolling_view_behavior"/> |
||||
|
||||
<com.keylesspalace.tusky.view.BackgroundMessageView |
||||
android:id="@+id/draftsErrorMessageView" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_centerInParent="true" |
||||
android:src="@android:color/transparent" |
||||
android:visibility="gone" |
||||
android:layout_gravity="center" |
||||
tools:src="@drawable/elephant_error" |
||||
tools:visibility="visible" /> |
||||
|
||||
<include |
||||
android:id="@+id/bottomSheet" |
||||
layout="@layout/item_status_bottom_sheet" /> |
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout> |
@ -0,0 +1,95 @@ |
||||
<?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:tools="http://schemas.android.com/tools" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginBottom="8dp" |
||||
android:background="?attr/selectableItemBackground" |
||||
android:paddingTop="4dp" |
||||
android:paddingBottom="4dp"> |
||||
|
||||
<TextView |
||||
android:id="@+id/draftSendingInfo" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginStart="8dp" |
||||
android:layout_marginTop="4dp" |
||||
android:layout_marginEnd="8dp" |
||||
android:drawablePadding="4dp" |
||||
android:fontFamily="sans-serif-medium" |
||||
android:gravity="center_vertical" |
||||
android:text="@string/drafts_toot_failed_to_send" |
||||
android:textColor="@color/tusky_red" |
||||
android:textSize="?attr/status_text_medium" |
||||
app:drawableStartCompat="@drawable/ic_alert_circle" |
||||
app:drawableTint="@color/tusky_red" |
||||
app:layout_constraintEnd_toStartOf="@id/deleteButton" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent" /> |
||||
|
||||
<androidx.emoji.widget.EmojiTextView |
||||
android:id="@+id/contentWarning" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginStart="8dp" |
||||
android:layout_marginTop="4dp" |
||||
android:layout_marginEnd="8dp" |
||||
android:fontFamily="sans-serif-medium" |
||||
android:textSize="?attr/status_text_medium" |
||||
app:layout_constraintEnd_toStartOf="@id/deleteButton" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toBottomOf="@id/draftSendingInfo" |
||||
tools:text="Some content warning" /> |
||||
|
||||
<androidx.emoji.widget.EmojiTextView |
||||
android:id="@+id/content" |
||||
android:layout_width="0dp" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginStart="8dp" |
||||
android:layout_marginTop="4dp" |
||||
android:layout_marginEnd="8dp" |
||||
android:textSize="?attr/status_text_medium" |
||||
app:layout_constraintEnd_toStartOf="@id/deleteButton" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toBottomOf="@id/contentWarning" |
||||
tools:text="Some toot content. May be very long." /> |
||||
|
||||
<ImageButton |
||||
android:id="@+id/deleteButton" |
||||
style="@style/TuskyImageButton" |
||||
android:layout_width="32dp" |
||||
android:layout_height="32dp" |
||||
android:layout_gravity="center_vertical" |
||||
android:layout_margin="12dp" |
||||
android:contentDescription="@string/action_delete" |
||||
android:padding="4dp" |
||||
app:layout_constraintBottom_toBottomOf="parent" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintTop_toTopOf="parent" |
||||
app:layout_constraintVertical_bias="0" |
||||
app:srcCompat="@drawable/ic_clear_24dp" /> |
||||
|
||||
<androidx.recyclerview.widget.RecyclerView |
||||
android:id="@+id/draftMediaPreview" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginTop="8dp" |
||||
android:layout_marginEnd="8dp" |
||||
app:layout_constraintEnd_toEndOf="parent" |
||||
app:layout_constraintHorizontal_bias="0" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toBottomOf="@id/content" /> |
||||
|
||||
<com.keylesspalace.tusky.components.compose.view.PollPreviewView |
||||
android:id="@+id/draftPoll" |
||||
android:layout_width="wrap_content" |
||||
android:layout_height="wrap_content" |
||||
android:layout_marginStart="8dp" |
||||
android:layout_marginTop="8dp" |
||||
android:minWidth="@dimen/poll_preview_min_width" |
||||
app:layout_constraintStart_toStartOf="parent" |
||||
app:layout_constraintTop_toBottomOf="@id/draftMediaPreview" |
||||
app:layout_goneMarginEnd="8dp" /> |
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout> |
@ -1,19 +1,16 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<merge xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto"> |
||||
<com.google.android.material.appbar.AppBarLayout |
||||
xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto" |
||||
android:id="@+id/appbar" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:elevation="@dimen/actionbar_elevation" |
||||
app:layout_collapseMode="pin"> |
||||
|
||||
<com.google.android.material.appbar.AppBarLayout |
||||
android:id="@+id/appbar" |
||||
<androidx.appcompat.widget.Toolbar |
||||
android:id="@+id/toolbar" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="wrap_content" |
||||
android:elevation="@dimen/actionbar_elevation" |
||||
app:layout_collapseMode="pin"> |
||||
android:layout_height="?attr/actionBarSize" /> |
||||
|
||||
<androidx.appcompat.widget.Toolbar |
||||
android:id="@+id/toolbar" |
||||
android:layout_width="match_parent" |
||||
android:layout_height="?attr/actionBarSize" /> |
||||
|
||||
</com.google.android.material.appbar.AppBarLayout> |
||||
|
||||
</merge> |
||||
</com.google.android.material.appbar.AppBarLayout> |
||||
|
@ -0,0 +1,10 @@ |
||||
<?xml version="1.0" encoding="utf-8"?> |
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android" |
||||
xmlns:app="http://schemas.android.com/apk/res-auto"> |
||||
<item |
||||
android:id="@+id/action_old_drafts" |
||||
android:icon="@drawable/ic_notebook" |
||||
android:title="@string/old_drafts" |
||||
android:visible="false" |
||||
app:showAsAction="ifRoom" /> |
||||
</menu> |
Loading…
Reference in new issue