diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/App.kt b/app/src/main/java/pl/szczodrzynski/edziennik/App.kt index b3ec1cc3..55b0ff69 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/App.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/App.kt @@ -72,6 +72,8 @@ class App : MultiDexApplication(), Configuration.Provider, CoroutineScope { val attendanceManager by lazy { AttendanceManager(this) } val buildManager by lazy { BuildManager(this) } val availabilityManager by lazy { AvailabilityManager(this) } + val textStylingManager by lazy { TextStylingManager(this) } + val messageManager by lazy { MessageManager(this) } val db get() = App.db diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt b/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt index 8c03ded0..6bf05279 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt @@ -814,6 +814,8 @@ fun View.attachToastHint(stringRes: Int) = onLongClick { true } +fun View.detachToastHint() = setOnLongClickListener(null) + fun LiveData.observeOnce(lifecycleOwner: LifecycleOwner, observer: Observer) { observe(lifecycleOwner, object : Observer { override fun onChanged(t: T?) { diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/db/dao/MessageDao.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/db/dao/MessageDao.kt index 51d56854..d4607bcc 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/data/db/dao/MessageDao.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/db/dao/MessageDao.kt @@ -29,7 +29,7 @@ abstract class MessageDao : BaseDao { LEFT JOIN metadata ON messageId = thingId AND thingType = ${Metadata.TYPE_MESSAGE} AND metadata.profileId = messages.profileId """ - private const val ORDER_BY = """ORDER BY messageIsPinned, addedDate DESC""" + private const val ORDER_BY = """ORDER BY messageIsPinned DESC, addedDate DESC""" } private val selective by lazy { MessageDaoSelective(App.db) } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/db/entity/Message.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/db/entity/Message.kt index 98105057..c115c3d3 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/data/db/entity/Message.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/db/entity/Message.kt @@ -41,7 +41,7 @@ open class Message( } @ColumnInfo(name = "messageIsPinned") - var isPinned: Boolean = false + var isStarred: Boolean = false var hasAttachments = false // if the attachments are not yet downloaded but we already know there are some get() = field || attachmentIds.isNotNullNorEmpty() diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/data/db/full/MessageFull.kt b/app/src/main/java/pl/szczodrzynski/edziennik/data/db/full/MessageFull.kt index 96103f38..3f912711 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/data/db/full/MessageFull.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/data/db/full/MessageFull.kt @@ -31,6 +31,8 @@ class MessageFull( var filterWeight = 0 @Ignore var searchHighlightText: CharSequence? = null + @Ignore + var readByEveryone = true // metadata var seen = false diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessageFragment.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessageFragment.kt index 31b6abc5..430fe0f0 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessageFragment.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessageFragment.kt @@ -27,7 +27,6 @@ import pl.szczodrzynski.edziennik.data.db.entity.LoginStore import pl.szczodrzynski.edziennik.data.db.entity.LoginStore.Companion.LOGIN_TYPE_IDZIENNIK import pl.szczodrzynski.edziennik.data.db.entity.Message.Companion.TYPE_DELETED import pl.szczodrzynski.edziennik.data.db.entity.Message.Companion.TYPE_RECEIVED -import pl.szczodrzynski.edziennik.data.db.entity.Message.Companion.TYPE_SENT import pl.szczodrzynski.edziennik.data.db.full.MessageFull import pl.szczodrzynski.edziennik.databinding.MessageFragmentBinding import pl.szczodrzynski.edziennik.ui.dialogs.MessagesConfigDialog @@ -53,6 +52,8 @@ class MessageFragment : Fragment(), CoroutineScope { override val coroutineContext: CoroutineContext get() = job + Dispatchers.Main + private val manager + get() = app.messageManager private lateinit var message: MessageFull override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -92,6 +93,35 @@ class MessageFragment : Fragment(), CoroutineScope { it.maxLines = if (it.maxLines == 30) 2 else 30 } + val replyDrawable = IconicsDrawable(activity, CommunityMaterial.Icon3.cmd_reply_outline).apply { + sizeDp = 24 + colorAttr(activity, android.R.attr.textColorPrimary) + } + val forwardDrawable = IconicsDrawable(activity, CommunityMaterial.Icon.cmd_arrow_right).apply { + sizeDp = 24 + colorAttr(activity, android.R.attr.textColorPrimary) + } + val deleteDrawable = IconicsDrawable(activity, CommunityMaterial.Icon.cmd_delete_outline).apply { + sizeDp = 24 + colorAttr(activity, android.R.attr.textColorPrimary) + } + val downloadDrawable = IconicsDrawable(activity, CommunityMaterial.Icon.cmd_download_outline).apply { + sizeDp = 24 + colorAttr(activity, android.R.attr.textColorPrimary) + } + b.replyButton.setCompoundDrawables(null, replyDrawable, null, null) + b.forwardButton.setCompoundDrawables(null, forwardDrawable, null, null) + b.deleteButton.setCompoundDrawables(null, deleteDrawable, null, null) + b.downloadButton.setCompoundDrawables(null, downloadDrawable, null, null) + + b.messageStar.onClick { + launch { + manager.starMessage(message, !message.isStarred) + manager.setStarIcon(b.messageStar, message) + } + } + b.messageStar.attachToastHint(R.string.hint_message_star) + b.replyButton.onClick { activity.loadTarget(MainActivity.TARGET_MESSAGES_COMPOSE, Bundle( "message" to app.gson.toJson(message), @@ -110,10 +140,7 @@ class MessageFragment : Fragment(), CoroutineScope { .setMessage(R.string.messages_delete_confirmation_text) .setPositiveButton(R.string.ok) { _, _ -> launch { - message.type = TYPE_DELETED - withContext(Dispatchers.Default) { - app.db.messageDao().replace(message) - } + manager.markAsDeleted(message) Toast.makeText(activity, "Wiadomość przeniesiona do usuniętych", Toast.LENGTH_SHORT).show() activity.navigateUp() } @@ -127,59 +154,10 @@ class MessageFragment : Fragment(), CoroutineScope { } launch { - - val messageString = arguments?.getString("message") - val messageId = arguments?.getLong("messageId") - if (messageId == null) { + message = manager.getMessage(App.profileId, arguments) ?: run { activity.navigateUp() return@launch } - - val msg = withContext(Dispatchers.Default) { - - val msg = - if (messageString != null) - app.gson.fromJson(messageString, MessageFull::class.java)?.also { - if (arguments?.getLong("sentDate") ?: 0L > 0L) - it.addedDate = arguments?.getLong("sentDate") ?: 0L - } - else - app.db.messageDao().getByIdNow(App.profileId, messageId) - - if (msg == null) - return@withContext null - - // make recipients ID-unique - // this helps when multiple profiles receive the same message - // (there are multiple -1 recipients for the same message ID) - val recipientsDistinct = - msg.recipients?.distinctBy { it.id } ?: return@withContext null - msg.recipients?.clear() - msg.recipients?.addAll(recipientsDistinct) - - // load recipients in sent messages - val teachers by lazy { app.db.teacherDao().getAllNow(App.profileId) } - msg.recipients?.forEach { recipient -> - if (recipient.fullName == null) { - recipient.fullName = teachers.firstOrNull { it.id == recipient.id }?.fullName ?: "" - } - } - - if (msg.type == TYPE_SENT && msg.senderName == null) { - msg.senderName = app.profile.accountName ?: app.profile.studentNameLong - } - - //msg.recipients = app.db.messageRecipientDao().getAllByMessageId(msg.profileId, msg.id) - if (msg.body != null && !msg.seen) { - app.db.metadataDao().setSeen(msg.profileId, msg, true) - } - return@withContext msg - } ?: run { - activity.navigateUp() - return@launch - } - - message = msg b.subject.text = message.subject checkMessage() } @@ -208,7 +186,6 @@ class MessageFragment : Fragment(), CoroutineScope { } } - val readByAll = checkRecipients() if (app.profile.loginStoreType == LoginStore.LOGIN_TYPE_VULCAN) { // vulcan: change message status or download attachments if (message.type == TYPE_RECEIVED && !message.seen || message.attachmentIds == null) { @@ -216,7 +193,7 @@ class MessageFragment : Fragment(), CoroutineScope { return } } - else if (!readByAll) { + else if (!message.readByEveryone) { // if a sent msg is not read by everyone, download it again to check the read status EdziennikTask.messageGet(App.profileId, message).enqueue(activity) return @@ -225,16 +202,6 @@ class MessageFragment : Fragment(), CoroutineScope { showMessage() } - private fun checkRecipients(): Boolean { - message.recipients?.forEach { recipient -> - if (recipient.id == -1L) - recipient.fullName = app.profile.accountName ?: app.profile.studentNameLong ?: "" - if (message.type == TYPE_SENT && recipient.readDate < 1) - return false - } - return true - } - private fun showMessage() { b.body.text = MessagesUtils.htmlToSpannable(activity, message.body.toString()) b.date.text = getString(R.string.messages_date_time_format, Date.fromMillis(message.addedDate).formattedStringShort, Time.fromMillis(message.addedDate).stringHM) @@ -245,6 +212,8 @@ class MessageFragment : Fragment(), CoroutineScope { b.subject.text = message.subject + manager.setStarIcon(b.messageStar, message) + b.replyButton.isVisible = message.type == TYPE_RECEIVED || message.type == TYPE_DELETED b.deleteButton.isVisible = message.type == TYPE_RECEIVED if (message.type == TYPE_RECEIVED || message.type == TYPE_DELETED) { @@ -255,9 +224,9 @@ class MessageFragment : Fragment(), CoroutineScope { fabIcon = CommunityMaterial.Icon3.cmd_reply_outline } - setFabOnClickListener(View.OnClickListener { + setFabOnClickListener { b.replyButton.performClick() - }) + } } activity.gainAttentionFAB() } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessagesAdapter.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessagesAdapter.kt index aded9f4a..16faa588 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessagesAdapter.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessagesAdapter.kt @@ -22,7 +22,8 @@ import kotlin.coroutines.CoroutineContext class MessagesAdapter( val activity: AppCompatActivity, val teachers: List, - val onItemClick: ((item: MessageFull) -> Unit)? = null + val onItemClick: ((item: MessageFull) -> Unit)? = null, + val onStarClick: ((item: MessageFull) -> Unit)? = null, ) : RecyclerView.Adapter(), CoroutineScope, Filterable { companion object { private const val TAG = "MessagesAdapter" @@ -32,13 +33,17 @@ class MessagesAdapter( private val app = activity.applicationContext as App // optional: place the manager here + internal val manager + get() = app.messageManager private val job = Job() override val coroutineContext: CoroutineContext get() = job + Dispatchers.Main + // mutable var changed by the filter var items = listOf() - var allItems = listOf() + // mutable list managed by the fragment + val allItems = mutableListOf() val typefaceNormal: Typeface by lazy { Typeface.create(Typeface.DEFAULT, Typeface.NORMAL) } val typefaceBold: Typeface by lazy { Typeface.create(Typeface.DEFAULT, Typeface.BOLD) } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessagesListFragment.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessagesListFragment.kt index 32761515..65202979 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessagesListFragment.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/MessagesListFragment.kt @@ -12,13 +12,11 @@ import androidx.core.view.isVisible import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView.NO_POSITION -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.withContext +import kotlinx.coroutines.* import pl.szczodrzynski.edziennik.* import pl.szczodrzynski.edziennik.data.db.entity.Message import pl.szczodrzynski.edziennik.data.db.entity.Teacher +import pl.szczodrzynski.edziennik.data.db.full.MessageFull import pl.szczodrzynski.edziennik.databinding.MessagesListFragmentBinding import pl.szczodrzynski.edziennik.ui.modules.base.lazypager.LazyFragment import pl.szczodrzynski.edziennik.ui.modules.messages.models.MessagesSearch @@ -33,13 +31,15 @@ class MessagesListFragment : LazyFragment(), CoroutineScope { private lateinit var app: App private lateinit var activity: MainActivity private lateinit var b: MessagesListFragmentBinding - private var adapter: MessagesAdapter? = null + private lateinit var adapter: MessagesAdapter private val job: Job = Job() override val coroutineContext: CoroutineContext get() = job + Dispatchers.Main // local/private variables go here + private val manager + get() = app.messageManager var teachers = listOf() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -54,22 +54,28 @@ class MessagesListFragment : LazyFragment(), CoroutineScope { val messageType = arguments.getInt("messageType", Message.TYPE_RECEIVED) var topPosition = arguments.getInt("topPosition", NO_POSITION) var bottomPosition = arguments.getInt("bottomPosition", NO_POSITION) - val searchText = arguments.getString("searchText", "") + val searchText = arguments?.getString("searchText") teachers = withContext(Dispatchers.Default) { app.db.teacherDao().getAllNow(App.profileId) } - adapter = MessagesAdapter(activity, teachers) { + adapter = MessagesAdapter(activity, teachers, onItemClick = { activity.loadTarget(MainActivity.TARGET_MESSAGES_DETAILS, Bundle( - "messageId" to it.id + "messageId" to it.id )) - } + }, onStarClick = { + this@MessagesListFragment.launch { + manager.starMessage(it, !it.isStarred) + } + }) app.db.messageDao().getAllByType(App.profileId, messageType).observe(this@MessagesListFragment, Observer { messages -> - if (!isAdded) return@Observer + if (!isAdded || !this@MessagesListFragment::adapter.isInitialized) + return@Observer messages.forEach { message -> + // uh oh, so these are the workarounds ?? message.recipients?.removeAll { it.profileId != message.profileId } message.recipients?.forEach { recipient -> if (recipient.fullName == null) { @@ -78,62 +84,62 @@ class MessagesListFragment : LazyFragment(), CoroutineScope { } } - // load & configure the adapter - val items = messages.toMutableList() - items.add(0, MessagesSearch().also { - it.searchText = searchText - }) + // show/hide relevant views + setSwipeToRefresh(messageType in Message.TYPE_RECEIVED..Message.TYPE_SENT) + b.progressBar.isVisible = false + b.list.isVisible = messages.isNotEmpty() + b.noData.isVisible = messages.isEmpty() + if (messages.isEmpty()) { + return@Observer + } - adapter?.items = items - adapter?.allItems = items - - if (items.isNotNullNorEmpty() && b.list.adapter == null) { - if (searchText.isNotBlank()) - adapter?.filter?.filter(searchText) { - b.list.adapter = adapter - } - else - b.list.adapter = adapter + if (adapter.allItems.isEmpty()) { + // items empty - add the search field + adapter.allItems += MessagesSearch().also { + it.searchText = searchText ?: "" + } + } else { + // items not empty - remove all messages + adapter.allItems.removeAll { it is MessageFull } + } + // add all messages + adapter.allItems.addAll(messages) + // configure the adapter & recycler view + if (b.list.adapter == null) { b.list.apply { setHasFixedSize(true) layoutManager = LinearLayoutManager(context) addItemDecoration(SimpleDividerItemDecoration(context)) if (messageType in Message.TYPE_RECEIVED..Message.TYPE_SENT) addOnScrollListener(onScrollListener) + this.adapter = this@MessagesListFragment.adapter } } - setSwipeToRefresh(messageType in Message.TYPE_RECEIVED..Message.TYPE_SENT && items.isNullOrEmpty()) + val layoutManager = (b.list.layoutManager as? LinearLayoutManager) ?: return@Observer - (b.list.layoutManager as? LinearLayoutManager)?.let { layoutManager -> - if (topPosition != NO_POSITION && topPosition > layoutManager.findLastCompletelyVisibleItemPosition()) { - b.list.scrollToPosition(topPosition) - } else if (bottomPosition != NO_POSITION && bottomPosition < layoutManager.findFirstVisibleItemPosition()) { - b.list.scrollToPosition(bottomPosition) - } - topPosition = NO_POSITION - bottomPosition = NO_POSITION - } + // reapply the filter + val searchItem = adapter.items.firstOrNull { it is MessagesSearch } as? MessagesSearch + adapter.filter.filter(searchText ?: searchItem?.searchText, null) - // show/hide relevant views - b.progressBar.isVisible = false - if (items.isNullOrEmpty()) { - b.list.isVisible = false - b.noData.isVisible = true - } else { - b.list.isVisible = true - b.noData.isVisible = false + // restore the previously saved scroll position + if (topPosition != NO_POSITION && topPosition > layoutManager.findLastCompletelyVisibleItemPosition()) { + b.list.scrollToPosition(topPosition) + } else if (bottomPosition != NO_POSITION && bottomPosition < layoutManager.findFirstVisibleItemPosition()) { + b.list.scrollToPosition(bottomPosition) } + topPosition = NO_POSITION + bottomPosition = NO_POSITION }) }; return true } override fun onDestroy() { super.onDestroy() - if (!isAdded) + if (!isAdded || !this::adapter.isInitialized) return val layoutManager = (b.list.layoutManager as? LinearLayoutManager) - val searchItem = adapter?.items?.firstOrNull { it is MessagesSearch } as? MessagesSearch + val searchItem = adapter.items.firstOrNull { it is MessagesSearch } as? MessagesSearch onPageDestroy?.invoke(position, Bundle( "topPosition" to layoutManager?.findFirstVisibleItemPosition(), diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeFragment.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeFragment.kt index 25599bb5..305a2ac9 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeFragment.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeFragment.kt @@ -13,11 +13,9 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.AutoCompleteTextView -import androidx.core.text.HtmlCompat import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment -import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.hootsuite.nachos.chip.ChipInfo import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial @@ -27,6 +25,7 @@ import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import pl.szczodrzynski.edziennik.* import pl.szczodrzynski.edziennik.data.api.ERROR_MESSAGE_NOT_SENT +import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_MOBIDZIENNIK import pl.szczodrzynski.edziennik.data.api.edziennik.EdziennikTask import pl.szczodrzynski.edziennik.data.api.events.MessageSentEvent import pl.szczodrzynski.edziennik.data.api.events.RecipientListGetEvent @@ -39,16 +38,12 @@ import pl.szczodrzynski.edziennik.ui.dialogs.MessagesConfigDialog import pl.szczodrzynski.edziennik.ui.modules.messages.MessagesUtils import pl.szczodrzynski.edziennik.ui.modules.messages.MessagesUtils.getProfileImage import pl.szczodrzynski.edziennik.utils.Themes -import pl.szczodrzynski.edziennik.utils.html.BetterHtml +import pl.szczodrzynski.edziennik.utils.managers.TextStylingManager.StylingConfig import pl.szczodrzynski.edziennik.utils.models.Date import pl.szczodrzynski.edziennik.utils.models.Time -import pl.szczodrzynski.edziennik.utils.span.BoldSpan -import pl.szczodrzynski.edziennik.utils.span.ItalicSpan -import pl.szczodrzynski.edziennik.utils.span.SubscriptSizeSpan -import pl.szczodrzynski.edziennik.utils.span.SuperscriptSizeSpan +import pl.szczodrzynski.edziennik.utils.span.* import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetPrimaryItem import kotlin.coroutines.CoroutineContext -import kotlin.text.replace class MessagesComposeFragment : Fragment(), CoroutineScope { companion object { @@ -63,14 +58,17 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { override val coroutineContext: CoroutineContext get() = job + Dispatchers.Main +// private val manager +// get() = app.messageManager + private val textStylingManager + get() = app.textStylingManager private val profileConfig by lazy { app.config.forProfile().ui } private val greetingText get() = profileConfig.messagesGreetingText ?: "\n\nZ poważaniem\n${app.profile.accountOwnerName}" private var teachers = mutableListOf() - private var watchFormatChecked = true - private var watchSelectionChanged = true + private lateinit var stylingConfig: StylingConfig private val enableTextStyling get() = app.profile.loginStoreType != LoginStore.LOGIN_TYPE_VULCAN @@ -101,13 +99,6 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { // do your job } - if (App.devMode) { - b.textHtml.isVisible = true - b.text.addTextChangedListener { - b.textHtml.text = getHtmlText() - } - } - activity.bottomSheet.prependItem( BottomSheetPrimaryItem(true) .withTitle(R.string.menu_messages_config) @@ -126,48 +117,11 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { } } - private fun getHtmlText(): String { - val text = b.text.text ?: return "" - - // apparently setting the spans to a different Spannable calls the original EditText's - // onSelectionChanged with selectionStart=-1, which in effect unchecks the format toggles - watchSelectionChanged = false - var textHtml = if (enableTextStyling) { - val spanned = SpannableString(text) - // remove zero-length spans, as they seem to affect - // the whole line when converted to HTML - spanned.getSpans(0, spanned.length, Any::class.java).forEach { - val spanStart = spanned.getSpanStart(it) - val spanEnd = spanned.getSpanEnd(it) - if (spanStart == spanEnd && it::class.java in BetterHtml.customSpanClasses) - spanned.removeSpan(it) - } - HtmlCompat.toHtml(spanned, HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL) - .replace("\n", "") - .replace(" dir=\"ltr\"", "") - .replace("", "") - .replace("", "") - .replace("", "") - .replace("", "") - .replace("", "") - .replace("p style=\"margin-top:0; margin-bottom:0;\"", "p") - } else { - text.toString() - } - watchSelectionChanged = true - - if (app.profile.loginStoreType == LoginStore.LOGIN_TYPE_MOBIDZIENNIK) { - textHtml = textHtml - .replace("
", "

 

") - .replace("", "") - .replace("", "") - .replace("", "") - .replace("", "") - .replace("", "") - .replace("", "") - } - - return textHtml + private fun getMessageBody(): String { + return if (enableTextStyling) + textStylingManager.getHtmlText(stylingConfig) + else + b.text.text?.toString() ?: "" } private fun getRecipientList() { @@ -185,80 +139,6 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { } } - @Suppress("UNUSED_PARAMETER") - private fun onFormatChecked( - group: MaterialButtonToggleGroup, - checkedId: Int, - isChecked: Boolean - ) { - if (!watchFormatChecked) - return - val span = when (checkedId) { - R.id.fontStyleBold -> BoldSpan() - R.id.fontStyleItalic -> ItalicSpan() - R.id.fontStyleUnderline -> UnderlineSpan() - R.id.fontStyleStrike -> StrikethroughSpan() - R.id.fontStyleSubscript -> SubscriptSizeSpan(10, dip = true) - R.id.fontStyleSuperscript -> SuperscriptSizeSpan(10, dip = true) - else -> return - } - - // see comments in getHtmlText() - watchSelectionChanged = false - if (isChecked) - BetterHtml.applyFormat(span = span, editText = b.text) - else - BetterHtml.removeFormat(span = span, editText = b.text) - watchSelectionChanged = true - - if (App.devMode) - b.textHtml.text = getHtmlText() - } - - @Suppress("UNUSED_PARAMETER") - private fun onFormatClear(view: View) { - // shortened version on onFormatChecked(), removing all spans - watchSelectionChanged = false - BetterHtml.removeFormat(span = null, editText = b.text) - watchSelectionChanged = true - if (App.devMode) - b.textHtml.text = getHtmlText() - // force update of text style toggle states - onSelectionChanged(b.text.selectionStart, b.text.selectionEnd) - } - - private fun onSelectionChanged(selectionStart: Int, selectionEnd: Int) { - if (!watchSelectionChanged) - return - val spanned = b.text.text ?: return - val spans = spanned.getSpans(selectionStart, selectionEnd, Any::class.java).mapNotNull { - if (it::class.java !in BetterHtml.customSpanClasses) - return@mapNotNull null - val spanStart = spanned.getSpanStart(it) - val spanEnd = spanned.getSpanEnd(it) - // remove 0-length spans after navigating out of them - if (spanStart == spanEnd) - spanned.removeSpan(it) - else if (spanned.getSpanFlags(it) hasSet SPAN_EXCLUSIVE_EXCLUSIVE) - spanned.setSpan(it, spanStart, spanEnd, SPAN_EXCLUSIVE_INCLUSIVE) - - // names are helpful here - val isNotAfterWord = selectionEnd <= spanEnd - val isSelectionInWord = selectionStart != selectionEnd && selectionStart >= spanStart - val isCursorInWord = selectionStart == selectionEnd && selectionStart > spanStart - val isChecked = (isCursorInWord || isSelectionInWord) && isNotAfterWord - if (isChecked) it::class.java else null - } - watchFormatChecked = false - b.fontStyleBold.isChecked = BoldSpan::class.java in spans - b.fontStyleItalic.isChecked = ItalicSpan::class.java in spans - b.fontStyleUnderline.isChecked = UnderlineSpan::class.java in spans - b.fontStyleStrike.isChecked = StrikethroughSpan::class.java in spans - b.fontStyleSubscript.isChecked = SubscriptSizeSpan::class.java in spans - b.fontStyleSuperscript.isChecked = SuperscriptSizeSpan::class.java in spans - watchFormatChecked = true - } - private fun createView() { b.recipientsLayout.setBoxCornerRadii(0f, 0f, 0f, 0f) b.subjectLayout.setBoxCornerRadii(0f, 0f, 0f, 0f) @@ -318,54 +198,66 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { b.subjectLayout.isEnabled = false b.textLayout.isEnabled = false + val styles = listOf( + StylingConfig.Style( + button = b.fontStyleBold, + spanClass = BoldSpan::class.java, + icon = CommunityMaterial.Icon2.cmd_format_bold, + hint = R.string.hint_style_bold, + ), + StylingConfig.Style( + button = b.fontStyleItalic, + spanClass = ItalicSpan::class.java, + icon = CommunityMaterial.Icon2.cmd_format_italic, + hint = R.string.hint_style_italic, + ), + StylingConfig.Style( + button = b.fontStyleUnderline, + // a custom span is used to prevent issues with keyboards which underline words + spanClass = UnderlineCustomSpan::class.java, + icon = CommunityMaterial.Icon2.cmd_format_underline, + hint = R.string.hint_style_underline, + ), + StylingConfig.Style( + button = b.fontStyleStrike, + spanClass = StrikethroughSpan::class.java, + icon = CommunityMaterial.Icon2.cmd_format_strikethrough, + hint = R.string.hint_style_strike, + ), + StylingConfig.Style( + button = b.fontStyleSubscript, + spanClass = SubscriptSizeSpan::class.java, + icon = CommunityMaterial.Icon2.cmd_format_subscript, + hint = R.string.hint_style_subscript, + ), + StylingConfig.Style( + button = b.fontStyleSuperscript, + spanClass = SuperscriptSizeSpan::class.java, + icon = CommunityMaterial.Icon2.cmd_format_superscript, + hint = R.string.hint_style_superscript, + ), + ) + + stylingConfig = StylingConfig( + editText = b.text, + fontStyleGroup = b.fontStyle, + fontStyleClear = b.fontStyleClear, + styles = styles, + textHtml = if (App.devMode) b.textHtml else null, + htmlCompatibleMode = app.profile.loginStoreType == LOGIN_TYPE_MOBIDZIENNIK, + ) + b.fontStyleLayout.isVisible = enableTextStyling - b.fontStyleBold.isEnabled = false - b.fontStyleItalic.isEnabled = false - b.fontStyleUnderline.isEnabled = false - b.fontStyleStrike.isEnabled = false - b.fontStyleSubscript.isEnabled = false - b.fontStyleSuperscript.isEnabled = false - b.fontStyleClear.isEnabled = false - b.text.setOnFocusChangeListener { _, hasFocus -> - b.fontStyleBold.isEnabled = hasFocus - b.fontStyleItalic.isEnabled = hasFocus - b.fontStyleUnderline.isEnabled = hasFocus - b.fontStyleStrike.isEnabled = hasFocus - b.fontStyleSubscript.isEnabled = hasFocus - b.fontStyleSuperscript.isEnabled = hasFocus - b.fontStyleClear.isEnabled = hasFocus + if (enableTextStyling) { + textStylingManager.attach(stylingConfig) } - b.fontStyleBold.text = CommunityMaterial.Icon2.cmd_format_bold.character.toString() - b.fontStyleItalic.text = CommunityMaterial.Icon2.cmd_format_italic.character.toString() - b.fontStyleUnderline.text = CommunityMaterial.Icon2.cmd_format_underline.character.toString() - b.fontStyleStrike.text = CommunityMaterial.Icon2.cmd_format_strikethrough.character.toString() - b.fontStyleSubscript.text = CommunityMaterial.Icon2.cmd_format_subscript.character.toString() - b.fontStyleSuperscript.text = CommunityMaterial.Icon2.cmd_format_superscript.character.toString() - b.fontStyleBold.attachToastHint(R.string.hint_style_bold) - b.fontStyleItalic.attachToastHint(R.string.hint_style_italic) - b.fontStyleUnderline.attachToastHint(R.string.hint_style_underline) - b.fontStyleStrike.attachToastHint(R.string.hint_style_strike) - b.fontStyleSubscript.attachToastHint(R.string.hint_style_subscript) - b.fontStyleSuperscript.attachToastHint(R.string.hint_style_superscript) - - /*b.fontStyleBold.shapeAppearanceModel = b.fontStyleBold.shapeAppearanceModel - .toBuilder() - .setBottomLeftCornerSize(0f) - .build() - b.fontStyleSuperscript.shapeAppearanceModel = b.fontStyleBold.shapeAppearanceModel - .toBuilder() - .setBottomRightCornerSize(0f) - .build() - b.fontStyleClear.shapeAppearanceModel = b.fontStyleClear.shapeAppearanceModel - .toBuilder() - .setTopLeftCornerSize(0f) - .setTopRightCornerSize(0f) - .build()*/ - - b.fontStyle.addOnButtonCheckedListener(this::onFormatChecked) - b.fontStyleClear.setOnClickListener(this::onFormatClear) - b.text.setSelectionChangedListener(this::onSelectionChanged) + if (App.devMode) { + b.textHtml.isVisible = true + b.text.addTextChangedListener { + b.textHtml.text = getMessageBody() + } + } activity.navView.bottomBar.apply { fabEnable = true @@ -533,7 +425,7 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { if (b.textLayout.counterMaxLength != -1 && b.text.length() > b.textLayout.counterMaxLength) return - val textHtml = getHtmlText() + val body = getMessageBody() activity.bottomSheet.hideKeyboard() @@ -541,7 +433,7 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { .setTitle(R.string.messages_compose_confirm_title) .setMessage(R.string.messages_compose_confirm_text) .setPositiveButton(R.string.send) { _, _ -> - EdziennikTask.messageSend(App.profileId, recipients, subject.trim(), textHtml).enqueue(activity) + EdziennikTask.messageSend(App.profileId, recipients, subject.trim(), body).enqueue(activity) } .setNegativeButton(R.string.cancel, null) .show() diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/utils/MessagesComparator.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/utils/MessagesComparator.kt index 2279b600..43497aa1 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/utils/MessagesComparator.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/utils/MessagesComparator.kt @@ -13,15 +13,16 @@ class MessagesComparator : Comparator { return 0 return when { - // standard sorting + // descending sorting (1. true, 2. false) + o1.isStarred && !o2.isStarred -> -1 + !o1.isStarred && o2.isStarred -> 1 + // ascending sorting o1.filterWeight > o2.filterWeight -> 1 o1.filterWeight < o2.filterWeight -> -1 - else -> when { - // reversed sorting - o1.addedDate > o2.addedDate -> -1 - o1.addedDate < o2.addedDate -> 1 - else -> 0 - } + // descending sorting + o1.addedDate > o2.addedDate -> -1 + o1.addedDate < o2.addedDate -> 1 + else -> 0 } } } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/viewholder/MessageViewHolder.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/viewholder/MessageViewHolder.kt index a466d08b..f59bee59 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/viewholder/MessageViewHolder.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/viewholder/MessageViewHolder.kt @@ -55,6 +55,11 @@ class MessageViewHolder( b.messageDate.setTextAppearance(activity, style) b.messageDate.typeface = typeface + if (adapter.onStarClick == null) { + b.messageStar.isVisible = false + } + b.messageStar.detachToastHint() + val messageInfo = MessagesUtils.getMessageInfo(app, item, 48, 24, 18, 12) b.messageProfileBackground.setImageBitmap(messageInfo.profileImage) b.messageSender.text = messageInfo.profileName @@ -75,5 +80,11 @@ class MessageViewHolder( adapter.onItemClick?.let { listener -> b.root.onClick { listener(item) } } + adapter.onStarClick?.let { listener -> + b.messageStar.isVisible = true + adapter.manager.setStarIcon(b.messageStar, item) + b.messageStar.onClick { listener(item) } + b.messageStar.attachToastHint(R.string.hint_message_star) + } } } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/html/BetterHtml.kt b/app/src/main/java/pl/szczodrzynski/edziennik/utils/html/BetterHtml.kt index 90119e55..4cf45486 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/utils/html/BetterHtml.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/html/BetterHtml.kt @@ -26,7 +26,7 @@ object BetterHtml { val customSpanClasses = listOf( BoldSpan::class.java, ItalicSpan::class.java, - UnderlineSpan::class.java, + UnderlineCustomSpan::class.java, StrikethroughSpan::class.java, SubscriptSizeSpan::class.java, SuperscriptSizeSpan::class.java, @@ -101,8 +101,9 @@ object BetterHtml { Typeface.ITALIC -> ItalicSpan() else -> null } - is SubscriptSpan -> SubscriptSizeSpan(size = 10, dip = true) - is SuperscriptSpan -> SuperscriptSizeSpan(size = 10, dip = true) + is UnderlineSpan -> UnderlineCustomSpan() + is SubscriptSpan -> SubscriptSizeSpan() + is SuperscriptSpan -> SuperscriptSizeSpan() else -> null } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/managers/MessageManager.kt b/app/src/main/java/pl/szczodrzynski/edziennik/utils/managers/MessageManager.kt new file mode 100644 index 00000000..5c8fd604 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/managers/MessageManager.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) Kuba Szczodrzyński 2021-10-7. + */ + +package pl.szczodrzynski.edziennik.utils.managers + +import android.os.Bundle +import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial +import com.mikepenz.iconics.utils.colorRes +import com.mikepenz.iconics.view.IconicsImageView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import pl.szczodrzynski.edziennik.App +import pl.szczodrzynski.edziennik.R +import pl.szczodrzynski.edziennik.data.db.entity.Message +import pl.szczodrzynski.edziennik.data.db.full.MessageFull +import pl.szczodrzynski.navlib.colorAttr + +class MessageManager(private val app: App) { + + suspend fun getMessage(profileId: Int, args: Bundle?): MessageFull? { + val id = args?.getLong("messageId") ?: return null + val json = args.getString("message") + val addedDate = args.getLong("sentDate") + return getMessage(profileId, id, json, addedDate) + } + + suspend fun getMessage( + profileId: Int, + id: Long, + json: String?, + sentDate: Long = 0L + ): MessageFull? { + val message = if (json != null) { + app.gson.fromJson(json, MessageFull::class.java)?.also { + if (sentDate > 0L) { + it.addedDate = sentDate + } + } + } else { + withContext(Dispatchers.IO) { + app.db.messageDao().getByIdNow(profileId, id) + } + } ?: return null + + // make recipients ID-unique + // this helps when multiple profiles receive the same message + // (there are multiple -1 recipients for the same message ID) + val recipientsDistinct = message.recipients?.distinctBy { it.id } ?: return null + message.recipients?.clear() + message.recipients?.addAll(recipientsDistinct) + + // load recipients for sent messages + val teachers = withContext(Dispatchers.IO) { + app.db.teacherDao().getAllNow(profileId) + } + + message.recipients?.forEach { recipient -> + // store the account name as a recipient + if (recipient.id == -1L) + recipient.fullName = app.profile.accountName ?: app.profile.studentNameLong + + // lookup a teacher by the recipient ID + if (recipient.fullName == null) + recipient.fullName = teachers.firstOrNull { it.id == recipient.id }?.fullName ?: "" + + // unset the readByEveryone flag + if (recipient.readDate < 1 && message.type == Message.TYPE_SENT) + message.readByEveryone = false + } + + // store the account name as sender for sent messages + if (message.type == Message.TYPE_SENT && message.senderName == null) { + message.senderName = app.profile.accountName ?: app.profile.studentNameLong + } + + // set the message as seen + if (message.body != null && !message.seen) { + app.db.metadataDao().setSeen(profileId, message, true) + } + //msg.recipients = app.db.messageRecipientDao().getAllByMessageId(msg.profileId, msg.id) + + return message + } + + fun setStarIcon(image: IconicsImageView, message: Message) { + if (message.isStarred) { + image.icon?.colorRes = R.color.md_amber_500 + image.icon?.icon = CommunityMaterial.Icon3.cmd_star + } else { + image.icon?.colorAttr(image.context, android.R.attr.textColorSecondary) + image.icon?.icon = CommunityMaterial.Icon3.cmd_star_outline + } + } + + suspend fun starMessage(message: Message, isStarred: Boolean) { + message.isStarred = isStarred + withContext(Dispatchers.Default) { + app.db.messageDao().replace(message) + } + } + + suspend fun markAsDeleted(message: Message) { + message.type = Message.TYPE_DELETED + withContext(Dispatchers.Default) { + app.db.messageDao().replace(message) + } + } +} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/managers/TextStylingManager.kt b/app/src/main/java/pl/szczodrzynski/edziennik/utils/managers/TextStylingManager.kt new file mode 100644 index 00000000..bd4ce3a6 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/managers/TextStylingManager.kt @@ -0,0 +1,195 @@ +/* + * Copyright (c) Kuba Szczodrzyński 2021-10-7. + */ + +package pl.szczodrzynski.edziennik.utils.managers + +import android.text.Spanned +import android.widget.Button +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.text.HtmlCompat +import com.google.android.material.button.MaterialButton +import com.google.android.material.button.MaterialButtonToggleGroup +import com.mikepenz.iconics.typeface.IIcon +import pl.szczodrzynski.edziennik.App +import pl.szczodrzynski.edziennik.attachToastHint +import pl.szczodrzynski.edziennik.hasSet +import pl.szczodrzynski.edziennik.utils.TextInputKeyboardEdit +import pl.szczodrzynski.edziennik.utils.html.BetterHtml + +class TextStylingManager(private val app: App) { + companion object { + private const val TAG = "TextStylingManager" + } + + data class StylingConfig( + val editText: TextInputKeyboardEdit, + val fontStyleGroup: MaterialButtonToggleGroup, + val fontStyleClear: Button, + val styles: List