From d7c5b1b71e9fcda7bb0b7051a3f34e3573c4a71c Mon Sep 17 00:00:00 2001 From: gavine99 Date: Wed, 19 Feb 2025 18:51:36 +1100 Subject: [PATCH] changes to make scheduled messages activity ui compliant with the rest of the app message listing activities. now allows sending and deleting multiple scheduled messages at once --- .../ScheduledMessageRepositoryImpl.kt | 4 + .../repository/ScheduledMessageRepository.kt | 3 + .../moez/QKSMS/common/base/QkRealmAdapter.kt | 47 ++- .../QKSMS/feature/compose/MessagesAdapter.kt | 35 --- .../conversations/ConversationsAdapter.kt | 23 -- .../feature/scheduled/ScheduledActivity.kt | 91 ++++-- .../scheduled/ScheduledMessageAdapter.kt | 15 +- .../QKSMS/feature/scheduled/ScheduledState.kt | 3 +- .../QKSMS/feature/scheduled/ScheduledView.kt | 13 +- .../feature/scheduled/ScheduledViewModel.kt | 108 +++++-- .../main/res/layout/scheduled_activity.xml | 268 ++++++++++-------- .../src/main/res/menu/scheduled_messages.xml | 51 ++++ presentation/src/main/res/values/strings.xml | 10 +- 13 files changed, 430 insertions(+), 241 deletions(-) create mode 100644 presentation/src/main/res/menu/scheduled_messages.xml diff --git a/data/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepositoryImpl.kt b/data/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepositoryImpl.kt index c407c526..676133c8 100644 --- a/data/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepositoryImpl.kt +++ b/data/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepositoryImpl.kt @@ -69,6 +69,10 @@ class ScheduledMessageRepositoryImpl @Inject constructor() : ScheduledMessageRep disposables.add(subscription) } + override fun deleteScheduledMessages(ids: List) { + ids.forEach { deleteScheduledMessage(it) } + } + // Ensure to clear disposables when the repository is no longer needed fun clearDisposables() { disposables.clear() diff --git a/domain/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepository.kt b/domain/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepository.kt index cd78bc08..e4150aa9 100644 --- a/domain/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepository.kt +++ b/domain/src/main/java/com/moez/QKSMS/repository/ScheduledMessageRepository.kt @@ -50,4 +50,7 @@ interface ScheduledMessageRepository { */ fun deleteScheduledMessage(id: Long) + // delete multiple scheduled messages by id list + fun deleteScheduledMessages(ids: List) + } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/common/base/QkRealmAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/common/base/QkRealmAdapter.kt index 1fbc7d6d..8312e765 100644 --- a/presentation/src/main/java/com/moez/QKSMS/common/base/QkRealmAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/common/base/QkRealmAdapter.kt @@ -52,13 +52,28 @@ abstract class QkRealmAdapter : RealmRecyclerViewAdapter() + /** + * Mark this message as highlighted + */ + var highlight: Long = -1L + set(value) { + if (field == value) return + + field = value + notifyDataSetChanged() + } + /** * Toggles the selected state for a particular view * * If we are currently in selection mode (we have an active selection), then the state will * toggle. If we are not in selection mode, then we will only toggle if [force] */ - protected fun toggleSelection(id: Long, force: Boolean = true): Boolean { + protected fun toggleSelection( + id: Long, + force: Boolean = true, + useChangeEvent: Boolean = true + ): Boolean { if (!force && selection.isEmpty()) return false selection = when (selection.contains(id)) { @@ -66,7 +81,8 @@ abstract class QkRealmAdapter : RealmRecyclerViewAdapter selection + id } - selectionChanges.onNext(selection) + if (useChangeEvent) + selectionChanges.onNext(selection) return true } @@ -80,6 +96,33 @@ abstract class QkRealmAdapter : RealmRecyclerViewAdapter= 0 are allowed. Input was: $index") diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt index e6f2074f..c4421ef1 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/compose/MessagesAdapter.kt @@ -132,17 +132,6 @@ class MessagesAdapter @Inject constructor( private val conversation: Conversation? get() = data?.first?.takeIf { it.isValid } - /** - * Mark this message as highlighted - */ - var highlight: Long = -1L - set(value) { - if (field == value) return - - field = value - notifyDataSetChanged() - } - private val contactCache = ContactCache() private val expanded = HashMap() private val subs = subscriptionManager.activeSubscriptionInfoList @@ -405,30 +394,6 @@ class MessagesAdapter @Inject constructor( } } - fun toggleSelectAll() { - var needToSelectAll = false - - // if a non-selected item is found, then we need to select all, otherwise deselect all - for (position in 0 until itemCount) - if (!isSelected(getItemId(position))) { - needToSelectAll = true - break - } - - // select or deselect item based on if toggling all selected of deselected - for (position in 0 until itemCount) { - val messageId = getItemId(position) - // if deselecting all then toggle selection (we know all items are selected) - if (!needToSelectAll) - toggleSelection(messageId) - // else, selecting all, toggle if not already selected - else if (!isSelected(messageId)) - toggleSelection(messageId) - } - - notifyDataSetChanged() - } - fun expandMessages(messageIds: List, expand: Boolean) { messageIds.forEach { messageId -> expanded[messageId] = expand } notifyDataSetChanged() diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt index ec9ff36d..e9448d7e 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/conversations/ConversationsAdapter.kt @@ -131,27 +131,4 @@ class ConversationsAdapter @Inject constructor( return if (getItem(position)?.unread == false) 0 else 1 } - fun toggleSelectAll() { - var needToSelectAll = false - - // if a non-selected item is found, then we need to select all, otherwise deselect all - for (position in 0 until itemCount) - if (!isSelected(getItemId(position))) { - needToSelectAll = true - break - } - - // select or deselect item based on if toggling all selected of deselected - for (position in 0 until itemCount) { - val threadId = getItemId(position) - // if deselecting all then toggle selection (we know all items are selected) - if (!needToSelectAll) - toggleSelection(threadId) - // else, selecting all, toggle if not already selected - else if (!isSelected(threadId)) - toggleSelection(threadId) - } - - notifyDataSetChanged() - } } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledActivity.kt b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledActivity.kt index 77603095..4439fd6c 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledActivity.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledActivity.kt @@ -20,33 +20,36 @@ package dev.octoshrimpy.quik.feature.scheduled import android.graphics.Typeface import android.os.Bundle +import android.view.Menu +import android.view.MenuItem import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProviders import com.jakewharton.rxbinding2.view.clicks import dev.octoshrimpy.quik.R -import dev.octoshrimpy.quik.common.QkDialog import dev.octoshrimpy.quik.common.base.QkThemedActivity -import dev.octoshrimpy.quik.common.util.FontProvider import dev.octoshrimpy.quik.common.util.extensions.setBackgroundTint import dev.octoshrimpy.quik.common.util.extensions.setTint import dagger.android.AndroidInjection -import kotlinx.android.synthetic.main.collapsing_toolbar.* +import io.reactivex.subjects.PublishSubject +import io.reactivex.subjects.Subject +import kotlinx.android.synthetic.main.main_activity.toolbar import kotlinx.android.synthetic.main.scheduled_activity.* import javax.inject.Inject class ScheduledActivity : QkThemedActivity(), ScheduledView { - @Inject lateinit var dialog: QkDialog - @Inject lateinit var fontProvider: FontProvider - @Inject lateinit var messageAdapter: ScheduledMessageAdapter + @Inject lateinit var scheduledMessageAdapter: ScheduledMessageAdapter @Inject lateinit var viewModelFactory: ViewModelProvider.Factory - override val messageClickIntent by lazy { messageAdapter.clicks } - override val messageMenuIntent by lazy { dialog.adapter.menuItemClicks } override val composeIntent by lazy { compose.clicks() } override val upgradeIntent by lazy { upgrade.clicks() } + override val messagesSelectedIntent by lazy { scheduledMessageAdapter.selectionChanges } + override val optionsItemIntent: Subject = PublishSubject.create() + override val deleteScheduledMessages: Subject> = PublishSubject.create() + override val sendScheduledMessages: Subject> = PublishSubject.create() + override val backPressedIntent: Subject = PublishSubject.create() private val viewModel by lazy { ViewModelProviders.of(this, viewModelFactory)[ScheduledViewModel::class.java] } @@ -58,19 +61,8 @@ class ScheduledActivity : QkThemedActivity(), ScheduledView { showBackButton(true) viewModel.bindView(this) - if (!prefs.systemFont.get()) { - fontProvider.getLato { lato -> - val typeface = Typeface.create(lato, Typeface.BOLD) - collapsingToolbar.setCollapsedTitleTypeface(typeface) - collapsingToolbar.setExpandedTitleTypeface(typeface) - } - } - - dialog.title = getString(R.string.scheduled_options_title) - dialog.adapter.setData(R.array.scheduled_options) - - messageAdapter.emptyView = empty - messages.adapter = messageAdapter + scheduledMessageAdapter.emptyView = empty + messages.adapter = scheduledMessageAdapter colors.theme().let { theme -> sampleMessage.setBackgroundTint(theme.theme) @@ -84,14 +76,65 @@ class ScheduledActivity : QkThemedActivity(), ScheduledView { } override fun render(state: ScheduledState) { - messageAdapter.updateData(state.scheduledMessages) + scheduledMessageAdapter.updateData(state.scheduledMessages) + setTitle(when { + (state.selectedMessages > 0) -> + getString(R.string.compose_title_selected, state.selectedMessages) + else -> getString(R.string.scheduled_title) + }) + + // show/hide menu items + toolbar.menu.findItem(R.id.delete)?.isVisible = + ((scheduledMessageAdapter.itemCount != 0) && (state.selectedMessages != 0)) + toolbar.menu.findItem(R.id.select_all)?.isVisible = + ((scheduledMessageAdapter.itemCount > 1) && (state.selectedMessages != 0)) + toolbar.menu.findItem(R.id.copy)?.isVisible = (state.selectedMessages != 0) + toolbar.menu.findItem(R.id.send_now)?.isVisible = (state.selectedMessages != 0) + + // show compose button compose.isVisible = state.upgraded upgrade.isVisible = !state.upgraded } - override fun showMessageOptions() { - dialog.show(this) + override fun onBackPressed() = backPressedIntent.onNext(Unit) + + override fun clearSelection() = scheduledMessageAdapter.clearSelection() + + override fun toggleSelectAll() = scheduledMessageAdapter.toggleSelectAll() + + override fun showDeleteDialog(messages: List) { + val count = messages.size + android.app.AlertDialog.Builder(this) + .setTitle(R.string.dialog_delete_title) + .setMessage(resources.getQuantityString(R.plurals.dialog_delete_chat, count, count)) + .setPositiveButton(R.string.button_delete) { _, _ -> deleteScheduledMessages.onNext(messages) } + .setNegativeButton(R.string.button_cancel, null) + .show() + } + + override fun showSendNowDialog(messages: List) { + val count = messages.size + android.app.AlertDialog.Builder(this) + .setTitle(R.string.dialog_delete_title) + .setMessage(resources.getQuantityString(R.plurals.dialog_send_now, count, count)) + .setPositiveButton(R.string.main_menu_send_now) { _, _ -> sendScheduledMessages.onNext(messages) } + .setNegativeButton(R.string.button_cancel, null) + .show() + } + + override fun onCreateOptionsMenu(menu: Menu?): Boolean { + menuInflater.inflate(R.menu.scheduled_messages, menu) + return super.onCreateOptionsMenu(menu) + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + optionsItemIntent.onNext(item.itemId) + return true + } + + override fun finishActivity() { + finish() } } \ No newline at end of file diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAdapter.kt b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAdapter.kt index 9d2e59b0..f42f6a66 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAdapter.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledMessageAdapter.kt @@ -61,7 +61,13 @@ class ScheduledMessageAdapter @Inject constructor( return QkViewHolder(view).apply { view.setOnClickListener { val message = getItem(adapterPosition) ?: return@setOnClickListener - clicks.onNext(message.id) + if (toggleSelection(message.id, false)) + view.isActivated = isSelected(message.id) + } + view.setOnClickListener { + val message = getItem(adapterPosition) ?: return@setOnClickListener + toggleSelection(message.id) + view.isActivated = isSelected(message.id) } } } @@ -79,11 +85,18 @@ class ScheduledMessageAdapter @Inject constructor( holder.date.text = dateFormatter.getScheduledTimestamp(message.date) holder.body.text = message.body + // update the selected/highlighted state + holder.containerView.isActivated = isSelected(message.id) || highlight == message.id + val adapter = holder.attachments.adapter as ScheduledMessageAttachmentAdapter adapter.data = message.attachments.map(Uri::parse) holder.attachments.isVisible = message.attachments.isNotEmpty() } + override fun getItemId(position: Int): Long { + return getItem(position)?.id ?: -1 + } + /** * Cache the contacts in a map by the address, because the messages we're binding don't have * a reference to the contact. diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledState.kt b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledState.kt index 987d7f54..92d42439 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledState.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledState.kt @@ -23,5 +23,6 @@ import io.realm.RealmResults data class ScheduledState( val scheduledMessages: RealmResults? = null, - val upgraded: Boolean = true + val upgraded: Boolean = true, + val selectedMessages: Int = 0, ) diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledView.kt b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledView.kt index 26b079d5..b1717287 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledView.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledView.kt @@ -23,11 +23,18 @@ import io.reactivex.Observable interface ScheduledView : QkView { - val messageClickIntent: Observable - val messageMenuIntent: Observable val composeIntent: Observable<*> val upgradeIntent: Observable<*> + val messagesSelectedIntent: Observable> + val optionsItemIntent: Observable + val deleteScheduledMessages: Observable> + val sendScheduledMessages: Observable> + val backPressedIntent: Observable - fun showMessageOptions() + fun clearSelection() + fun toggleSelectAll() + fun showDeleteDialog(messages: List) + fun showSendNowDialog(messages: List) + fun finishActivity() } diff --git a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledViewModel.kt b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledViewModel.kt index c2f1489b..e2449208 100644 --- a/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledViewModel.kt +++ b/presentation/src/main/java/com/moez/QKSMS/feature/scheduled/ScheduledViewModel.kt @@ -4,14 +4,12 @@ import android.content.Context import dev.octoshrimpy.quik.R import dev.octoshrimpy.quik.common.Navigator import dev.octoshrimpy.quik.common.base.QkViewModel -import dev.octoshrimpy.quik.common.util.ClipboardUtils -import dev.octoshrimpy.quik.common.util.extensions.makeToast import dev.octoshrimpy.quik.interactor.SendScheduledMessage import dev.octoshrimpy.quik.manager.BillingManager import dev.octoshrimpy.quik.repository.ScheduledMessageRepository import com.uber.autodispose.android.lifecycle.scope import com.uber.autodispose.autoDisposable -import io.reactivex.Observable +import dev.octoshrimpy.quik.common.util.ClipboardUtils import io.reactivex.android.schedulers.AndroidSchedulers import io.reactivex.rxkotlin.plusAssign import io.reactivex.rxkotlin.withLatestFrom @@ -23,7 +21,7 @@ class ScheduledViewModel @Inject constructor( private val context: Context, private val navigator: Navigator, private val scheduledMessageRepo: ScheduledMessageRepository, - private val sendScheduledMessage: SendScheduledMessage + private val sendScheduledMessageInteractor: SendScheduledMessage, ) : QkViewModel(ScheduledState( scheduledMessages = scheduledMessageRepo.getScheduledMessages() )) { @@ -36,41 +34,93 @@ class ScheduledViewModel @Inject constructor( override fun bindView(view: ScheduledView) { super.bindView(view) - view.messageClickIntent + // update the state when the message selected count changes + view.messagesSelectedIntent + .map { selection -> selection.size } .autoDisposable(view.scope()) - .subscribe { view.showMessageOptions() } + .subscribe { newState { copy(selectedMessages = it) } } - view.messageMenuIntent - .withLatestFrom(view.messageClickIntent) { itemId, messageId -> - when (itemId) { - 0 -> sendScheduledMessage.execute(messageId) - 1 -> scheduledMessageRepo.getScheduledMessage(messageId)?.let { message -> - ClipboardUtils.copy(context, message.body) - context.makeToast(R.string.toast_copied) - } - 2 -> { - val deleteSubscription = Observable.fromCallable { - scheduledMessageRepo.deleteScheduledMessage(messageId) - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ - // Handle completion, e.g., log success or update UI - }, { - // Handle error, e.g., log or show error message - }) + // toggle select all / select none + view.optionsItemIntent + .filter { it == R.id.select_all } + .autoDisposable(view.scope()) + .subscribe { view.toggleSelectAll() } - disposables += deleteSubscription + // show the delete message dialog if one or more messages selected + view.optionsItemIntent + .filter { it == R.id.delete } + .withLatestFrom(view.messagesSelectedIntent) { _, selectedMessages -> selectedMessages } + .autoDisposable(view.scope()) + .subscribe { view.showDeleteDialog(it) } + + // copy the selected message text to the clipboard + view.optionsItemIntent + .filter { it == R.id.copy } + .withLatestFrom(view.messagesSelectedIntent) { _, selectedMessages -> selectedMessages } + .autoDisposable(view.scope()) + .subscribe { + val messages = it + .mapNotNull(scheduledMessageRepo::getScheduledMessage) + .sortedBy { it.date } // same order as messages on screen + val text = when (messages.size) { + 1 -> messages.first().body + else -> messages.fold(StringBuilder()) { acc, message -> + if (acc.isNotEmpty() && message.body.isNotEmpty()) + acc.append("\n\n") + acc.append(message.body) } } - Unit + + ClipboardUtils.copy(context, text.toString()) } + + // send the messages now menu item selected + view.optionsItemIntent + .filter { it == R.id.send_now } + .withLatestFrom(view.messagesSelectedIntent) { _, selectedMessages -> selectedMessages } .autoDisposable(view.scope()) - .subscribe() + .subscribe { view.showSendNowDialog(it) } + + // delete message(s) (fired after the confirmation dialog has been shown) + view.deleteScheduledMessages + .observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .autoDisposable(view.scope()) + .subscribe { + scheduledMessageRepo.deleteScheduledMessages(it) + view.clearSelection() + } + + // send message(s) now (fired after the confirmation dialog has been shown) + view.sendScheduledMessages + .observeOn(AndroidSchedulers.mainThread()) + .subscribeOn(Schedulers.io()) + .autoDisposable(view.scope()) + .subscribe { + it.forEach { sendScheduledMessageInteractor.execute(it) } + view.clearSelection() + } + + // navigate back or unselect + view.optionsItemIntent + .filter { it == android.R.id.home } + .map { Unit } + .mergeWith(view.backPressedIntent) + .withLatestFrom(state) { _, state -> state } + .autoDisposable(view.scope()) + .subscribe { + when { + (it.selectedMessages > 0) -> view.clearSelection() + else -> view.finishActivity() + } + } view.composeIntent .autoDisposable(view.scope()) - .subscribe { navigator.showCompose(mode = "scheduling") } + .subscribe { + navigator.showCompose(mode = "scheduling") + view.clearSelection() + } view.upgradeIntent .autoDisposable(view.scope()) diff --git a/presentation/src/main/res/layout/scheduled_activity.xml b/presentation/src/main/res/layout/scheduled_activity.xml index 7b598f1c..08199c0e 100644 --- a/presentation/src/main/res/layout/scheduled_activity.xml +++ b/presentation/src/main/res/layout/scheduled_activity.xml @@ -17,145 +17,177 @@ ~ You should have received a copy of the GNU General Public License ~ along with QKSMS. If not, see . --> - + android:layout_height="match_parent" + android:orientation="vertical"> - + android:layout_height="wrap_content" + android:gravity="center_vertical" + app:layout_constrainedHeight="true" + app:layout_constraintHeight_max="140dp" + app:layout_constraintTop_toTopOf="parent"> - - - - - - - - - - - - - - - - - - - + android:textDirection="ltr" + tools:text="Moez Bhatti" /> - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + android:layout_gravity="bottom|end" + android:layout_margin="16dp" + android:contentDescription="@string/scheduled_compose_cd" + android:src="@drawable/ic_schedule_send_black_24dp" + android:tint="@color/white" + android:visibility="visible" + tools:backgroundTint="@color/tools_theme" /> - + - + - + - \ No newline at end of file + + + + + + \ No newline at end of file diff --git a/presentation/src/main/res/menu/scheduled_messages.xml b/presentation/src/main/res/menu/scheduled_messages.xml new file mode 100644 index 00000000..1e86b168 --- /dev/null +++ b/presentation/src/main/res/menu/scheduled_messages.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + diff --git a/presentation/src/main/res/values/strings.xml b/presentation/src/main/res/values/strings.xml index b1dffe67..1a1a8a4b 100644 --- a/presentation/src/main/res/values/strings.xml +++ b/presentation/src/main/res/values/strings.xml @@ -57,6 +57,7 @@ Mark read Mark unread Block + Send now Rename conversation Syncing messages… You: %s @@ -102,6 +103,10 @@ Are you sure you would like to delete this message? Are you sure you would like to delete %d messages? + + Are you sure you would like to send this message now? + Are you sure you would like to send %d messages now? + Clear message Clear the message? This action can\'t be undone. @@ -234,11 +239,6 @@ Schedule a message Scheduled message Missing - - Send now - Copy text - Delete - Appearance General