Indicate in UI when scheduled messages are present

Added a new property to scheduled messages to save which conversation the scheduled message belongs to.
This commit is contained in:
Inhishonor
2025-07-08 18:22:07 -07:00
parent 56a767bbd7
commit 3d7526512d
19 changed files with 146 additions and 18 deletions
@@ -36,7 +36,7 @@ class QkRealmMigration @Inject constructor(
) : RealmMigration {
companion object {
const val SchemaVersion: Long = 11
const val SchemaVersion: Long = 12
}
@SuppressLint("ApplySharedPref")
@@ -233,6 +233,14 @@ class QkRealmMigration @Inject constructor(
version++
}
if (version == 11L) {
realm.schema.get("ScheduledMessage")
?.addField("conversationId", Long::class.java, FieldAttribute.REQUIRED)
// Because there was never any property associated with which conversation/recipients a scheduled message was for,
// we can't update this field on a realm migration. It will be set to a default of 0
version++
}
check(version >= newVersion) { "Migration missing from v$oldVersion to v$newVersion" }
}
@@ -21,7 +21,8 @@ class ScheduledMessageRepositoryImpl @Inject constructor() : ScheduledMessageRep
recipients: List<String>,
sendAsGroup: Boolean,
body: String,
attachments: List<String>
attachments: List<String>,
conversationId: Long
): ScheduledMessage {
Realm.getDefaultInstance().use { realm ->
val id = (realm
@@ -34,7 +35,7 @@ class ScheduledMessageRepositoryImpl @Inject constructor() : ScheduledMessageRep
val attachmentsRealmList = RealmList(*attachments.toTypedArray())
val message = ScheduledMessage(id, date, subId, recipientsRealmList, sendAsGroup, body,
attachmentsRealmList)
attachmentsRealmList, conversationId)
realm.executeTransaction { realm.insertOrUpdate(message) }
@@ -63,6 +64,13 @@ class ScheduledMessageRepositoryImpl @Inject constructor() : ScheduledMessageRep
.findFirst()
}
override fun getScheduledMessagesForConversation(conversationId: Long): RealmResults<ScheduledMessage> {
return Realm.getDefaultInstance()
.where(ScheduledMessage::class.java)
.equalTo("conversationId", conversationId)
.findAllAsync()
}
override fun deleteScheduledMessage(id: Long) {
val subscription = Completable.fromAction {
Realm.getDefaultInstance().use { realm ->
@@ -41,7 +41,8 @@ class AddScheduledMessage @Inject constructor(
val recipients: List<String>,
val sendAsGroup: Boolean,
val body: String,
val attachments: List<String>
val attachments: List<String>,
val conversationId: Long
)
@SuppressLint("Range")
@@ -50,7 +51,13 @@ class AddScheduledMessage @Inject constructor(
return Flowable.just(params)
.map { // step 1 - save, as-is, to db to get primary key id
scheduledMessageRepo.saveScheduledMessage(
it.date, it.subId, it.recipients, it.sendAsGroup, it.body, it.attachments
it.date,
it.subId,
it.recipients,
it.sendAsGroup,
it.body,
it.attachments,
it.conversationId
)
}
.map { scheduledMessageDb ->
@@ -29,7 +29,8 @@ open class ScheduledMessage(
var recipients: RealmList<String> = RealmList(),
var sendAsGroup: Boolean = true,
var body: String = "",
var attachments: RealmList<String> = RealmList()
var attachments: RealmList<String> = RealmList(),
var conversationId: Long = 0
) : RealmObject() {
fun copy(
@@ -39,10 +40,11 @@ open class ScheduledMessage(
recipients: RealmList<String> = this.recipients,
sendAsGroup: Boolean = this.sendAsGroup,
body: String = this.body,
attachments: RealmList<String> = this.attachments
attachments: RealmList<String> = this.attachments,
conversationId: Long = this.conversationId
): ScheduledMessage {
return ScheduledMessage(id, date, subId, recipients, sendAsGroup, body, attachments)
return ScheduledMessage(id, date, subId, recipients, sendAsGroup, body, attachments, conversationId)
}
}
@@ -18,7 +18,9 @@
*/
package dev.octoshrimpy.quik.repository
import dev.octoshrimpy.quik.model.Recipient
import dev.octoshrimpy.quik.model.ScheduledMessage
import io.realm.RealmList
import io.realm.RealmResults
interface ScheduledMessageRepository {
@@ -32,7 +34,8 @@ interface ScheduledMessageRepository {
recipients: List<String>,
sendAsGroup: Boolean,
body: String,
attachments: List<String>
attachments: List<String>,
conversationId: Long
): ScheduledMessage
/**
@@ -50,6 +53,11 @@ interface ScheduledMessageRepository {
*/
fun getScheduledMessage(id: Long): ScheduledMessage?
/**
* Returns all scheduled messages with the given [conversationId]
*/
fun getScheduledMessagesForConversation(conversationId: Long): RealmResults<ScheduledMessage>
/**
* Deletes the scheduled message with the given [id]
*/
@@ -160,8 +160,9 @@ class Navigator @Inject constructor(
startActivity(Intent(context, BackupActivity::class.java))
}
fun showScheduled() {
fun showScheduled(conversationId: Long?) {
val intent = Intent(context, ScheduledActivity::class.java)
conversationId?.let { intent.putExtra("conversationId", it) }
startActivity(intent)
}
@@ -469,6 +469,8 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
// Don't set the adapters unless needed
if (state.editingMode && chips.adapter == null) chips.adapter = chipsAdapter
toolbar.menu.findItem(R.id.viewScheduledMessages)?.isVisible = !state.editingMode && state.selectedMessages == 0
&& state.query.isEmpty() && state.hasScheduledMessages
toolbar.menu.findItem(R.id.select_all)?.isVisible = !state.editingMode && (messageAdapter.itemCount > 1) && state.selectedMessages != 0
toolbar.menu.findItem(R.id.add)?.isVisible = state.editingMode
toolbar.menu.findItem(R.id.call)?.isVisible = !state.editingMode && state.selectedMessages == 0
@@ -47,6 +47,7 @@ data class ComposeState(
val remaining: String = "",
val subscription: SubscriptionInfoCompat? = null,
val canSend: Boolean = false,
val hasScheduledMessages: Boolean = false,
val validRecipientNumbers: Int = 1,
val audioMsgRecording: Boolean = false,
val saveDraft: Boolean = true,
@@ -72,6 +72,7 @@ import dev.octoshrimpy.quik.model.getText
import dev.octoshrimpy.quik.repository.ContactRepository
import dev.octoshrimpy.quik.repository.ConversationRepository
import dev.octoshrimpy.quik.repository.MessageRepository
import dev.octoshrimpy.quik.repository.ScheduledMessageRepository
import dev.octoshrimpy.quik.util.ActiveSubscriptionObservable
import dev.octoshrimpy.quik.util.FileUtils
import dev.octoshrimpy.quik.util.PhoneNumberUtils
@@ -114,6 +115,7 @@ class ComposeViewModel @Inject constructor(
private val markRead: MarkRead,
private val messageDetailsFormatter: MessageDetailsFormatter,
private val messageRepo: MessageRepository,
private val scheduledMessageRepo: ScheduledMessageRepository,
private val navigator: Navigator,
private val permissionManager: PermissionManager,
private val phoneNumberUtils: PhoneNumberUtils,
@@ -265,6 +267,22 @@ class ComposeViewModel @Inject constructor(
newState { copy(subscription = sub) }
}.subscribe()
// checks if there are any scheduled messages in convo
disposables += conversation
.distinctUntilChanged { conversation -> conversation.id }
.observeOn(AndroidSchedulers.mainThread())
.switchMap { conversation ->
scheduledMessageRepo
.getScheduledMessagesForConversation(conversation.id)
.asFlowable()
.toObservable()
}
.observeOn(AndroidSchedulers.mainThread())
.subscribe { liveResults ->
val hasAny = liveResults.isNotEmpty()
newState { copy(hasScheduledMessages = hasAny) }
}
// actions
if (mode == "scheduling")
newState { copy(scheduling = true) }
@@ -338,6 +356,15 @@ class ComposeViewModel @Inject constructor(
.autoDisposable(view.scope())
.subscribe { newState { copy() } }
// Show scheduled messages
view.optionsItemIntent
.filter {it == R.id.viewScheduledMessages}
.withLatestFrom(state, conversation)
.autoDisposable(view.scope())
.subscribe { (_, _, conversation) ->
navigator.showScheduled(conversation.id)
}
// toggle select all / select none
view.optionsItemIntent
.filter { it == R.id.select_all }
@@ -1099,6 +1126,7 @@ class ComposeViewModel @Inject constructor(
}
val subId = state.subscription?.subscriptionId ?: -1
val conversationId = (conversation.id)
val addresses = when (conversation.recipients.isNotEmpty()) {
true -> conversation.recipients.map { it.address }
false -> chips.map { chip -> chip.address }
@@ -1116,10 +1144,11 @@ class ComposeViewModel @Inject constructor(
addresses,
sendAsGroup,
body.toString(),
state.attachments.map { it.uri.toString() }
state.attachments.map { it.uri.toString() },
conversationId
)
).also {
newState { copy(scheduled = 0) }
newState { copy(scheduled = 0, hasScheduledMessages = true ) }
showScheduledToast = true
}
@@ -24,6 +24,7 @@ import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.text.buildSpannedString
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import dev.octoshrimpy.quik.R
import dev.octoshrimpy.quik.common.Navigator
import dev.octoshrimpy.quik.common.base.QkRealmAdapter
@@ -33,7 +34,9 @@ import dev.octoshrimpy.quik.common.util.DateFormatter
import dev.octoshrimpy.quik.common.util.extensions.resolveThemeColor
import dev.octoshrimpy.quik.common.util.extensions.setTint
import dev.octoshrimpy.quik.model.Conversation
import dev.octoshrimpy.quik.repository.ScheduledMessageRepository
import dev.octoshrimpy.quik.util.PhoneNumberUtils
import io.reactivex.disposables.CompositeDisposable
import kotlinx.android.synthetic.main.conversation_list_item.*
import kotlinx.android.synthetic.main.conversation_list_item.view.*
import javax.inject.Inject
@@ -42,9 +45,11 @@ class ConversationsAdapter @Inject constructor(
private val colors: Colors,
private val context: Context,
private val dateFormatter: DateFormatter,
private val scheduledMessageRepo: ScheduledMessageRepository,
private val navigator: Navigator,
private val phoneNumberUtils: PhoneNumberUtils
) : QkRealmAdapter<Conversation>() {
private val disposables = CompositeDisposable()
init {
// This is how we access the threadId for the swipe actions
@@ -117,6 +122,16 @@ class ConversationsAdapter @Inject constructor(
// Make the preview in italics if draft
if (conversation.draft.isNotEmpty()) holder.snippet.setTypeface(null, Typeface.ITALIC)
// Get Scheduled Messages
val disposable = scheduledMessageRepo
.getScheduledMessagesForConversation(conversation.id)
.asFlowable()
.toObservable()
.subscribe { messages ->
holder.scheduled.isVisible = messages.isNotEmpty()
}
disposables.add(disposable)
holder.pinned.isVisible = conversation.pinned
holder.unread.setTint(theme)
}
@@ -129,4 +144,10 @@ class ConversationsAdapter @Inject constructor(
return if (getItem(position)?.unread == false) 0 else 1
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
disposables.clear()
}
}
@@ -317,7 +317,7 @@ class MainViewModel @Inject constructor(
else -> newState { copy(hasError = true) }
}
NavItem.BACKUP -> navigator.showBackup()
NavItem.SCHEDULED -> navigator.showScheduled()
NavItem.SCHEDULED -> navigator.showScheduled(null)
NavItem.BLOCKING -> navigator.showBlockedConversations()
NavItem.SETTINGS -> navigator.showSettings()
// NavItem.PLUS -> navigator.showQksmsPlusActivity("main_menu")
@@ -66,7 +66,7 @@ class PlusViewModel @Inject constructor(
view.scheduleClicks
.autoDisposable(view.scope())
.subscribe { navigator.showScheduled() }
.subscribe { navigator.showScheduled(null) }
view.backupClicks
.autoDisposable(view.scope())
@@ -99,7 +99,7 @@ class ScheduledActivity : QkThemedActivity(), ScheduledView {
((scheduledMessageAdapter.itemCount != 0) && (state.selectedMessages == 1))
// show compose button
compose.isVisible = state.upgraded
compose.isVisible = state.upgraded && (state.conversationId == null)
upgrade.isVisible = !state.upgraded
}
@@ -23,6 +23,7 @@ import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import dev.octoshrimpy.quik.injection.ViewModelKey
import javax.inject.Named
@Module
class ScheduledActivityModule {
@@ -32,4 +33,9 @@ class ScheduledActivityModule {
@ViewModelKey(ScheduledViewModel::class)
fun provideScheduledViewModel(viewModel: ScheduledViewModel): ViewModel = viewModel
}
@Provides
@Named("conversationId")
fun getConversationId(activity: ScheduledActivity):
Long? = activity.intent.extras?.getLong("conversationId")
}
@@ -25,4 +25,5 @@ data class ScheduledState(
val scheduledMessages: RealmResults<ScheduledMessage>? = null,
val upgraded: Boolean = true,
val selectedMessages: Int = 0,
val conversationId: Long? = null,
)
@@ -15,8 +15,10 @@ import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.rxkotlin.plusAssign
import io.reactivex.schedulers.Schedulers
import javax.inject.Inject
import javax.inject.Named
class ScheduledViewModel @Inject constructor(
@Named("conversationId") private val conversationId: Long?,
billingManager: BillingManager,
private val context: Context,
private val navigator: Navigator,
@@ -24,10 +26,12 @@ class ScheduledViewModel @Inject constructor(
private val sendScheduledMessageInteractor: SendScheduledMessage,
private val deleteScheduledMessagesInteractor: DeleteScheduledMessages,
) : QkViewModel<ScheduledView, ScheduledState>(ScheduledState(
scheduledMessages = scheduledMessageRepo.getScheduledMessages()
scheduledMessages = scheduledMessageRepo.getScheduledMessages(),
conversationId = conversationId
)) {
init {
loadMessages(conversationId)
disposables += billingManager.upgradeStatus
.subscribe { upgraded -> newState { copy(upgraded = upgraded) } }
}
@@ -157,4 +161,12 @@ class ScheduledViewModel @Inject constructor(
.autoDisposable(view.scope())
.subscribe { navigator.showQksmsPlusActivity("schedule_fab") }
}
private fun loadMessages(conversationId: Long?) {
val results = if (conversationId != null)
scheduledMessageRepo.getScheduledMessagesForConversation(conversationId)
else
scheduledMessageRepo.getScheduledMessages()
newState { copy(scheduledMessages = results) }
}
}
@@ -69,7 +69,7 @@
android:ellipsize="end"
android:maxLines="1"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/pinned"
app:layout_constraintEnd_toStartOf="@id/scheduled"
app:layout_constraintStart_toStartOf="@id/title"
app:layout_constraintTop_toBottomOf="@id/title"
tools:text="@tools:sample/lorem/random" />
@@ -80,9 +80,23 @@
android:layout_height="16dp"
android:layout_marginTop="2dp"
android:paddingStart="4dp"
android:paddingTop="2dp"
android:src="@drawable/ic_baseline_push_pin_24"
android:tint="?android:attr/textColorTertiary"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/scheduled"
app:layout_constraintTop_toTopOf="@id/snippet"
tools:visibility="visible" />
<ImageView
android:id="@+id/scheduled"
android:layout_width="20dp"
android:layout_height="16dp"
android:layout_marginTop="2dp"
android:paddingStart="4dp"
android:src="@drawable/ic_event_black_24dp"
android:tint="?android:attr/textColorTertiary"
android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/unread"
app:layout_constraintTop_toTopOf="@id/snippet"
tools:visibility="visible" />
@@ -34,6 +34,13 @@
android:visible="false"
app:showAsAction="always" />
<item
android:id="@+id/viewScheduledMessages"
android:icon="@drawable/ic_event_black_24dp"
android:title="@string/compose_scheduled_view"
android:visible="false"
app:showAsAction="always" />
<item
android:id="@+id/select_all"
android:icon="?attr/actionModeSelectAllDrawable"
@@ -139,6 +139,7 @@
<string name="compose_scheduled_future">Selected time must be in the future!</string>
<string name="compose_scheduled_plus">You must unlock QUIK+ to use scheduled messaging</string>
<string name="compose_scheduled_toast">Added to scheduled messages</string>
<string name="compose_scheduled_view">Scheduled messages for this conversation</string>
<string name="compose_hint">Write a message…</string>
<string name="compose_no_valid_recipients">Can\'t send to other participants</string>
<string name="compose_menu_copy">Copy text</string>