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

View File

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

View File

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

View File

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

View File

@ -13,17 +13,19 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.AutoCompleteTextView import android.widget.AutoCompleteTextView
import android.widget.ScrollView
import android.widget.Toast
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.hootsuite.nachos.chip.ChipInfo
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import kotlinx.coroutines.* import kotlinx.coroutines.*
import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.EventBus
import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.Subscribe
import org.greenrobot.eventbus.ThreadMode import org.greenrobot.eventbus.ThreadMode
import pl.szczodrzynski.edziennik.* 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.ERROR_MESSAGE_NOT_SENT
import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_MOBIDZIENNIK import pl.szczodrzynski.edziennik.data.api.LOGIN_TYPE_MOBIDZIENNIK
import pl.szczodrzynski.edziennik.data.api.edziennik.EdziennikTask 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.events.RecipientListGetEvent
import pl.szczodrzynski.edziennik.data.api.models.ApiError import pl.szczodrzynski.edziennik.data.api.models.ApiError
import pl.szczodrzynski.edziennik.data.db.entity.LoginStore 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.entity.Teacher
import pl.szczodrzynski.edziennik.data.db.full.MessageFull
import pl.szczodrzynski.edziennik.databinding.MessagesComposeFragmentBinding import pl.szczodrzynski.edziennik.databinding.MessagesComposeFragmentBinding
import pl.szczodrzynski.edziennik.ui.dialogs.MessagesConfigDialog import pl.szczodrzynski.edziennik.ui.dialogs.MessagesConfigDialog
import pl.szczodrzynski.edziennik.ui.modules.messages.MessagesUtils import pl.szczodrzynski.edziennik.ui.modules.messages.MessagesFragment
import pl.szczodrzynski.edziennik.ui.modules.messages.MessagesUtils.getProfileImage
import pl.szczodrzynski.edziennik.utils.Themes 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.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.edziennik.utils.span.*
import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetPrimaryItem import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetPrimaryItem
import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetSeparatorItem
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
class MessagesComposeFragment : Fragment(), CoroutineScope { class MessagesComposeFragment : Fragment(), CoroutineScope {
companion object { companion object {
private const val TAG = "MessagesComposeFragment" private const val TAG = "MessagesComposeFragment"
@ -58,19 +60,25 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main get() = job + Dispatchers.Main
// private val manager private val manager
// get() = app.messageManager get() = app.messageManager
private val textStylingManager private val textStylingManager
get() = app.textStylingManager get() = app.textStylingManager
private val profileConfig by lazy { app.config.forProfile().ui } private val profileConfig by lazy { app.config.forProfile().ui }
private val greetingText private val greetingText
get() = profileConfig.messagesGreetingText ?: "\n\nZ poważaniem\n${app.profile.accountOwnerName}" 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 stylingConfig: StylingConfig
private lateinit var uiConfig: UIConfig
private val enableTextStyling private val enableTextStyling
get() = app.profile.loginStoreType != LoginStore.LOGIN_TYPE_VULCAN 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? { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
activity = (getActivity() as MainActivity?) ?: return null activity = (getActivity() as MainActivity?) ?: return null
@ -99,7 +107,30 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
// do your job // 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) BottomSheetPrimaryItem(true)
.withTitle(R.string.menu_messages_config) .withTitle(R.string.menu_messages_config)
.withIcon(CommunityMaterial.Icon.cmd_cog_outline) .withIcon(CommunityMaterial.Icon.cmd_cog_outline)
@ -146,12 +177,15 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
b.recipients.addTextChangedListener(onTextChanged = { _, _, _, _ -> b.recipients.addTextChangedListener(onTextChanged = { _, _, _, _ ->
b.recipientsLayout.error = null b.recipientsLayout.error = null
changedRecipients = true
}) })
b.subject.addTextChangedListener(onTextChanged = { _, _, _, _ -> b.subject.addTextChangedListener(onTextChanged = { _, _, _, _ ->
b.subjectLayout.error = null b.subjectLayout.error = null
changedSubject = true
}) })
b.text.addTextChangedListener(onTextChanged = { _, _, _, _ -> b.text.addTextChangedListener(onTextChanged = { _, _, _, _ ->
b.textLayout.error = null b.textLayout.error = null
changedBody = true
}) })
b.subjectLayout.counterMaxLength = when (app.profile.loginStoreType) { 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( stylingConfig = StylingConfig(
editText = b.text, editText = b.text,
fontStyleGroup = b.fontStyle, fontStyleGroup = b.fontStyle,
@ -250,6 +295,9 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
b.fontStyleLayout.isVisible = enableTextStyling b.fontStyleLayout.isVisible = enableTextStyling
if (enableTextStyling) { if (enableTextStyling) {
textStylingManager.attach(stylingConfig) textStylingManager.attach(stylingConfig)
b.fontStyle.addOnButtonCheckedListener { _, _, _ ->
changedBody = true
}
} }
if (App.devMode) { if (App.devMode) {
@ -272,10 +320,68 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
activity.gainAttentionFAB() 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") @SuppressLint("SetTextI18n")
private fun updateRecipientList(list: List<Teacher>) { launch { private fun updateRecipientList(list: List<Teacher>) { launch {
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
teachers = list.sortedBy { it.fullName }.toMutableList() teachers.clear()
teachers.addAll(list.sortedBy { it.fullName })
Teacher.types.mapTo(teachers) { Teacher.types.mapTo(teachers) {
Teacher(-1, -it.toLong(), Teacher.typeName(activity, it), "") Teacher(-1, -it.toLong(), Teacher.typeName(activity, it), "")
} }
@ -291,93 +397,30 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
val adapter = MessagesComposeSuggestionAdapter(activity, teachers) val adapter = MessagesComposeSuggestionAdapter(activity, teachers)
b.recipients.setAdapter(adapter) b.recipients.setAdapter(adapter)
if (profileConfig.messagesGreetingOnCompose) val message = manager.fillWithBundle(uiConfig, arguments)
b.text.setText(greetingText) if (message != null && message.type == Message.TYPE_DRAFT) {
draftMessageId = message.id
if (discardDraftItem != null)
activity.bottomSheet.addItemAt(2, discardDraftItem!!)
discardDraftItem = null
}
handleReplyMessage() when {
handleMailToIntent() 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() { private fun sendMessage() {
b.recipientsLayout.error = null b.recipientsLayout.error = null
b.subjectLayout.error = null b.subjectLayout.error = null
@ -439,6 +482,20 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
.show() .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) @Subscribe(threadMode = ThreadMode.MAIN, sticky = true)
fun onRecipientListGetEvent(event: RecipientListGetEvent) { fun onRecipientListGetEvent(event: RecipientListGetEvent) {
if (event.profileId != App.profileId) if (event.profileId != App.profileId)
@ -460,11 +517,17 @@ class MessagesComposeFragment : Fragment(), CoroutineScope {
return 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.snackbar(app.getString(R.string.messages_sent_success), app.getString(R.string.ok))
activity.loadTarget(MainActivity.TARGET_MESSAGES_DETAILS, Bundle( activity.loadTarget(MainActivity.TARGET_MESSAGES_DETAILS, Bundle(
"messageId" to event.message.id, "messageId" to event.message.id,
"message" to app.gson.toJson(event.message), "message" to app.gson.toJson(event.message),
"sentDate" to event.sentDate "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") @Suppress("DEPRECATION")
val htmlSpannable = HtmlCompat.fromHtml( val htmlSpannable = HtmlCompat.fromHtml(
text, 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, null,
LiTagHandler() LiTagHandler()
) ).trimEnd() // fromHtml seems to add two line breaks at the end, needlessly
val spanned = SpannableStringBuilder(htmlSpannable) val spanned = SpannableStringBuilder(htmlSpannable)
spanned.getSpans(0, spanned.length, Any::class.java).forEach { spanned.getSpans(0, spanned.length, Any::class.java).forEach {

View File

@ -4,20 +4,51 @@
package pl.szczodrzynski.edziennik.utils.managers package pl.szczodrzynski.edziennik.utils.managers
import android.content.Context
import android.os.Bundle 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.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.colorRes import com.mikepenz.iconics.utils.colorRes
import com.mikepenz.iconics.view.IconicsImageView import com.mikepenz.iconics.view.IconicsImageView
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import pl.szczodrzynski.edziennik.App import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.db.entity.Message 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.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 import pl.szczodrzynski.navlib.colorAttr
class MessageManager(private val app: App) { 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? { suspend fun getMessage(profileId: Int, args: Bundle?): MessageFull? {
val id = args?.getLong("messageId") ?: return null val id = args?.getLong("messageId") ?: return null
val json = args.getString("message") val json = args.getString("message")
@ -43,10 +74,9 @@ class MessageManager(private val app: App) {
} }
} ?: return null } ?: return null
// make recipients ID-unique
// this helps when multiple profiles receive the same message // this helps when multiple profiles receive the same message
// (there are multiple -1 recipients for the same message ID) // (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?.clear()
message.recipients?.addAll(recipientsDistinct) message.recipients?.addAll(recipientsDistinct)
@ -106,4 +136,149 @@ class MessageManager(private val app: App) {
app.db.messageDao().replace(message) 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 package pl.szczodrzynski.edziennik.utils.managers
import android.text.SpannableStringBuilder
import android.text.Spanned import android.text.Spanned
import android.widget.Button import android.widget.Button
import android.widget.TextView import android.widget.TextView
@ -23,6 +24,10 @@ class TextStylingManager(private val app: App) {
private const val TAG = "TextStylingManager" private const val TAG = "TextStylingManager"
} }
private val paragraphBrRegex by lazy {
"((?:<br>)+)</p>".toRegex()
}
data class StylingConfig( data class StylingConfig(
val editText: TextInputKeyboardEdit, val editText: TextInputKeyboardEdit,
val fontStyleGroup: MaterialButtonToggleGroup, val fontStyleGroup: MaterialButtonToggleGroup,
@ -86,8 +91,15 @@ class TextStylingManager(private val app: App) {
.build()*/ .build()*/
} }
fun getHtmlText(config: StylingConfig): String { fun getHtmlText(config: StylingConfig, enableHtmlCompatible: Boolean = true): String {
val spanned = config.editText.text ?: return "" 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 // apparently setting the spans to a different Spannable calls the original EditText's
// onSelectionChanged with selectionStart=-1, which in effect unchecks the format toggles // 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) if (spanStart == spanEnd && it::class.java in BetterHtml.customSpanClasses)
spanned.removeSpan(it) spanned.removeSpan(it)
} }
var textHtml = HtmlCompat.toHtml(spanned, HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL) var textHtml = HtmlCompat.toHtml(spanned, toHtmlFlag)
.replace("\n", "") .replace("\n", "")
.replace(" dir=\"ltr\"", "") .replace(" dir=\"ltr\"", "")
.replace("</b><b>", "") .replace("</b><b>", "")
@ -110,10 +122,15 @@ class TextStylingManager(private val app: App) {
.replace("</sub><sub>", "") .replace("</sub><sub>", "")
.replace("</sup><sup>", "") .replace("</sup><sup>", "")
.replace("p style=\"margin-top:0; margin-bottom:0;\"", "p") .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 config.watchSelectionChanged = true
if (config.htmlCompatibleMode) { if (htmlCompatibleMode) {
textHtml = textHtml textHtml = textHtml
.replace("<br>", "<p>&nbsp;</p>") .replace("<br>", "<p>&nbsp;</p>")
.replace("<b>", "<strong>") .replace("<b>", "<strong>")

View File

@ -153,8 +153,9 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginVertical="8dp" android:layout_marginVertical="8dp"
android:minHeight="250dp" android:minHeight="200dp"
android:paddingHorizontal="16dp" android:paddingHorizontal="16dp"
android:paddingBottom="32dp"
android:textIsSelectable="true" android:textIsSelectable="true"
tools:text="To jest treść wiadomości.\n\nZazwyczaj ma wiele linijek.\n\nTak" /> tools:text="To jest treść wiadomości.\n\nZazwyczaj ma wiele linijek.\n\nTak" />
@ -252,7 +253,8 @@
android:layout_marginHorizontal="4dp" android:layout_marginHorizontal="4dp"
android:layout_weight="1" android:layout_weight="1"
android:paddingHorizontal="4dp" android:paddingHorizontal="4dp"
android:paddingTop="16dp" android:paddingTop="8dp"
android:paddingBottom="8dp"
android:text="@string/message_download" android:text="@string/message_download"
android:textAllCaps="false" android:textAllCaps="false"
android:visibility="gone" 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_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_attachment">Fügen Sie einen Anhang hinzu</string>
<string name="messages_compose_menu_discard">Nachricht abbrechen</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_menu_send">Senden</string>
<string name="messages_compose_recipient_exists">Dieser Empfänger wurde bereits ausgewählt</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> <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_confirm_title">Confirm sending the message</string>
<string name="messages_compose_menu_attachment">Add an attachment</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_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_menu_send">Send</string>
<string name="messages_compose_recipient_exists">This recipient has already been selected</string> <string name="messages_compose_recipient_exists">This recipient has already been selected</string>
<string name="messages_compose_recipients_empty">Select recipients</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_confirm_title">Potwierdź wysłanie wiadomości</string>
<string name="messages_compose_menu_attachment">Dodaj załącznik</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_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_menu_send">Wyślij</string>
<string name="messages_compose_recipient_exists">Ten odbiorca został już wybrany</string> <string name="messages_compose_recipient_exists">Ten odbiorca został już wybrany</string>
<string name="messages_compose_recipients_empty">Wybierz odbiorców</string> <string name="messages_compose_recipients_empty">Wybierz odbiorców</string>
@ -1476,4 +1476,17 @@
<string name="message_delete">Usuń</string> <string name="message_delete">Usuń</string>
<string name="message_reply">Odpowiedz</string> <string name="message_reply">Odpowiedz</string>
<string name="message_download">Pobierz ponownie</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> </resources>