[Messages] Add saving messages as draft. (#92)

* [Messages/Compose] Move original message handling code to MessageManager.

* [Messages/Compose] Add draft saving dialog on back button press.

* [Messages/Compose] Implement saving messages as draft.

* [Messages/Compose] Fix missing line breaks when saving/loading HTML.

* [Messages] Fix download button icon padding.

* [Messages] Fix showing correct message read date.

* [Messages] Improve (and fix) scrolling to previous list position.

* [Messages] Fix message body trimming.

* [Messages/Compose] Add draft-related bottom sheet items.

* [Refactor] Cleanup MainActivity code.

* [Messages/Compose] Set htmlCompatible to true by default.

* [Messages/Compose] Show confirmation dialog when navigating with unsaved changes.

* [Messages] Restore message body bottom padding.

* [Messages] Fix download button icon padding, again.
This commit is contained in:
Kuba Szczodrzyński 2021-10-09 22:48:41 +02:00 committed by GitHub
parent 44263ac95f
commit 50ae767fcd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 617 additions and 226 deletions

View File

@ -1,6 +1,7 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JetCodeStyleSettings>
<option name="ALLOW_TRAILING_COMMA" value="true" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">

View File

@ -738,6 +738,8 @@ fun Bundle(vararg properties: Pair<String, Any?>): Bundle {
is Short -> putShort(property.first, property.second as Short)
is Double -> putDouble(property.first, property.second as Double)
is Boolean -> putBoolean(property.first, property.second as Boolean)
is Bundle -> putBundle(property.first, property.second as Bundle)
is Parcelable -> putParcelable(property.first, property.second as Parcelable)
is Array<*> -> putParcelableArray(property.first, property.second as Array<out Parcelable>)
}
}

View File

@ -17,7 +17,6 @@ import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.ColorUtils
import androidx.core.view.isVisible
import androidx.lifecycle.Observer
import androidx.navigation.NavOptions
import com.danimahardhika.cafebar.CafeBar
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@ -30,20 +29,17 @@ import com.mikepenz.materialdrawer.model.DividerDrawerItem
import com.mikepenz.materialdrawer.model.ProfileDrawerItem
import com.mikepenz.materialdrawer.model.ProfileSettingDrawerItem
import com.mikepenz.materialdrawer.model.interfaces.*
import com.mikepenz.materialdrawer.model.utils.withIsHiddenInMiniDrawer
import com.mikepenz.materialdrawer.model.utils.hiddenInMiniDrawer
import eu.szkolny.font.SzkolnyFont
import kotlinx.coroutines.*
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import pl.droidsonroids.gif.GifDrawable
import pl.szczodrzynski.edziennik.data.api.ERROR_API_INVALID_SIGNATURE
import pl.szczodrzynski.edziennik.data.api.ERROR_VULCAN_API_DEPRECATED
import pl.szczodrzynski.edziennik.data.api.edziennik.EdziennikTask
import pl.szczodrzynski.edziennik.data.api.events.*
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.api.szkolny.SzkolnyApi
import pl.szczodrzynski.edziennik.data.api.szkolny.response.RegisterAvailabilityStatus
import pl.szczodrzynski.edziennik.data.api.szkolny.response.Update
import pl.szczodrzynski.edziennik.data.db.entity.LoginStore
import pl.szczodrzynski.edziennik.data.db.entity.Metadata.*
@ -85,7 +81,6 @@ import pl.szczodrzynski.edziennik.ui.modules.webpush.WebPushFragment
import pl.szczodrzynski.edziennik.utils.*
import pl.szczodrzynski.edziennik.utils.Utils.d
import pl.szczodrzynski.edziennik.utils.Utils.dpToPx
import pl.szczodrzynski.edziennik.utils.managers.AvailabilityManager
import pl.szczodrzynski.edziennik.utils.managers.AvailabilityManager.Error.Type
import pl.szczodrzynski.edziennik.utils.models.Date
import pl.szczodrzynski.edziennik.utils.models.NavTarget
@ -102,15 +97,13 @@ import kotlin.coroutines.CoroutineContext
import kotlin.math.roundToInt
class MainActivity : AppCompatActivity(), CoroutineScope {
@Suppress("MemberVisibilityCanBePrivate")
companion object {
var useOldMessages = false
const val TAG = "MainActivity"
const val DRAWER_PROFILE_ADD_NEW = 200
const val DRAWER_PROFILE_SYNC_ALL = 201
const val DRAWER_PROFILE_EXPORT_DATA = 202
const val DRAWER_PROFILE_MANAGE = 203
const val DRAWER_PROFILE_MARK_ALL_AS_READ = 204
const val DRAWER_ITEM_HOME = 1
@ -255,6 +248,10 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
val swipeRefreshLayout: SwipeRefreshLayoutNoTouch by lazy { b.swipeRefreshLayout }
var onBeforeNavigate: (() -> Boolean)? = null
var pausedNavigationData: PausedNavigationData? = null
private set
val app: App by lazy {
applicationContext as App
}
@ -327,6 +324,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
window.statusBarColor = statusBarColor
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && ColorUtils.calculateLuminance(statusBarColor) > 0.6) {
@Suppress("deprecation")
window.decorView.systemUiVisibility = window.decorView.systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
}
@ -370,13 +368,12 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
drawerProfileListEmptyListener = {
onProfileListEmptyEvent(ProfileListEmptyEvent())
}
drawerItemSelectedListener = { id, position, drawerItem ->
drawerItemSelectedListener = { id, _, _ ->
loadTarget(id)
true
}
drawerProfileSelectedListener = { id, profile, _, _ ->
loadProfile(id)
false
drawerProfileSelectedListener = { id, _, _, _ ->
// why is this negated -_-
!loadProfile(id)
}
drawerProfileLongClickListener = { _, profile, _, view ->
if (view != null && profile is ProfileDrawerItem) {
@ -408,7 +405,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
savedInstanceState.clear()
}
app.db.profileDao().all.observe(this, Observer { profiles ->
app.db.profileDao().all.observe(this) { profiles ->
val allArchived = profiles.all { it.archived }
drawer.setProfileList(profiles.filter { it.id >= 0 && (!it.archived || allArchived) }.toMutableList())
//prepend the archived profile if loaded
@ -424,18 +421,18 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
})
}
drawer.currentProfile = App.profileId
})
}
setDrawerItems()
handleIntent(intent?.extras)
app.db.metadataDao().unreadCounts.observe(this, Observer { unreadCounters ->
app.db.metadataDao().unreadCounts.observe(this) { unreadCounters ->
unreadCounters.map {
it.type = it.thingType
}
drawer.setUnreadCounterList(unreadCounters)
})
}
b.swipeRefreshLayout.isEnabled = true
b.swipeRefreshLayout.setOnRefreshListener { launch { syncCurrentFeature() } }
@ -543,29 +540,29 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
BottomSheetPrimaryItem(false)
.withTitle(R.string.menu_sync)
.withIcon(CommunityMaterial.Icon.cmd_download_outline)
.withOnClickListener(View.OnClickListener {
.withOnClickListener {
bottomSheet.close()
SyncViewListDialog(this, navTargetId)
}),
},
BottomSheetSeparatorItem(false),
BottomSheetPrimaryItem(false)
.withTitle(R.string.menu_settings)
.withIcon(CommunityMaterial.Icon.cmd_cog_outline)
.withOnClickListener(View.OnClickListener { loadTarget(DRAWER_ITEM_SETTINGS) }),
.withOnClickListener { loadTarget(DRAWER_ITEM_SETTINGS) },
BottomSheetPrimaryItem(false)
.withTitle(R.string.menu_feedback)
.withIcon(CommunityMaterial.Icon2.cmd_help_circle_outline)
.withOnClickListener(View.OnClickListener { loadTarget(TARGET_FEEDBACK) })
.withOnClickListener { loadTarget(TARGET_FEEDBACK) }
)
if (App.devMode) {
bottomSheet += BottomSheetPrimaryItem(false)
.withTitle(R.string.menu_debug)
.withIcon(CommunityMaterial.Icon.cmd_android_debug_bridge)
.withOnClickListener(View.OnClickListener { loadTarget(DRAWER_ITEM_DEBUG) })
.withOnClickListener { loadTarget(DRAWER_ITEM_DEBUG) }
}
}
private var profileSettingClickListener = { id: Int, view: View? ->
private var profileSettingClickListener = { id: Int, _: View? ->
when (id) {
DRAWER_PROFILE_ADD_NEW -> {
requestHandler.requestLogin()
@ -599,7 +596,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
|_____/ \__, |_| |_|\___|
__/ |
|__*/
suspend fun syncCurrentFeature() {
private suspend fun syncCurrentFeature() {
if (app.profile.archived) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.profile_archived_title)
@ -756,7 +753,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.app_manager_dialog_title)
.setMessage(R.string.app_manager_dialog_text)
.setPositiveButton(R.string.ok) { dialog, which ->
.setPositiveButton(R.string.ok) { _, _ ->
try {
for (intent in appManagerIntentList) {
if (packageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY) != null) {
@ -772,7 +769,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
}
}
}
.setNeutralButton(R.string.dont_ask_again) { dialog, which ->
.setNeutralButton(R.string.dont_ask_again) { _, _ ->
app.config.sync.dontShowAppManagerDialog = true
}
.setCancelable(false)
@ -967,6 +964,8 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
super.onNewIntent(intent)
handleIntent(intent?.extras)
}
@Suppress("deprecation")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
requestHandler.handleResult(requestCode, resultCode, data)
@ -985,31 +984,84 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
.setPopExitAnim(R.anim.task_close_exit) // new fragment exit
.build()
private fun canNavigate(): Boolean = onBeforeNavigate?.invoke() != false
fun resumePausedNavigation(): Boolean {
if (pausedNavigationData == null)
return false
pausedNavigationData?.let { data ->
when (data) {
is PausedNavigationData.LoadProfile -> loadProfile(
id = data.id,
drawerSelection = data.drawerSelection,
arguments = data.arguments,
skipBeforeNavigate = true,
)
is PausedNavigationData.LoadTarget -> loadTarget(
id = data.id,
arguments = data.arguments,
skipBeforeNavigate = true,
)
else -> return false
}
}
pausedNavigationData = null
return true
}
fun loadProfile(id: Int) = loadProfile(id, navTargetId)
fun loadProfile(id: Int, arguments: Bundle?) = loadProfile(id, navTargetId, arguments)
fun loadProfile(profile: Profile) = loadProfile(
profile,
navTargetId,
null,
if (app.profile.archived) app.profile.id else null
)
private fun loadProfile(id: Int, drawerSelection: Int, arguments: Bundle? = null) {
// fun loadProfile(id: Int, arguments: Bundle?) = loadProfile(id, navTargetId, arguments)
fun loadProfile(profile: Profile): Boolean {
if (!canNavigate()) {
pausedNavigationData = PausedNavigationData.LoadProfile(
id = profile.id,
drawerSelection = navTargetId,
arguments = null,
)
return false
}
loadProfile(profile, navTargetId, null)
return true
}
private fun loadProfile(
id: Int,
drawerSelection: Int,
arguments: Bundle? = null,
skipBeforeNavigate: Boolean = false,
): Boolean {
if (!skipBeforeNavigate && !canNavigate()) {
drawer.close()
// restore the previous profile after changing it with the drawer
// well, it still does not change the toolbar profile image,
// but that's now NavView's problem, not mine.
drawer.currentProfile = app.profile.id
pausedNavigationData = PausedNavigationData.LoadProfile(
id = id,
drawerSelection = drawerSelection,
arguments = arguments,
)
return false
}
if (App.profileId == id) {
drawer.currentProfile = app.profile.id
loadTarget(drawerSelection, arguments)
return
// skipBeforeNavigate because it's checked above already
loadTarget(drawerSelection, arguments, skipBeforeNavigate = true)
return true
}
val previousArchivedId = if (app.profile.archived) app.profile.id else null
app.profileLoad(id) {
loadProfile(it, drawerSelection, arguments, previousArchivedId)
loadProfile(it, drawerSelection, arguments)
}
return true
}
private fun loadProfile(profile: Profile, drawerSelection: Int, arguments: Bundle?, previousArchivedId: Int?) {
private fun loadProfile(profile: Profile, drawerSelection: Int, arguments: Bundle?) {
App.profile = profile
MessagesFragment.pageSelection = -1
setDrawerItems()
val previousArchivedId = if (app.profile.archived) app.profile.id else null
if (previousArchivedId != null) {
// prevents accidentally removing the first item if the archived profile is not shown
drawer.removeProfileById(previousArchivedId)
@ -1030,26 +1082,44 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
// update it manually when switching profiles from other source
//if (drawer.currentProfile != app.profile.id)
drawer.currentProfile = app.profileId
loadTarget(drawerSelection, arguments)
loadTarget(drawerSelection, arguments, skipBeforeNavigate = true)
}
fun loadTarget(id: Int, arguments: Bundle? = null) {
fun loadTarget(
id: Int,
arguments: Bundle? = null,
skipBeforeNavigate: Boolean = false,
): Boolean {
var loadId = id
if (loadId == -1) {
loadId = DRAWER_ITEM_HOME
}
val target = navTargetList
.firstOrNull { it.id == loadId }
if (target == null) {
return if (target == null) {
Toast.makeText(this, getString(R.string.error_invalid_fragment, id), Toast.LENGTH_LONG).show()
loadTarget(navTargetList.first(), arguments)
}
else {
loadTarget(target, arguments)
loadTarget(navTargetList.first(), arguments, skipBeforeNavigate)
} else {
loadTarget(target, arguments, skipBeforeNavigate)
}
}
private fun loadTarget(target: NavTarget, args: Bundle? = null) {
private fun loadTarget(
target: NavTarget,
args: Bundle? = null,
skipBeforeNavigate: Boolean = false,
): Boolean {
d("NavDebug", "loadTarget(target = $target, args = $args)")
if (!skipBeforeNavigate && !canNavigate()) {
bottomSheet.close()
drawer.close()
pausedNavigationData = PausedNavigationData.LoadTarget(
id = target.id,
arguments = args,
)
return false
}
pausedNavigationData = null
val arguments = args ?: navBackStack.firstOrNull { it.first.id == target.id }?.second ?: Bundle()
bottomSheet.close()
bottomSheet.removeAllContextual()
@ -1064,7 +1134,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
d("NavDebug", "Navigating from ${navTarget.fragmentClass?.java?.simpleName} to ${target.fragmentClass?.java?.simpleName}")
val fragment = target.fragmentClass?.java?.newInstance() ?: return
val fragment = target.fragmentClass?.java?.newInstance() ?: return false
fragment.arguments = arguments
val transaction = fragmentManager.beginTransaction()
@ -1134,6 +1204,7 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
// TASK DESCRIPTION
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val bm = BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
@Suppress("deprecation")
val taskDesc = ActivityManager.TaskDescription(
if (target.id == HOME_ID) getString(R.string.app_name) else getString(R.string.app_task_format, getString(target.name)),
bm,
@ -1141,32 +1212,32 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
)
setTaskDescription(taskDesc)
}
return true
}
fun reloadTarget() = loadTarget(navTarget)
private fun popBackStack(): Boolean {
private fun popBackStack(skipBeforeNavigate: Boolean = false): Boolean {
if (navBackStack.size == 0) {
return false
}
// TODO back stack argument support
when {
navTarget.popToHome -> {
loadTarget(HOME_ID)
loadTarget(HOME_ID, skipBeforeNavigate = skipBeforeNavigate)
}
navTarget.popTo != null -> {
loadTarget(navTarget.popTo ?: HOME_ID)
loadTarget(navTarget.popTo ?: HOME_ID, skipBeforeNavigate = skipBeforeNavigate)
}
else -> {
navBackStack.last().let {
loadTarget(it.first, it.second)
loadTarget(it.first, it.second, skipBeforeNavigate = skipBeforeNavigate)
}
}
}
return true
}
fun navigateUp() {
if (!popBackStack()) {
fun navigateUp(skipBeforeNavigate: Boolean = false) {
if (!popBackStack(skipBeforeNavigate)) {
super.onBackPressed()
}
}
@ -1214,17 +1285,22 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
| | | | '__/ _` \ \ /\ / / _ \ '__| | | __/ _ \ '_ ` _ \/ __|
| |__| | | | (_| |\ V V / __/ | | | || __/ | | | | \__ \
|_____/|_| \__,_| \_/\_/ \___|_| |_|\__\___|_| |_| |_|__*/
@Suppress("UNUSED_PARAMETER")
private fun createDrawerItem(target: NavTarget, level: Int = 1): IDrawerItem<*> {
val item = DrawerPrimaryItem()
.withIdentifier(target.id.toLong())
.withName(target.name)
.withIsHiddenInMiniDrawer(!app.config.ui.miniMenuButtons.contains(target.id))
.also { if (target.description != null) it.withDescription(target.description!!) }
.also { if (target.icon != null) it.withIcon(target.icon!!) }
.also { if (target.title != null) it.withAppTitle(getString(target.title!!)) }
.also { if (target.badgeTypeId != null) it.withBadgeStyle(drawer.badgeStyle)}
.withSelectedBackgroundAnimated(false)
val item = DrawerPrimaryItem().apply {
identifier = target.id.toLong()
nameRes = target.name
hiddenInMiniDrawer = !app.config.ui.miniMenuButtons.contains(target.id)
if (target.description != null)
descriptionRes = target.description!!
if (target.icon != null)
withIcon(target.icon!!)
if (target.title != null)
appTitle = getString(target.title!!)
if (target.badgeTypeId != null)
badgeStyle = drawer.badgeStyle
isSelectedBackgroundAnimated = false
}
if (target.badgeTypeId != null)
drawer.addUnreadCounterType(target.badgeTypeId!!, target.id)
// TODO sub items
@ -1266,11 +1342,14 @@ class MainActivity : AppCompatActivity(), CoroutineScope {
}
if (target.isInProfileList) {
drawerProfiles += ProfileSettingDrawerItem()
.withIdentifier(target.id.toLong())
.withName(target.name)
.also { if (target.description != null) it.withDescription(target.description!!) }
.also { if (target.icon != null) it.withIcon(target.icon!!) }
drawerProfiles += ProfileSettingDrawerItem().apply {
identifier = target.id.toLong()
nameRes = target.name
if (target.description != null)
descriptionRes = target.description!!
if (target.icon != null)
withIcon(target.icon!!)
}
}
}

View File

@ -51,6 +51,9 @@ abstract class MessageDao : BaseDao<Message, MessageFull> {
@Query("DELETE FROM messages WHERE keep = 0")
abstract override fun removeNotKept()
@Query("DELETE FROM messages WHERE profileId = :profileId AND messageId = :messageId")
abstract fun delete(profileId: Int, messageId: Long)
// GET ALL - LIVE DATA
fun getAll(profileId: Int) =
getRaw("$QUERY WHERE messages.profileId = $profileId $ORDER_BY")

View File

@ -4,8 +4,6 @@
package pl.szczodrzynski.edziennik.data.db.dao;
import java.util.List;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
@ -14,6 +12,8 @@ import androidx.room.RawQuery;
import androidx.sqlite.db.SimpleSQLiteQuery;
import androidx.sqlite.db.SupportSQLiteQuery;
import java.util.List;
import pl.szczodrzynski.edziennik.data.db.entity.MessageRecipient;
import pl.szczodrzynski.edziennik.data.db.full.MessageRecipientFull;
@ -22,6 +22,9 @@ public abstract class MessageRecipientDao {
@Query("DELETE FROM messageRecipients WHERE profileId = :profileId")
public abstract void clear(int profileId);
@Query("DELETE FROM messageRecipients WHERE profileId = :profileId AND messageId = :messageId")
public abstract void clearFor(int profileId, long messageId);
@Insert(onConflict = OnConflictStrategy.REPLACE)
public abstract long add(MessageRecipient messageRecipient);

View File

@ -57,33 +57,41 @@ class MessagesFragment : Fragment(), CoroutineScope {
val args = arguments
val pagerAdapter = FragmentLazyPagerAdapter(
fragmentManager ?: return,
b.refreshLayout,
listOf(
MessagesListFragment().apply {
onPageDestroy = this@MessagesFragment.onPageDestroy
arguments = Bundle("messageType" to Message.TYPE_RECEIVED)
args?.getBundle("page0")?.let {
arguments?.putAll(it)
}
} to getString(R.string.messages_tab_received),
fragmentManager = parentFragmentManager,
swipeRefreshLayout = b.refreshLayout,
fragments = listOf(
MessagesListFragment().apply {
onPageDestroy = this@MessagesFragment.onPageDestroy
arguments = Bundle("messageType" to Message.TYPE_RECEIVED)
args?.getBundle("page0")?.let {
arguments?.putAll(it)
}
} to getString(R.string.messages_tab_received),
MessagesListFragment().apply {
onPageDestroy = this@MessagesFragment.onPageDestroy
arguments = Bundle("messageType" to Message.TYPE_SENT)
args?.getBundle("page1")?.let {
arguments?.putAll(it)
}
} to getString(R.string.messages_tab_sent),
MessagesListFragment().apply {
onPageDestroy = this@MessagesFragment.onPageDestroy
arguments = Bundle("messageType" to Message.TYPE_SENT)
args?.getBundle("page1")?.let {
arguments?.putAll(it)
}
} to getString(R.string.messages_tab_sent),
MessagesListFragment().apply {
onPageDestroy = this@MessagesFragment.onPageDestroy
arguments = Bundle("messageType" to Message.TYPE_DELETED)
args?.getBundle("page2")?.let {
arguments?.putAll(it)
}
} to getString(R.string.messages_tab_deleted)
)
MessagesListFragment().apply {
onPageDestroy = this@MessagesFragment.onPageDestroy
arguments = Bundle("messageType" to Message.TYPE_DELETED)
args?.getBundle("page2")?.let {
arguments?.putAll(it)
}
} to getString(R.string.messages_tab_deleted),
MessagesListFragment().apply {
onPageDestroy = this@MessagesFragment.onPageDestroy
arguments = Bundle("messageType" to Message.TYPE_DRAFT)
args?.getBundle("page3")?.let {
arguments?.putAll(it)
}
} to getString(R.string.messages_tab_draft),
),
)
b.viewPager.apply {
offscreenPageLimit = 1

View File

@ -11,9 +11,10 @@ import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView.NO_POSITION
import kotlinx.coroutines.*
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.MainActivity.Companion.TARGET_MESSAGES_COMPOSE
import pl.szczodrzynski.edziennik.MainActivity.Companion.TARGET_MESSAGES_DETAILS
import pl.szczodrzynski.edziennik.data.db.entity.Message
import pl.szczodrzynski.edziennik.data.db.entity.Teacher
import pl.szczodrzynski.edziennik.data.db.full.MessageFull
@ -52,8 +53,8 @@ class MessagesListFragment : LazyFragment(), CoroutineScope {
override fun onPageCreated(): Boolean { startCoroutineTimer(100L) {
val messageType = arguments.getInt("messageType", Message.TYPE_RECEIVED)
var topPosition = arguments.getInt("topPosition", NO_POSITION)
var bottomPosition = arguments.getInt("bottomPosition", NO_POSITION)
var recyclerViewState =
arguments?.getParcelable<LinearLayoutManager.SavedState>("recyclerViewState")
val searchText = arguments?.getString("searchText")
teachers = withContext(Dispatchers.Default) {
@ -61,9 +62,13 @@ class MessagesListFragment : LazyFragment(), CoroutineScope {
}
adapter = MessagesAdapter(activity, teachers, onItemClick = {
activity.loadTarget(MainActivity.TARGET_MESSAGES_DETAILS, Bundle(
"messageId" to it.id
))
val (target, args) =
if (it.type == Message.TYPE_DRAFT) {
TARGET_MESSAGES_COMPOSE to Bundle("message" to app.gson.toJson(it))
} else {
TARGET_MESSAGES_DETAILS to Bundle("messageId" to it.id)
}
activity.loadTarget(target, args)
}, onStarClick = {
this@MessagesListFragment.launch {
manager.starMessage(it, !it.isStarred)
@ -121,16 +126,13 @@ class MessagesListFragment : LazyFragment(), CoroutineScope {
// reapply the filter
val searchItem = adapter.items.firstOrNull { it is MessagesSearch } as? MessagesSearch
adapter.filter.filter(searchText ?: searchItem?.searchText, null)
// 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)
adapter.filter.filter(searchText ?: searchItem?.searchText) {
// restore the previously saved scroll position
recyclerViewState?.let {
layoutManager.onRestoreInstanceState(it)
}
recyclerViewState = null
}
topPosition = NO_POSITION
bottomPosition = NO_POSITION
})
}; return true }
@ -142,8 +144,7 @@ class MessagesListFragment : LazyFragment(), CoroutineScope {
val searchItem = adapter.items.firstOrNull { it is MessagesSearch } as? MessagesSearch
onPageDestroy?.invoke(position, Bundle(
"topPosition" to layoutManager?.findFirstVisibleItemPosition(),
"bottomPosition" to layoutManager?.findLastCompletelyVisibleItemPosition(),
"recyclerViewState" to layoutManager?.onSaveInstanceState(),
"searchText" to searchItem?.searchText?.toString()
))
}

View File

@ -13,17 +13,19 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.AutoCompleteTextView
import android.widget.ScrollView
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.hootsuite.nachos.chip.ChipInfo
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import kotlinx.coroutines.*
import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.MainActivity.Companion.DRAWER_ITEM_MESSAGES
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
@ -31,20 +33,20 @@ import pl.szczodrzynski.edziennik.data.api.events.MessageSentEvent
import pl.szczodrzynski.edziennik.data.api.events.RecipientListGetEvent
import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.db.entity.LoginStore
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.MessagesComposeFragmentBinding
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.ui.modules.messages.MessagesFragment
import pl.szczodrzynski.edziennik.utils.Themes
import pl.szczodrzynski.edziennik.utils.managers.MessageManager.UIConfig
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.*
import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetPrimaryItem
import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetSeparatorItem
import kotlin.coroutines.CoroutineContext
class MessagesComposeFragment : Fragment(), CoroutineScope {
companion object {
private const val TAG = "MessagesComposeFragment"
@ -58,19 +60,25 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
// private val manager
// get() = app.messageManager
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<Teacher>()
private val teachers = mutableListOf<Teacher>()
private lateinit var stylingConfig: StylingConfig
private lateinit var uiConfig: UIConfig
private val enableTextStyling
get() = app.profile.loginStoreType != LoginStore.LOGIN_TYPE_VULCAN
private var changedRecipients = false
private var changedSubject = false
private var changedBody = false
private var discardDraftItem: BottomSheetPrimaryItem? = null
private var draftMessageId: Long? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
activity = (getActivity() as MainActivity?) ?: return null
@ -99,7 +107,30 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
// do your job
}
activity.bottomSheet.prependItem(
discardDraftItem = BottomSheetPrimaryItem(true)
.withTitle(R.string.messages_compose_discard_draft)
.withIcon(CommunityMaterial.Icon3.cmd_text_box_remove_outline)
.withOnClickListener {
activity.bottomSheet.close()
discardDraftDialog()
}
activity.bottomSheet.prependItems(
BottomSheetPrimaryItem(true)
.withTitle(R.string.messages_compose_send_long)
.withIcon(CommunityMaterial.Icon3.cmd_send_outline)
.withOnClickListener {
activity.bottomSheet.close()
sendMessage()
},
BottomSheetPrimaryItem(true)
.withTitle(R.string.messages_compose_save_draft)
.withIcon(CommunityMaterial.Icon.cmd_content_save_edit_outline)
.withOnClickListener {
activity.bottomSheet.close()
saveDraft()
},
BottomSheetSeparatorItem(true),
BottomSheetPrimaryItem(true)
.withTitle(R.string.menu_messages_config)
.withIcon(CommunityMaterial.Icon.cmd_cog_outline)
@ -146,12 +177,15 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
b.recipients.addTextChangedListener(onTextChanged = { _, _, _, _ ->
b.recipientsLayout.error = null
changedRecipients = true
})
b.subject.addTextChangedListener(onTextChanged = { _, _, _, _ ->
b.subjectLayout.error = null
changedSubject = true
})
b.text.addTextChangedListener(onTextChanged = { _, _, _, _ ->
b.textLayout.error = null
changedBody = true
})
b.subjectLayout.counterMaxLength = when (app.profile.loginStoreType) {
@ -238,6 +272,17 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
),
)
uiConfig = UIConfig(
context = activity,
recipients = b.recipients,
subject = b.subject,
body = b.text,
teachers = teachers,
greetingOnCompose = profileConfig.messagesGreetingOnCompose,
greetingOnReply = profileConfig.messagesGreetingOnReply,
greetingOnForward = profileConfig.messagesGreetingOnForward,
greetingText = greetingText,
)
stylingConfig = StylingConfig(
editText = b.text,
fontStyleGroup = b.fontStyle,
@ -250,6 +295,9 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
b.fontStyleLayout.isVisible = enableTextStyling
if (enableTextStyling) {
textStylingManager.attach(stylingConfig)
b.fontStyle.addOnButtonCheckedListener { _, _, _ ->
changedBody = true
}
}
if (App.devMode) {
@ -272,10 +320,68 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
activity.gainAttentionFAB()
}
private fun onBeforeNavigate(): Boolean {
val messageText = b.text.text?.toString()?.trim() ?: ""
val greetingText = this.greetingText.trim()
// navigateUp if nothing changed
if ((!changedRecipients || b.recipients.allChips.isEmpty())
&& (!changedSubject || b.subject.text.isNullOrBlank())
&& (!changedBody || messageText.isEmpty() || messageText == greetingText)
)
return true
saveDraftDialog()
return false
}
private fun saveDraftDialog() {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.messages_compose_save_draft_title)
.setMessage(R.string.messages_compose_save_draft_text)
.setPositiveButton(R.string.save) { _, _ ->
saveDraft()
MessagesFragment.pageSelection = Message.TYPE_DRAFT
activity.loadTarget(DRAWER_ITEM_MESSAGES, skipBeforeNavigate = true)
}
.setNegativeButton(R.string.discard) { _, _ ->
activity.resumePausedNavigation()
}
.show()
}
private fun saveDraft() {
launch {
manager.saveAsDraft(uiConfig, stylingConfig, App.profileId, draftMessageId)
Toast.makeText(activity, R.string.messages_compose_draft_saved, Toast.LENGTH_SHORT).show()
changedRecipients = false
changedSubject = false
changedBody = false
}
if (discardDraftItem != null)
activity.bottomSheet.addItemAt(2, discardDraftItem!!)
discardDraftItem = null
}
private fun discardDraftDialog() {
MaterialAlertDialogBuilder(activity)
.setTitle(R.string.messages_compose_discard_draft_title)
.setMessage(R.string.messages_compose_discard_draft_text)
.setPositiveButton(R.string.remove) { _, _ ->
launch {
if (draftMessageId != null)
manager.deleteDraft(App.profileId, draftMessageId!!)
Toast.makeText(activity, R.string.messages_compose_draft_discarded, Toast.LENGTH_SHORT).show()
activity.navigateUp(skipBeforeNavigate = true)
}
}
.setNegativeButton(R.string.cancel, null)
.show()
}
@SuppressLint("SetTextI18n")
private fun updateRecipientList(list: List<Teacher>) { launch {
withContext(Dispatchers.Default) {
teachers = list.sortedBy { it.fullName }.toMutableList()
teachers.clear()
teachers.addAll(list.sortedBy { it.fullName })
Teacher.types.mapTo(teachers) {
Teacher(-1, -it.toLong(), Teacher.typeName(activity, it), "")
}
@ -291,93 +397,30 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
val adapter = MessagesComposeSuggestionAdapter(activity, teachers)
b.recipients.setAdapter(adapter)
if (profileConfig.messagesGreetingOnCompose)
b.text.setText(greetingText)
val message = manager.fillWithBundle(uiConfig, arguments)
if (message != null && message.type == Message.TYPE_DRAFT) {
draftMessageId = message.id
if (discardDraftItem != null)
activity.bottomSheet.addItemAt(2, discardDraftItem!!)
discardDraftItem = null
}
handleReplyMessage()
handleMailToIntent()
when {
b.recipients.text.isBlank() -> b.recipients.requestFocus()
b.subject.text.isNullOrBlank() -> b.subject.requestFocus()
else -> b.text.requestFocus()
}
if (!enableTextStyling)
b.text.setText(b.text.text?.toString())
b.text.setSelection(0)
(b.root as? ScrollView)?.smoothScrollTo(0, 0)
changedRecipients = false
changedSubject = false
changedBody = false
}}
private fun handleReplyMessage() = launch {
val replyMessage = arguments?.getString("message")
if (replyMessage != null) {
val chipList = mutableListOf<ChipInfo>()
var subject = ""
val span = SpannableStringBuilder()
var body: CharSequence = ""
withContext(Dispatchers.Default) {
val msg = app.gson.fromJson(replyMessage, MessageFull::class.java)
val dateString = getString(R.string.messages_date_time_format, Date.fromMillis(msg.addedDate).formattedStringShort, Time.fromMillis(msg.addedDate).stringHM)
// add original message info
span.appendText("W dniu ")
span.appendSpan(dateString, ItalicSpan(), SPAN_EXCLUSIVE_EXCLUSIVE)
span.appendText(", ")
span.appendSpan(msg.senderName.fixName(), ItalicSpan(), SPAN_EXCLUSIVE_EXCLUSIVE)
span.appendText(" napisał(a):")
span.setSpan(BoldSpan(), 0, span.length, SPAN_EXCLUSIVE_EXCLUSIVE)
span.appendText("\n\n")
if (arguments?.getString("type") == "reply") {
// add greeting text
if (profileConfig.messagesGreetingOnReply)
span.replace(0, 0, "$greetingText\n\n\n")
else
span.replace(0, 0, "\n\n")
teachers.firstOrNull { it.id == msg.senderId }?.let { teacher ->
teacher.image = getProfileImage(48, 24, 16, 12, 1, teacher.fullName)
chipList += ChipInfo(teacher.fullName, teacher)
}
subject = "Re: ${msg.subject}"
} else {
// add greeting text
if (profileConfig.messagesGreetingOnForward)
span.replace(0, 0, "$greetingText\n\n\n")
else
span.replace(0, 0, "\n\n")
subject = "Fwd: ${msg.subject}"
}
body = MessagesUtils.htmlToSpannable(activity, msg.body
?: "Nie udało się wczytać oryginalnej wiadomości.")//Html.fromHtml(msg.body?.replace("<br\\s?/?>".toRegex(), "\n") ?: "Nie udało się wczytać oryginalnej wiadomości.")
}
b.recipients.addTextWithChips(chipList)
if (b.recipients.text.isNullOrEmpty())
b.recipients.requestFocus()
else
b.text.requestFocus()
b.subject.setText(subject)
b.text.apply {
text = span.appendText(body)
if (!enableTextStyling)
setText(text?.toString())
setSelection(0)
}
b.root.scrollTo(0, 0)
}
else {
b.recipients.requestFocus()
}
}
private fun handleMailToIntent() {
val teacherId = arguments?.getLong("messageRecipientId")
if (teacherId == 0L)
return
val chipList = mutableListOf<ChipInfo>()
teachers.firstOrNull { it.id == teacherId }?.let { teacher ->
teacher.image = getProfileImage(48, 24, 16, 12, 1, teacher.fullName)
chipList += ChipInfo(teacher.fullName, teacher)
}
b.recipients.addTextWithChips(chipList)
val subject = arguments?.getString("messageSubject")
b.subject.setText(subject ?: return)
}
private fun sendMessage() {
b.recipientsLayout.error = null
b.subjectLayout.error = null
@ -439,6 +482,20 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
.show()
}
override fun onResume() {
super.onResume()
if (!isAdded || !this::activity.isInitialized)
return
activity.onBeforeNavigate = this::onBeforeNavigate
}
override fun onPause() {
super.onPause()
if (!this::activity.isInitialized)
return
activity.onBeforeNavigate = null
}
@Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
fun onRecipientListGetEvent(event: RecipientListGetEvent) {
if (event.profileId != App.profileId)
@ -460,11 +517,17 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
return
}
if (draftMessageId != null) {
launch {
manager.deleteDraft(App.profileId, draftMessageId!!)
}
}
activity.snackbar(app.getString(R.string.messages_sent_success), app.getString(R.string.ok))
activity.loadTarget(MainActivity.TARGET_MESSAGES_DETAILS, Bundle(
"messageId" to event.message.id,
"message" to app.gson.toJson(event.message),
"sentDate" to event.sentDate
))
), skipBeforeNavigate = true)
}
}

View File

@ -0,0 +1,21 @@
/*
* Copyright (c) Kuba Szczodrzyński 2021-10-9.
*/
package pl.szczodrzynski.edziennik.utils
import android.os.Bundle
open class PausedNavigationData {
data class LoadProfile(
val id: Int,
val drawerSelection: Int,
val arguments: Bundle?,
) : PausedNavigationData()
data class LoadTarget(
val id: Int,
val arguments: Bundle?,
) : PausedNavigationData()
}

View File

@ -79,10 +79,13 @@ object BetterHtml {
@Suppress("DEPRECATION")
val htmlSpannable = HtmlCompat.fromHtml(
text,
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST_ITEM or HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST or HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_DIV,
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST_ITEM
or HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_LIST
or HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_DIV
or HtmlCompat.FROM_HTML_MODE_LEGACY,
null,
LiTagHandler()
)
).trimEnd() // fromHtml seems to add two line breaks at the end, needlessly
val spanned = SpannableStringBuilder(htmlSpannable)
spanned.getSpans(0, spanned.length, Any::class.java).forEach {

View File

@ -4,20 +4,51 @@
package pl.szczodrzynski.edziennik.utils.managers
import android.content.Context
import android.os.Bundle
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.widget.EditText
import com.hootsuite.nachos.NachoTextView
import com.hootsuite.nachos.chip.ChipInfo
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.*
import pl.szczodrzynski.edziennik.data.db.entity.Message
import pl.szczodrzynski.edziennik.data.db.entity.MessageRecipient
import pl.szczodrzynski.edziennik.data.db.entity.Metadata
import pl.szczodrzynski.edziennik.data.db.entity.Teacher
import pl.szczodrzynski.edziennik.data.db.full.MessageFull
import pl.szczodrzynski.edziennik.ui.modules.messages.MessagesUtils
import pl.szczodrzynski.edziennik.utils.TextInputKeyboardEdit
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.navlib.colorAttr
class MessageManager(private val app: App) {
class UIConfig(
val context: Context,
val recipients: NachoTextView,
val subject: EditText,
val body: TextInputKeyboardEdit,
val teachers: List<Teacher>,
val greetingOnCompose: Boolean,
val greetingOnReply: Boolean,
val greetingOnForward: Boolean,
val greetingText: String,
)
private val textStylingManager
get() = app.textStylingManager
suspend fun getMessage(profileId: Int, args: Bundle?): MessageFull? {
val id = args?.getLong("messageId") ?: return null
val json = args.getString("message")
@ -43,10 +74,9 @@ class MessageManager(private val app: App) {
}
} ?: 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
val recipientsDistinct = message.recipients?.filter { it.profileId == profileId } ?: return null
message.recipients?.clear()
message.recipients?.addAll(recipientsDistinct)
@ -106,4 +136,149 @@ class MessageManager(private val app: App) {
app.db.messageDao().replace(message)
}
}
suspend fun deleteDraft(profileId: Int, messageId: Long) {
withContext(Dispatchers.Default) {
app.db.messageRecipientDao().clearFor(profileId, messageId)
app.db.messageDao().delete(profileId, messageId)
app.db.metadataDao().delete(profileId, Metadata.TYPE_MESSAGE, messageId)
}
}
suspend fun saveAsDraft(config: UIConfig, stylingConfig: StylingConfig, profileId: Int, messageId: Long?) {
val teachers = config.recipients.allChips.mapNotNull { it.data as? Teacher }
val subject = config.subject.text?.toString() ?: ""
val body = textStylingManager.getHtmlText(stylingConfig, enableHtmlCompatible = false)
withContext(Dispatchers.Default) {
if (messageId != null) {
app.db.messageRecipientDao().clearFor(profileId, messageId)
}
val message = Message(
profileId = profileId,
id = messageId ?: System.currentTimeMillis(),
type = Message.TYPE_DRAFT,
subject = subject,
body = body,
senderId = -1L,
addedDate = System.currentTimeMillis(),
)
val metadata = Metadata(profileId, Metadata.TYPE_MESSAGE, message.id, true, true)
val recipients = teachers.map {
MessageRecipient(profileId, it.id, message.id)
}
app.db.messageDao().replace(message)
app.db.messageRecipientDao().addAll(recipients)
app.db.metadataDao().add(metadata)
}
}
fun fillWithBundle(config: UIConfig, args: Bundle?): Message? {
args ?: return null
val messageJson = args.getString("message")
val teacherId = args.getLong("messageRecipientId")
val subject = args.getString("messageSubject")
val payloadType = args.getString("type")
if (config.greetingOnCompose)
config.body.setText(config.greetingText)
if (subject != null)
config.subject.setText(subject)
val message = if (messageJson != null)
app.gson.fromJson(messageJson, MessageFull::class.java)
else null
when {
message != null && message.type == Message.TYPE_DRAFT -> {
fillWithDraftMessage(config, message)
}
message != null -> {
fillWithMessage(config, message, payloadType)
}
teacherId != 0L -> {
fillWithRecipientIds(config, teacherId)
}
}
return message
}
private fun createRecipientChips(config: UIConfig, vararg teacherIds: Long?): List<ChipInfo> {
return teacherIds.mapNotNull { teacherId ->
val teacher = config.teachers.firstOrNull { it.id == teacherId } ?: return@mapNotNull null
teacher.image = MessagesUtils.getProfileImage(
diameterDp = 48,
textSizeBigDp = 24,
textSizeMediumDp = 16,
textSizeSmallDp = 12,
count = 1,
teacher.fullName
)
ChipInfo(teacher.fullName, teacher)
}
}
private fun fillWithRecipientIds(config: UIConfig, vararg teacherIds: Long?) {
config.recipients.addTextWithChips(createRecipientChips(config, *teacherIds))
}
private fun fillWithMessage(config: UIConfig, message: MessageFull, payloadType: String?) {
val spanned = SpannableStringBuilder()
val dateString = config.context.getString(
R.string.messages_reply_date_time_format,
Date.fromMillis(message.addedDate).formattedStringShort,
Time.fromMillis(message.addedDate).stringHM,
)
// add original message info
spanned.appendText("W dniu ")
spanned.appendSpan(dateString, ItalicSpan(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spanned.appendText(", ")
spanned.appendSpan(message.senderName.fixName(), ItalicSpan(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spanned.appendText(" napisał(a):")
spanned.setSpan(BoldSpan(), 0, spanned.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spanned.appendText("\n\n")
val greeting = when (payloadType) {
"reply" -> {
config.subject.setText(R.string.messages_compose_subject_reply_format, message.subject)
if (config.greetingOnReply)
config.greetingText
else null
}
"forward" -> {
config.subject.setText(R.string.messages_compose_subject_forward_format, message.subject)
if (config.greetingOnForward)
config.greetingText
else null
}
else -> null
}
if (greeting == null) {
spanned.replace(0, 0, "\n\n")
} else {
spanned.replace(0, 0, "$greeting\n\n\n")
}
val body = message.body ?: config.context.getString(R.string.messages_compose_body_load_failed)
spanned.appendText(BetterHtml.fromHtml(config.context, body))
fillWithRecipientIds(config, message.senderId)
config.body.text = spanned
}
private fun fillWithDraftMessage(config: UIConfig, message: MessageFull) {
val recipientIds = message.recipients?.map { it.id }?.toTypedArray() ?: emptyArray()
fillWithRecipientIds(config, *recipientIds)
config.subject.setText(message.subject)
val body = message.body ?: config.context.getString(R.string.messages_compose_body_load_failed)
config.body.setText(BetterHtml.fromHtml(config.context, body))
}
}

View File

@ -4,6 +4,7 @@
package pl.szczodrzynski.edziennik.utils.managers
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.widget.Button
import android.widget.TextView
@ -23,6 +24,10 @@ class TextStylingManager(private val app: App) {
private const val TAG = "TextStylingManager"
}
private val paragraphBrRegex by lazy {
"((?:<br>)+)</p>".toRegex()
}
data class StylingConfig(
val editText: TextInputKeyboardEdit,
val fontStyleGroup: MaterialButtonToggleGroup,
@ -86,8 +91,15 @@ class TextStylingManager(private val app: App) {
.build()*/
}
fun getHtmlText(config: StylingConfig): String {
val spanned = config.editText.text ?: return ""
fun getHtmlText(config: StylingConfig, enableHtmlCompatible: Boolean = true): String {
val text = config.editText.text?.trimEnd() ?: return ""
val spanned = SpannableStringBuilder(text)
val htmlCompatibleMode = config.htmlCompatibleMode && enableHtmlCompatible
val toHtmlFlag = if (htmlCompatibleMode)
HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL
else
HtmlCompat.TO_HTML_PARAGRAPH_LINES_CONSECUTIVE
// apparently setting the spans to a different Spannable calls the original EditText's
// onSelectionChanged with selectionStart=-1, which in effect unchecks the format toggles
@ -101,7 +113,7 @@ class TextStylingManager(private val app: App) {
if (spanStart == spanEnd && it::class.java in BetterHtml.customSpanClasses)
spanned.removeSpan(it)
}
var textHtml = HtmlCompat.toHtml(spanned, HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL)
var textHtml = HtmlCompat.toHtml(spanned, toHtmlFlag)
.replace("\n", "")
.replace(" dir=\"ltr\"", "")
.replace("</b><b>", "")
@ -110,10 +122,15 @@ class TextStylingManager(private val app: App) {
.replace("</sub><sub>", "")
.replace("</sup><sup>", "")
.replace("p style=\"margin-top:0; margin-bottom:0;\"", "p")
.replace("<br></p>", "</p><br>")
// replace multiple newlines so they convert fromHtml correctly
// this should not be breaking with htmlCompatibleMode == true,
// as line breaks cannot occur inside paragraphs with these flags
.replace(paragraphBrRegex, "</p>$1")
config.watchSelectionChanged = true
if (config.htmlCompatibleMode) {
if (htmlCompatibleMode) {
textHtml = textHtml
.replace("<br>", "<p>&nbsp;</p>")
.replace("<b>", "<strong>")

View File

@ -153,8 +153,9 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginVertical="8dp"
android:minHeight="250dp"
android:minHeight="200dp"
android:paddingHorizontal="16dp"
android:paddingBottom="32dp"
android:textIsSelectable="true"
tools:text="To jest treść wiadomości.\n\nZazwyczaj ma wiele linijek.\n\nTak" />
@ -252,7 +253,8 @@
android:layout_marginHorizontal="4dp"
android:layout_weight="1"
android:paddingHorizontal="4dp"
android:paddingTop="16dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:text="@string/message_download"
android:textAllCaps="false"
android:visibility="gone"

View File

@ -675,7 +675,7 @@
<string name="messages_compose_confirm_title">Bestätigen Sie das Senden der Nachricht</string>
<string name="messages_compose_menu_attachment">Fügen Sie einen Anhang hinzu</string>
<string name="messages_compose_menu_discard">Nachricht abbrechen</string>
<string name="messages_compose_menu_save_draft">Entwurf speichern</string>
<string name="messages_compose_save_draft">Entwurf speichern</string>
<string name="messages_compose_menu_send">Senden</string>
<string name="messages_compose_recipient_exists">Dieser Empfänger wurde bereits ausgewählt</string>
<string name="messages_compose_recipients_empty">Wählen Sie die Empfänger aus</string>

View File

@ -677,7 +677,7 @@
<string name="messages_compose_confirm_title">Confirm sending the message</string>
<string name="messages_compose_menu_attachment">Add an attachment</string>
<string name="messages_compose_menu_discard">Abort message</string>
<string name="messages_compose_menu_save_draft">Save draft</string>
<string name="messages_compose_save_draft">Save draft</string>
<string name="messages_compose_menu_send">Send</string>
<string name="messages_compose_recipient_exists">This recipient has already been selected</string>
<string name="messages_compose_recipients_empty">Select recipients</string>

View File

@ -730,7 +730,7 @@
<string name="messages_compose_confirm_title">Potwierdź wysłanie wiadomości</string>
<string name="messages_compose_menu_attachment">Dodaj załącznik</string>
<string name="messages_compose_menu_discard">Odrzuć wiadomość</string>
<string name="messages_compose_menu_save_draft">Zapisz wersję roboczą</string>
<string name="messages_compose_save_draft">Zapisz wersję roboczą</string>
<string name="messages_compose_menu_send">Wyślij</string>
<string name="messages_compose_recipient_exists">Ten odbiorca został już wybrany</string>
<string name="messages_compose_recipients_empty">Wybierz odbiorców</string>
@ -1476,4 +1476,17 @@
<string name="message_delete">Usuń</string>
<string name="message_reply">Odpowiedz</string>
<string name="message_download">Pobierz ponownie</string>
<string name="messages_compose_subject_reply_format" translatable="false">Re: %s</string>
<string name="messages_compose_subject_forward_format" translatable="false">Fwd: %s</string>
<string name="messages_compose_body_load_failed">Nie udało się wczytać oryginalnej wiadomości.</string>
<string name="messages_compose_save_draft_title">Zapisz zmiany</string>
<string name="messages_compose_save_draft_text">Czy chcesz zapisać zmiany jako wersję roboczą?\n\nBędzie możliwa późniejsza edycja oraz wysłanie wiadomości.</string>
<string name="discard">Odrzuć</string>
<string name="messages_compose_draft_saved">Zapisano wersję roboczą</string>
<string name="messages_tab_draft">Wersje robocze</string>
<string name="messages_compose_send_long">Wyślij wiadomość</string>
<string name="messages_compose_discard_draft">Usuń wersję roboczą</string>
<string name="messages_compose_draft_discarded">Usunięto wersję roboczą</string>
<string name="messages_compose_discard_draft_title">Usuń wersję roboczą</string>
<string name="messages_compose_discard_draft_text">Czy chcesz odrzucić zapisaną wersję wiadomości? Spowoduje to również anulowanie wprowadzonych zmian i usunięcie wiadomości.</string>
</resources>