diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt b/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt index 42eed408..8c03ded0 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/Extensions.kt @@ -1308,3 +1308,41 @@ fun Profile.getSchoolYearConstrains(): CalendarConstraints { .setEnd(dateYearEnd.inMillisUtc) .build() } + +fun CharSequence.getWordBounds(position: Int, onlyInWord: Boolean = false): Pair? { + if (length == 0) + return null + + // only if cursor between letters + if (onlyInWord) { + if (position < 1) + return null + if (position == length) + return null + + val charBefore = this[position - 1] + if (!charBefore.isLetterOrDigit()) + return null + val charAfter = this[position] + if (!charAfter.isLetterOrDigit()) + return null + } + + var rangeStart = substring(0 until position).indexOfLast { !it.isLetterOrDigit() } + if (rangeStart == -1) // no whitespace, set to first index + rangeStart = 0 + else // cut the leading whitespace + rangeStart += 1 + + var rangeEnd = substring(position).indexOfFirst { !it.isLetterOrDigit() } + if (rangeEnd == -1) // no whitespace, set to last index + rangeEnd = length + else // append the substring offset + rangeEnd += position + + if (!onlyInWord && rangeStart == rangeEnd) + return null + return rangeStart to rangeEnd +} + +infix fun Int.hasSet(what: Int) = this and what == what diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeChipCreator.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeChipCreator.kt new file mode 100644 index 00000000..649fd9ed --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeChipCreator.kt @@ -0,0 +1,141 @@ +/* + * Copyright (c) Kuba Szczodrzyński 2021-10-4. + */ + +package pl.szczodrzynski.edziennik.ui.modules.messages.compose + +import android.content.Context +import android.graphics.drawable.BitmapDrawable +import android.text.style.AbsoluteSizeSpan +import android.text.style.ForegroundColorSpan +import android.widget.Toast +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.hootsuite.nachos.ChipConfiguration +import com.hootsuite.nachos.NachoTextView +import com.hootsuite.nachos.chip.ChipInfo +import com.hootsuite.nachos.chip.ChipSpan +import com.hootsuite.nachos.chip.ChipSpanChipCreator +import pl.szczodrzynski.edziennik.* +import pl.szczodrzynski.edziennik.data.db.entity.Teacher +import pl.szczodrzynski.edziennik.ui.modules.messages.MessagesUtils +import pl.szczodrzynski.edziennik.utils.Colors +import pl.szczodrzynski.edziennik.utils.Themes +import pl.szczodrzynski.navlib.elevateSurface + +class MessagesComposeChipCreator( + private val context: Context, + private val nacho: NachoTextView, + private val teacherList: List, +) : ChipSpanChipCreator() { + + override fun createChip(context: Context, text: CharSequence, data: Any?): ChipSpan? { + if (data == null || data !is Teacher) + return null + if (data.id !in -24L..0L) { + nacho.allChips.forEach { + if (it.data == data) { + Toast.makeText( + context, + R.string.messages_compose_recipient_exists, + Toast.LENGTH_SHORT + ).show() + return null + } + } + val chipSpan = ChipSpan( + context, + data.fullName, + BitmapDrawable(context.resources, data.image), + data + ) + chipSpan.setIconBackgroundColor(Colors.stringToMaterialColor(data.fullName)) + return chipSpan + } + + val type = (data.id * -1).toInt() + + val textColorPrimary = android.R.attr.textColorPrimary.resolveAttr(context) + val textColorSecondary = android.R.attr.textColorSecondary.resolveAttr(context) + + val sortByCategory = type in listOf( + Teacher.TYPE_PARENTS_COUNCIL, + Teacher.TYPE_EDUCATOR, + Teacher.TYPE_STUDENT + ) + val teachers = if (sortByCategory) + teacherList.sortedBy { it.typeDescription } + else + teacherList + + val category = mutableListOf() + val categoryNames = mutableListOf() + val categoryCheckedItems = mutableListOf() + teachers.forEach { teacher -> + if (!teacher.isType(type)) + return@forEach + + category += teacher + val name = teacher.fullName + val description = when (type) { + Teacher.TYPE_TEACHER -> null + Teacher.TYPE_PARENTS_COUNCIL -> teacher.typeDescription + Teacher.TYPE_SCHOOL_PARENTS_COUNCIL -> null + Teacher.TYPE_PEDAGOGUE -> null + Teacher.TYPE_LIBRARIAN -> null + Teacher.TYPE_SCHOOL_ADMIN -> null + Teacher.TYPE_SUPER_ADMIN -> null + Teacher.TYPE_SECRETARIAT -> null + Teacher.TYPE_PRINCIPAL -> null + Teacher.TYPE_EDUCATOR -> teacher.typeDescription + Teacher.TYPE_PARENT -> teacher.typeDescription + Teacher.TYPE_STUDENT -> teacher.typeDescription + Teacher.TYPE_SPECIALIST -> null + else -> teacher.typeDescription + } + categoryNames += listOfNotNull( + name.asSpannable( + ForegroundColorSpan(textColorPrimary) + ), + description?.asSpannable( + ForegroundColorSpan(textColorSecondary), + AbsoluteSizeSpan(14.dp) + ) + ).concat("\n") + + // check the teacher if already added as a recipient + categoryCheckedItems += nacho.allChips.firstOrNull { it.data == teacher } != null + } + + MaterialAlertDialogBuilder(context) + .setTitle("Dodaj odbiorców - " + Teacher.typeName(context, type)) + //.setMessage(getString(R.string.messages_compose_recipients_text_format, Teacher.typeName(activity, type))) + .setPositiveButton("OK", null) + .setNeutralButton("Anuluj", null) + .setMultiChoiceItems( + categoryNames.toTypedArray(), + categoryCheckedItems.toBooleanArray() + ) { _, which, isChecked -> + val teacher = category[which] + if (isChecked) { + val chipInfoList = mutableListOf() + teacher.image = + MessagesUtils.getProfileImage(48, 24, 16, 12, 1, teacher.fullName) + chipInfoList.add(ChipInfo(teacher.fullName, teacher)) + nacho.addTextWithChips(chipInfoList) + } else { + nacho.allChips.forEach { + if (it.data == teacher) + nacho.chipTokenizer?.deleteChipAndPadding(it, nacho.text) + } + } + } + .show() + return null + } + + override fun configureChip(chip: ChipSpan, chipConfiguration: ChipConfiguration) { + super.configureChip(chip, chipConfiguration) + chip.setBackgroundColor(elevateSurface(context, 8).toColorStateList()) + chip.setTextColor(Themes.getPrimaryTextColor(context)) + } +} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeChipTokenizer.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeChipTokenizer.kt new file mode 100644 index 00000000..560b0a06 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeChipTokenizer.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) Kuba Szczodrzyński 2021-10-4. + */ + +package pl.szczodrzynski.edziennik.ui.modules.messages.compose + +import android.content.Context +import com.hootsuite.nachos.NachoTextView +import com.hootsuite.nachos.chip.ChipSpan +import com.hootsuite.nachos.tokenizer.SpanChipTokenizer +import pl.szczodrzynski.edziennik.data.db.entity.Teacher + +class MessagesComposeChipTokenizer( + context: Context, + nacho: NachoTextView, + teacherList: List, +) : SpanChipTokenizer( + context, + MessagesComposeChipCreator( + context = context, + nacho = nacho, + teacherList = teacherList + ), + ChipSpan::class.java +) diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeFragment.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeFragment.kt index 33744a98..25599bb5 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeFragment.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/modules/messages/compose/MessagesComposeFragment.kt @@ -5,30 +5,21 @@ package pl.szczodrzynski.edziennik.ui.modules.messages.compose import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Typeface -import android.graphics.drawable.BitmapDrawable import android.os.Bundle -import android.text.Spannable -import android.text.SpannableString -import android.text.SpannableStringBuilder -import android.text.style.AbsoluteSizeSpan -import android.text.style.ForegroundColorSpan -import android.text.style.StyleSpan +import android.text.* +import android.text.Spanned.* +import android.text.style.* import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.AutoCompleteTextView -import android.widget.Toast import androidx.core.text.HtmlCompat +import androidx.core.view.isVisible import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment +import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.dialog.MaterialAlertDialogBuilder -import com.hootsuite.nachos.ChipConfiguration import com.hootsuite.nachos.chip.ChipInfo -import com.hootsuite.nachos.chip.ChipSpan -import com.hootsuite.nachos.chip.ChipSpanChipCreator -import com.hootsuite.nachos.tokenizer.SpanChipTokenizer import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial import kotlinx.coroutines.* import org.greenrobot.eventbus.EventBus @@ -47,13 +38,15 @@ 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.utils.Colors import pl.szczodrzynski.edziennik.utils.Themes -import pl.szczodrzynski.edziennik.utils.Themes.getPrimaryTextColor +import pl.szczodrzynski.edziennik.utils.html.BetterHtml import pl.szczodrzynski.edziennik.utils.models.Date import pl.szczodrzynski.edziennik.utils.models.Time +import pl.szczodrzynski.edziennik.utils.span.BoldSpan +import pl.szczodrzynski.edziennik.utils.span.ItalicSpan +import pl.szczodrzynski.edziennik.utils.span.SubscriptSizeSpan +import pl.szczodrzynski.edziennik.utils.span.SuperscriptSizeSpan import pl.szczodrzynski.navlib.bottomsheet.items.BottomSheetPrimaryItem -import pl.szczodrzynski.navlib.elevateSurface import kotlin.coroutines.CoroutineContext import kotlin.text.replace @@ -76,11 +69,16 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { private var teachers = mutableListOf() + private var watchFormatChecked = true + private var watchSelectionChanged = true + private val enableTextStyling + get() = app.profile.loginStoreType != LoginStore.LOGIN_TYPE_VULCAN + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { activity = (getActivity() as MainActivity?) ?: return null context ?: return null app = activity.application as App - context!!.theme.applyStyle(Themes.appTheme, true) + requireContext().theme.applyStyle(Themes.appTheme, true) // activity, context and profile is valid b = MessagesComposeFragmentBinding.inflate(inflater) return b.root @@ -103,40 +101,12 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { // do your job } - /*b.fontStyleBold.icon = IconicsDrawable(activity, CommunityMaterial.Icon.cmd_format_bold) - .sizeDp(24) - .colorAttr(activity, R.attr.colorOnPrimary) - b.fontStyleItalic.icon = IconicsDrawable(activity, CommunityMaterial.Icon.cmd_format_italic) - .sizeDp(24) - .colorAttr(activity, R.attr.colorOnPrimary) - b.fontStyleUnderline.icon = IconicsDrawable(activity, CommunityMaterial.Icon.cmd_format_underline) - .sizeDp(24) - .colorAttr(activity, R.attr.colorOnPrimary) - - b.fontStyle.addOnButtonCheckedListener { _, checkedId, isChecked -> - val span: Any = when (checkedId) { - R.id.fontStyleBold -> StyleSpan(Typeface.BOLD) - R.id.fontStyleItalic -> StyleSpan(Typeface.ITALIC) - R.id.fontStyleUnderline -> UnderlineSpan() - else -> StyleSpan(Typeface.NORMAL) + if (App.devMode) { + b.textHtml.isVisible = true + b.text.addTextChangedListener { + b.textHtml.text = getHtmlText() } - - if (isChecked) { - val flags = if (b.text.selectionStart == b.text.selectionEnd) - SpannableString.SPAN_INCLUSIVE_INCLUSIVE - else - SpannableString.SPAN_EXCLUSIVE_INCLUSIVE - b.text.text?.setSpan(span, b.text.selectionStart, b.text.selectionEnd, flags) - } - else { - b.text.text?.getSpans(b.text.selectionStart, b.text.selectionEnd, span.javaClass)?.forEach { - if (it is StyleSpan && span is StyleSpan && it.style == span.style) - b.text.text?.removeSpan(it) - else if (it.javaClass == span.javaClass) - b.text.text?.removeSpan(it) - } - } - }*/ + } activity.bottomSheet.prependItem( BottomSheetPrimaryItem(true) @@ -156,6 +126,50 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { } } + private fun getHtmlText(): String { + val text = b.text.text ?: return "" + + // apparently setting the spans to a different Spannable calls the original EditText's + // onSelectionChanged with selectionStart=-1, which in effect unchecks the format toggles + watchSelectionChanged = false + var textHtml = if (enableTextStyling) { + val spanned = SpannableString(text) + // remove zero-length spans, as they seem to affect + // the whole line when converted to HTML + spanned.getSpans(0, spanned.length, Any::class.java).forEach { + val spanStart = spanned.getSpanStart(it) + val spanEnd = spanned.getSpanEnd(it) + if (spanStart == spanEnd && it::class.java in BetterHtml.customSpanClasses) + spanned.removeSpan(it) + } + HtmlCompat.toHtml(spanned, HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL) + .replace("\n", "") + .replace(" dir=\"ltr\"", "") + .replace("", "") + .replace("", "") + .replace("", "") + .replace("", "") + .replace("", "") + .replace("p style=\"margin-top:0; margin-bottom:0;\"", "p") + } else { + text.toString() + } + watchSelectionChanged = true + + if (app.profile.loginStoreType == LoginStore.LOGIN_TYPE_MOBIDZIENNIK) { + textHtml = textHtml + .replace("
", "

 

") + .replace("", "") + .replace("", "") + .replace("", "") + .replace("", "") + .replace("", "") + .replace("", "") + } + + return textHtml + } + private fun getRecipientList() { if (System.currentTimeMillis() - app.profile.lastReceiversSync > 1 * DAY * 1000 && app.profile.loginStoreType != LoginStore.LOGIN_TYPE_VULCAN) { activity.snackbar("Pobieranie listy odbiorców...") @@ -171,6 +185,80 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { } } + @Suppress("UNUSED_PARAMETER") + private fun onFormatChecked( + group: MaterialButtonToggleGroup, + checkedId: Int, + isChecked: Boolean + ) { + if (!watchFormatChecked) + return + val span = when (checkedId) { + R.id.fontStyleBold -> BoldSpan() + R.id.fontStyleItalic -> ItalicSpan() + R.id.fontStyleUnderline -> UnderlineSpan() + R.id.fontStyleStrike -> StrikethroughSpan() + R.id.fontStyleSubscript -> SubscriptSizeSpan(10, dip = true) + R.id.fontStyleSuperscript -> SuperscriptSizeSpan(10, dip = true) + else -> return + } + + // see comments in getHtmlText() + watchSelectionChanged = false + if (isChecked) + BetterHtml.applyFormat(span = span, editText = b.text) + else + BetterHtml.removeFormat(span = span, editText = b.text) + watchSelectionChanged = true + + if (App.devMode) + b.textHtml.text = getHtmlText() + } + + @Suppress("UNUSED_PARAMETER") + private fun onFormatClear(view: View) { + // shortened version on onFormatChecked(), removing all spans + watchSelectionChanged = false + BetterHtml.removeFormat(span = null, editText = b.text) + watchSelectionChanged = true + if (App.devMode) + b.textHtml.text = getHtmlText() + // force update of text style toggle states + onSelectionChanged(b.text.selectionStart, b.text.selectionEnd) + } + + private fun onSelectionChanged(selectionStart: Int, selectionEnd: Int) { + if (!watchSelectionChanged) + return + val spanned = b.text.text ?: return + val spans = spanned.getSpans(selectionStart, selectionEnd, Any::class.java).mapNotNull { + if (it::class.java !in BetterHtml.customSpanClasses) + return@mapNotNull null + val spanStart = spanned.getSpanStart(it) + val spanEnd = spanned.getSpanEnd(it) + // remove 0-length spans after navigating out of them + if (spanStart == spanEnd) + spanned.removeSpan(it) + else if (spanned.getSpanFlags(it) hasSet SPAN_EXCLUSIVE_EXCLUSIVE) + spanned.setSpan(it, spanStart, spanEnd, SPAN_EXCLUSIVE_INCLUSIVE) + + // names are helpful here + val isNotAfterWord = selectionEnd <= spanEnd + val isSelectionInWord = selectionStart != selectionEnd && selectionStart >= spanStart + val isCursorInWord = selectionStart == selectionEnd && selectionStart > spanStart + val isChecked = (isCursorInWord || isSelectionInWord) && isNotAfterWord + if (isChecked) it::class.java else null + } + watchFormatChecked = false + b.fontStyleBold.isChecked = BoldSpan::class.java in spans + b.fontStyleItalic.isChecked = ItalicSpan::class.java in spans + b.fontStyleUnderline.isChecked = UnderlineSpan::class.java in spans + b.fontStyleStrike.isChecked = StrikethroughSpan::class.java in spans + b.fontStyleSubscript.isChecked = SubscriptSizeSpan::class.java in spans + b.fontStyleSuperscript.isChecked = SuperscriptSizeSpan::class.java in spans + watchFormatChecked = true + } + private fun createView() { b.recipientsLayout.setBoxCornerRadii(0f, 0f, 0f, 0f) b.subjectLayout.setBoxCornerRadii(0f, 0f, 0f, 0f) @@ -203,107 +291,7 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { else -> -1 } - b.recipients.chipTokenizer = SpanChipTokenizer(activity, object : ChipSpanChipCreator() { - override fun createChip(context: Context, text: CharSequence, data: Any?): ChipSpan? { - if (data == null || data !is Teacher) - return null - if (data.id !in -24L..0L) { - b.recipients.allChips.forEach { - if (it.data == data) { - Toast.makeText(activity, R.string.messages_compose_recipient_exists, Toast.LENGTH_SHORT).show() - return null - } - } - val chipSpan = ChipSpan(context, data.fullName, BitmapDrawable(context.resources, data.image), data) - chipSpan.setIconBackgroundColor(Colors.stringToMaterialColor(data.fullName)) - return chipSpan - } - - val type = (data.id * -1).toInt() - - val textColorPrimary = android.R.attr.textColorPrimary.resolveAttr(activity) - val textColorSecondary = android.R.attr.textColorSecondary.resolveAttr(activity) - - val sortByCategory = type in listOf( - Teacher.TYPE_PARENTS_COUNCIL, - Teacher.TYPE_EDUCATOR, - Teacher.TYPE_STUDENT - ) - val teachers = if (sortByCategory) - teachers.sortedBy { it.typeDescription } - else - teachers - - val category = mutableListOf() - val categoryNames = mutableListOf() - val categoryCheckedItems = mutableListOf() - teachers.forEach { teacher -> - if (!teacher.isType(type)) - return@forEach - - category += teacher - val name = teacher.fullName - val description = when (type) { - Teacher.TYPE_TEACHER -> null - Teacher.TYPE_PARENTS_COUNCIL -> teacher.typeDescription - Teacher.TYPE_SCHOOL_PARENTS_COUNCIL -> null - Teacher.TYPE_PEDAGOGUE -> null - Teacher.TYPE_LIBRARIAN -> null - Teacher.TYPE_SCHOOL_ADMIN -> null - Teacher.TYPE_SUPER_ADMIN -> null - Teacher.TYPE_SECRETARIAT -> null - Teacher.TYPE_PRINCIPAL -> null - Teacher.TYPE_EDUCATOR -> teacher.typeDescription - Teacher.TYPE_PARENT -> teacher.typeDescription - Teacher.TYPE_STUDENT -> teacher.typeDescription - Teacher.TYPE_SPECIALIST -> null - else -> teacher.typeDescription - } - categoryNames += listOfNotNull( - name.asSpannable( - ForegroundColorSpan(textColorPrimary) - ), - description?.asSpannable( - ForegroundColorSpan(textColorSecondary), - AbsoluteSizeSpan(14.dp) - ) - ).concat("\n") - - // check the teacher if already added as a recipient - categoryCheckedItems += b.recipients.allChips.firstOrNull { it.data == teacher } != null - } - - MaterialAlertDialogBuilder(activity) - .setTitle("Dodaj odbiorców - "+ Teacher.typeName(activity, type)) - //.setMessage(getString(R.string.messages_compose_recipients_text_format, Teacher.typeName(activity, type))) - .setPositiveButton("OK", null) - .setNeutralButton("Anuluj", null) - .setMultiChoiceItems(categoryNames.toTypedArray(), categoryCheckedItems.toBooleanArray()) { _, which, isChecked -> - val teacher = category[which] - if (isChecked) { - val chipInfoList = mutableListOf() - teacher.image = getProfileImage(48, 24, 16, 12, 1, teacher.fullName) - chipInfoList.add(ChipInfo(teacher.fullName, teacher)) - b.recipients.addTextWithChips(chipInfoList) - } - else { - b.recipients.allChips.forEach { - if (it.data == teacher) - b.recipients.chipTokenizer?.deleteChipAndPadding(it, b.recipients.text) - } - } - } - .show() - return null - } - - override fun configureChip(chip: ChipSpan, chipConfiguration: ChipConfiguration) { - super.configureChip(chip, chipConfiguration) - chip.setBackgroundColor(elevateSurface(activity, 8).toColorStateList()) - chip.setTextColor(getPrimaryTextColor(activity)) - } - }, ChipSpan::class.java) - + b.recipients.chipTokenizer = MessagesComposeChipTokenizer(activity, b.recipients, teachers) b.recipients.setIllegalCharacterIdentifier { c -> c.toString().matches("[\\n;:_ ]".toRegex()) } @@ -330,6 +318,55 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { b.subjectLayout.isEnabled = false b.textLayout.isEnabled = false + b.fontStyleLayout.isVisible = enableTextStyling + b.fontStyleBold.isEnabled = false + b.fontStyleItalic.isEnabled = false + b.fontStyleUnderline.isEnabled = false + b.fontStyleStrike.isEnabled = false + b.fontStyleSubscript.isEnabled = false + b.fontStyleSuperscript.isEnabled = false + b.fontStyleClear.isEnabled = false + b.text.setOnFocusChangeListener { _, hasFocus -> + b.fontStyleBold.isEnabled = hasFocus + b.fontStyleItalic.isEnabled = hasFocus + b.fontStyleUnderline.isEnabled = hasFocus + b.fontStyleStrike.isEnabled = hasFocus + b.fontStyleSubscript.isEnabled = hasFocus + b.fontStyleSuperscript.isEnabled = hasFocus + b.fontStyleClear.isEnabled = hasFocus + } + + b.fontStyleBold.text = CommunityMaterial.Icon2.cmd_format_bold.character.toString() + b.fontStyleItalic.text = CommunityMaterial.Icon2.cmd_format_italic.character.toString() + b.fontStyleUnderline.text = CommunityMaterial.Icon2.cmd_format_underline.character.toString() + b.fontStyleStrike.text = CommunityMaterial.Icon2.cmd_format_strikethrough.character.toString() + b.fontStyleSubscript.text = CommunityMaterial.Icon2.cmd_format_subscript.character.toString() + b.fontStyleSuperscript.text = CommunityMaterial.Icon2.cmd_format_superscript.character.toString() + b.fontStyleBold.attachToastHint(R.string.hint_style_bold) + b.fontStyleItalic.attachToastHint(R.string.hint_style_italic) + b.fontStyleUnderline.attachToastHint(R.string.hint_style_underline) + b.fontStyleStrike.attachToastHint(R.string.hint_style_strike) + b.fontStyleSubscript.attachToastHint(R.string.hint_style_subscript) + b.fontStyleSuperscript.attachToastHint(R.string.hint_style_superscript) + + /*b.fontStyleBold.shapeAppearanceModel = b.fontStyleBold.shapeAppearanceModel + .toBuilder() + .setBottomLeftCornerSize(0f) + .build() + b.fontStyleSuperscript.shapeAppearanceModel = b.fontStyleBold.shapeAppearanceModel + .toBuilder() + .setBottomRightCornerSize(0f) + .build() + b.fontStyleClear.shapeAppearanceModel = b.fontStyleClear.shapeAppearanceModel + .toBuilder() + .setTopLeftCornerSize(0f) + .setTopRightCornerSize(0f) + .build()*/ + + b.fontStyle.addOnButtonCheckedListener(this::onFormatChecked) + b.fontStyleClear.setOnClickListener(this::onFormatClear) + b.text.setSelectionChangedListener(this::onSelectionChanged) + activity.navView.bottomBar.apply { fabEnable = true fabExtendedText = getString(R.string.messages_compose_send) @@ -382,11 +419,11 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { 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, StyleSpan(Typeface.ITALIC), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + span.appendSpan(dateString, ItalicSpan(), SPAN_EXCLUSIVE_EXCLUSIVE) span.appendText(", ") - span.appendSpan(msg.senderName.fixName(), StyleSpan(Typeface.ITALIC), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + span.appendSpan(msg.senderName.fixName(), ItalicSpan(), SPAN_EXCLUSIVE_EXCLUSIVE) span.appendText(" napisał(a):") - span.setSpan(StyleSpan(Typeface.BOLD), 0, span.length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + span.setSpan(BoldSpan(), 0, span.length, SPAN_EXCLUSIVE_EXCLUSIVE) span.appendText("\n\n") if (arguments?.getString("type") == "reply") { @@ -422,6 +459,8 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { b.subject.setText(subject) b.text.apply { text = span.appendText(body) + if (!enableTextStyling) + setText(text?.toString()) setSelection(0) } b.root.scrollTo(0, 0) @@ -494,30 +533,7 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { if (b.textLayout.counterMaxLength != -1 && b.text.length() > b.textLayout.counterMaxLength) return - var textHtml = if (app.profile.loginStoreType != LoginStore.LOGIN_TYPE_VULCAN) { - HtmlCompat.toHtml(SpannableString(text), HtmlCompat.TO_HTML_PARAGRAPH_LINES_INDIVIDUAL) - .replace("\n", "") - .replace(" dir=\"ltr\"", "") - } - else { - text.toString() - } - - textHtml = textHtml - .replace("
", "") - .replace("", "") - .replace("p style=\"margin-top:0; margin-bottom:0;\"", "p") - - if (app.profile.loginStoreType == LoginStore.LOGIN_TYPE_MOBIDZIENNIK) { - textHtml = textHtml - .replace("


", "

") - .replace("", "") - .replace("", "") - .replace("", "") - .replace("", "") - .replace("", "") - .replace("", "") - } + val textHtml = getHtmlText() activity.bottomSheet.hideKeyboard() @@ -525,7 +541,7 @@ class MessagesComposeFragment : Fragment(), CoroutineScope { .setTitle(R.string.messages_compose_confirm_title) .setMessage(R.string.messages_compose_confirm_text) .setPositiveButton(R.string.send) { _, _ -> - EdziennikTask.messageSend(App.profileId, recipients, subject.trim(), textHtml.trim()).enqueue(activity) + EdziennikTask.messageSend(App.profileId, recipients, subject.trim(), textHtml).enqueue(activity) } .setNegativeButton(R.string.cancel, null) .show() diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/TextInputKeyboardEdit.kt b/app/src/main/java/pl/szczodrzynski/edziennik/utils/TextInputKeyboardEdit.kt index cbb4f5a0..0b5e1c67 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/utils/TextInputKeyboardEdit.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/TextInputKeyboardEdit.kt @@ -18,6 +18,7 @@ class TextInputKeyboardEdit : AppCompatEditText { * Keyboard Listener */ internal var listener: KeyboardListener? = null + private var selectionListener: ((Int, Int) -> Unit)? = null constructor(context: Context) : super(context) @@ -27,14 +28,12 @@ class TextInputKeyboardEdit : AppCompatEditText { override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) { super.onFocusChanged(focused, direction, previouslyFocusedRect) - if (listener != null) - listener!!.onStateChanged(this, true) + listener?.onStateChanged(this, true) } override fun onKeyPreIme(keyCode: Int, @NonNull event: KeyEvent): Boolean { if (event.keyCode == KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) { - if (listener != null) - listener!!.onStateChanged(this, false) + listener?.onStateChanged(this, false) // Hide cursor isFocusable = false @@ -50,6 +49,15 @@ class TextInputKeyboardEdit : AppCompatEditText { this.listener = listener } + fun setSelectionChangedListener(listener: ((selectionStart: Int, selectionEnd: Int) -> Unit)?) { + this.selectionListener = listener + } + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + super.onSelectionChanged(selStart, selEnd) + selectionListener?.invoke(selStart, selEnd) + } + interface KeyboardListener { fun onStateChanged(keyboardEditText: TextInputKeyboardEdit, showing: Boolean) } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/html/BetterHtml.kt b/app/src/main/java/pl/szczodrzynski/edziennik/utils/html/BetterHtml.kt index 4119369e..90119e55 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/utils/html/BetterHtml.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/html/BetterHtml.kt @@ -6,26 +6,41 @@ package pl.szczodrzynski.edziennik.utils.html import android.content.Context import android.graphics.Color +import android.graphics.Typeface +import android.text.Editable import android.text.SpannableStringBuilder import android.text.Spanned -import android.text.style.BulletSpan +import android.text.Spanned.* +import android.text.style.* +import androidx.appcompat.widget.AppCompatEditText import androidx.core.graphics.ColorUtils import androidx.core.text.HtmlCompat import pl.szczodrzynski.edziennik.dp +import pl.szczodrzynski.edziennik.getWordBounds import pl.szczodrzynski.edziennik.resolveAttr +import pl.szczodrzynski.edziennik.utils.span.* import pl.szczodrzynski.navlib.blendColors object BetterHtml { + val customSpanClasses = listOf( + BoldSpan::class.java, + ItalicSpan::class.java, + UnderlineSpan::class.java, + StrikethroughSpan::class.java, + SubscriptSizeSpan::class.java, + SuperscriptSizeSpan::class.java, + ) + @JvmStatic fun fromHtml(context: Context, html: String): Spanned { val hexPattern = "(#[a-fA-F0-9]{6})" val colorRegex = "(?:color=\"$hexPattern\")|(?:style=\"color: ?${hexPattern})" - .toRegex(RegexOption.IGNORE_CASE) + .toRegex(RegexOption.IGNORE_CASE) var text = html - .replace("\\[META:[A-z0-9]+;[0-9-]+]".toRegex(), "") - .replace("background-color: ?$hexPattern;".toRegex(), "") + .replace("\\[META:[A-z0-9]+;[0-9-]+]".toRegex(), "") + .replace("background-color: ?$hexPattern;".toRegex(), "") val colorBackground = android.R.attr.colorBackground.resolveAttr(context) val textColorPrimary = android.R.attr.textColorPrimary.resolveAttr(context) and 0xffffff @@ -39,7 +54,11 @@ object BetterHtml { var blendAmount = 1 var numIterations = 0 - while (numIterations < 100 && ColorUtils.calculateContrast(colorBackground, newColor) < 4.5f) { + while (numIterations < 100 && ColorUtils.calculateContrast( + colorBackground, + newColor + ) < 4.5f + ) { blendAmount += 2 newColor = blendColors(color, blendAmount shl 24 or textColorPrimary) numIterations++ @@ -59,26 +78,109 @@ 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, - null, - LiTagHandler() + 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, + null, + LiTagHandler() ) - val spannableBuilder = SpannableStringBuilder(htmlSpannable) - val bulletSpans = spannableBuilder.getSpans(0, spannableBuilder.length, BulletSpan::class.java) - bulletSpans.forEach { - val start = spannableBuilder.getSpanStart(it) - val end = spannableBuilder.getSpanEnd(it) - spannableBuilder.removeSpan(it) - spannableBuilder.setSpan( - ImprovedBulletSpan(bulletRadius = 3.dp, startWidth = 24.dp, gapWidth = 8.dp), - start, - end, - Spanned.SPAN_INCLUSIVE_EXCLUSIVE - ) + val spanned = SpannableStringBuilder(htmlSpannable) + spanned.getSpans(0, spanned.length, Any::class.java).forEach { + val spanStart = spanned.getSpanStart(it) + val spanEnd = spanned.getSpanEnd(it) + val spanFlags = spanned.getSpanFlags(it) + + val newSpan: Any? = when (it) { + is BulletSpan -> ImprovedBulletSpan( + bulletRadius = 3.dp, + startWidth = 24.dp, + gapWidth = 8.dp + ) + is StyleSpan -> when (it.style) { + Typeface.BOLD -> BoldSpan() + Typeface.ITALIC -> ItalicSpan() + else -> null + } + is SubscriptSpan -> SubscriptSizeSpan(size = 10, dip = true) + is SuperscriptSpan -> SuperscriptSizeSpan(size = 10, dip = true) + else -> null + } + + if (newSpan != null) { + spanned.removeSpan(it) + spanned.setSpan(newSpan, spanStart, spanEnd, spanFlags) + } } - return spannableBuilder + return spanned + } + + fun applyFormat(span: Any, editText: AppCompatEditText) { + applyFormat(span, editText.text ?: return, editText.selectionStart, editText.selectionEnd) + } + + fun removeFormat(span: Any?, editText: AppCompatEditText) { + removeFormat(span, editText.text ?: return, editText.selectionStart, editText.selectionEnd) + } + + fun applyFormat(span: Any, spanned: Editable, selectionStart: Int, selectionEnd: Int) { + if (selectionStart == -1 || selectionEnd == -1) return + val cursorOnly = selectionStart == selectionEnd + + val wordBounds = spanned.getWordBounds(selectionStart, onlyInWord = true) + if (cursorOnly && wordBounds != null) { + // use the detected word bounds instead of cursor/selection + val (start, end) = wordBounds + spanned.setSpan(span, start, end, SPAN_EXCLUSIVE_INCLUSIVE) + } else { + val spanFlags = if (cursorOnly) + SPAN_INCLUSIVE_INCLUSIVE + else + SPAN_EXCLUSIVE_INCLUSIVE + spanned.setSpan(span, selectionStart, selectionEnd, spanFlags) + } + } + + fun removeFormat(span: Any?, spanned: Editable, selectionStart: Int, selectionEnd: Int) { + if (selectionStart == -1 || selectionEnd == -1) return + val cursorOnly = selectionStart == selectionEnd + + val spanClass = span?.javaClass ?: Any::class.java + spanned.getSpans(selectionStart, selectionEnd, spanClass).forEach { + if (span == null && it::class.java !in customSpanClasses) + return@forEach + val spanStart = spanned.getSpanStart(it) + val spanEnd = spanned.getSpanEnd(it) + val wordBounds = spanned.getWordBounds(selectionStart, onlyInWord = true) + + val (newSpanStart, newSpanEnd, newSpanFlags) = when { + !cursorOnly -> { + // cut the selected range out of the span + Triple(selectionStart, selectionEnd, SPAN_EXCLUSIVE_INCLUSIVE) + } + wordBounds == null -> { + // this allows to change spans mid-word - EXCLUSIVE so the style does + // not apply to characters typed later + // it's set back to INCLUSIVE when the cursor enters the word again + // (onSelectionChanged) + Triple(selectionStart, selectionEnd, SPAN_EXCLUSIVE_EXCLUSIVE) + } + else /* wordBounds != null */ -> { + // a word is selected, slice the span in two + Triple(wordBounds.first, wordBounds.second, SPAN_EXCLUSIVE_INCLUSIVE) + } + } + + // remove the existing span + spanned.removeSpan(it) + // "clone" the span so it can be applied twice, if needed + // (can't use 'span' from the parameters as it's nullable) + val itClone = it::class.java.newInstance() + // reapply the span wherever needed + if (spanStart < newSpanStart) + spanned.setSpan(it, spanStart, newSpanStart, newSpanFlags) + if (spanEnd > newSpanEnd) + spanned.setSpan(itClone, newSpanEnd, spanEnd, newSpanFlags) + } } } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/BoldSpan.kt b/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/BoldSpan.kt new file mode 100644 index 00000000..1dd7fb7a --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/BoldSpan.kt @@ -0,0 +1,10 @@ +/* + * Copyright (c) Kuba Szczodrzyński 2021-10-3. + */ + +package pl.szczodrzynski.edziennik.utils.span + +import android.graphics.Typeface +import android.text.style.StyleSpan + +class BoldSpan : StyleSpan(Typeface.BOLD) diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/html/ImprovedBulletSpan.kt b/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/ImprovedBulletSpan.kt similarity index 90% rename from app/src/main/java/pl/szczodrzynski/edziennik/utils/html/ImprovedBulletSpan.kt rename to app/src/main/java/pl/szczodrzynski/edziennik/utils/span/ImprovedBulletSpan.kt index 96dcbdfa..6bdfbe42 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/utils/html/ImprovedBulletSpan.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/ImprovedBulletSpan.kt @@ -6,7 +6,7 @@ * https://github.com/davidbilik/bullet-span-sample/blob/master/app/src/main/java/cz/davidbilik/bulletsample/ImprovedBulletSpan.kt */ -package pl.szczodrzynski.edziennik.utils.html +package pl.szczodrzynski.edziennik.utils.span import android.graphics.Canvas import android.graphics.Paint @@ -20,10 +20,10 @@ import android.text.style.LeadingMarginSpan * Copy of [android.text.style.BulletSpan] from android SDK 28 with removed internal code */ class ImprovedBulletSpan( - val bulletRadius: Int = STANDARD_BULLET_RADIUS, - val startWidth: Int = STANDARD_GAP_WIDTH, - val gapWidth: Int = STANDARD_GAP_WIDTH, - val color: Int = STANDARD_COLOR + val bulletRadius: Int = STANDARD_BULLET_RADIUS, + val startWidth: Int = STANDARD_GAP_WIDTH, + val gapWidth: Int = STANDARD_GAP_WIDTH, + val color: Int = STANDARD_COLOR ) : LeadingMarginSpan { companion object { diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/ItalicSpan.kt b/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/ItalicSpan.kt new file mode 100644 index 00000000..4ae7650d --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/ItalicSpan.kt @@ -0,0 +1,10 @@ +/* + * Copyright (c) Kuba Szczodrzyński 2021-10-3. + */ + +package pl.szczodrzynski.edziennik.utils.span + +import android.graphics.Typeface +import android.text.style.StyleSpan + +class ItalicSpan : StyleSpan(Typeface.ITALIC) diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/SubscriptSizeSpan.kt b/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/SubscriptSizeSpan.kt new file mode 100644 index 00000000..97fd9f2a --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/SubscriptSizeSpan.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) Kuba Szczodrzyński 2021-10-3. + */ + +package pl.szczodrzynski.edziennik.utils.span + +import android.text.TextPaint +import android.text.style.SubscriptSpan + +class SubscriptSizeSpan( + private val size: Int, + private val dip: Boolean, +) : SubscriptSpan() { + + override fun updateDrawState(textPaint: TextPaint) { + super.updateDrawState(textPaint) + if (dip) { + textPaint.textSize = size * textPaint.density + } else { + textPaint.textSize = size.toFloat() + } + } + + override fun updateMeasureState(textPaint: TextPaint) { + super.updateMeasureState(textPaint) + if (dip) { + textPaint.textSize = size * textPaint.density + } else { + textPaint.textSize = size.toFloat() + } + } +} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/SuperscriptSizeSpan.kt b/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/SuperscriptSizeSpan.kt new file mode 100644 index 00000000..7b82ed51 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/utils/span/SuperscriptSizeSpan.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) Kuba Szczodrzyński 2021-10-3. + */ + +package pl.szczodrzynski.edziennik.utils.span + +import android.text.TextPaint +import android.text.style.SuperscriptSpan + +class SuperscriptSizeSpan( + private val size: Int, + private val dip: Boolean, +) : SuperscriptSpan() { + + override fun updateDrawState(textPaint: TextPaint) { + super.updateDrawState(textPaint) + if (dip) { + textPaint.textSize = size * textPaint.density + } else { + textPaint.textSize = size.toFloat() + } + } + + override fun updateMeasureState(textPaint: TextPaint) { + super.updateMeasureState(textPaint) + if (dip) { + textPaint.textSize = size * textPaint.density + } else { + textPaint.textSize = size.toFloat() + } + } +} diff --git a/app/src/main/res/layout/messages_compose_fragment.xml b/app/src/main/res/layout/messages_compose_fragment.xml index 9363a92f..d9d24613 100644 --- a/app/src/main/res/layout/messages_compose_fragment.xml +++ b/app/src/main/res/layout/messages_compose_fragment.xml @@ -6,15 +6,21 @@ + + + + + + + - + android:orientation="vertical" + android:paddingBottom="40dp"> + app:endIconDrawable="@drawable/dropdown_arrow" + app:endIconMode="custom"> - - + tools:text="kachoomba" /> + + android:hint="@string/messages_compose_text_hint" + android:inputType="textMultiLine|textAutoCorrect|textLongMessage|textAutoComplete|textCapSentences" + android:minLines="3" + tools:text="Witam,\n\nchciałem przekazać bardzo ważną wiadomość.\nJest to cytat znanej osoby.\n\n"To jest tak, ale nie. Pamiętaj żeby oczywiście"\n\nCytat ma bardzo duże przesłanie i ogromny wpływ na dzisiejszą kulturę i rozwój współczesnej cywilizacji.\n\nJako zadanie domowe, należy wypisać 5 pierwszych liczb pierwszych. Uzasadnij decyzję, odwołując się do cytatu i 3 innych przykładów z literatury lub filmu.\n\nPozdrawiam,\nJa." /> - + - - - - --> + android:layout_marginHorizontal="8dp" + android:text="@string/messages_compose_style_clear" /> +