Skip to content

Commit 7b0e2e7

Browse files
authored
Merge pull request #1996 from Infomaniak/toolbar
Add toolbar formatting options to the new rich html editor
2 parents e0e37ab + b518377 commit 7b0e2e7

17 files changed

+474
-177
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* Infomaniak Mail - Android
3+
* Copyright (C) 2024 Infomaniak Network SA
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
17+
*/
18+
package com.infomaniak.mail.ui.newMessage
19+
20+
import android.content.Context
21+
import android.content.DialogInterface
22+
import android.util.Patterns
23+
import androidx.appcompat.app.AlertDialog
24+
import androidx.core.widget.addTextChangedListener
25+
import androidx.core.widget.doOnTextChanged
26+
import com.google.android.material.dialog.MaterialAlertDialogBuilder
27+
import com.infomaniak.lib.core.utils.context
28+
import com.infomaniak.lib.core.utils.showKeyboard
29+
import com.infomaniak.mail.R
30+
import com.infomaniak.mail.databinding.DialogInsertLinkBinding
31+
import com.infomaniak.mail.ui.alertDialogs.BaseAlertDialog
32+
import com.infomaniak.mail.utils.extensions.trimmedText
33+
import dagger.hilt.android.qualifiers.ActivityContext
34+
import dagger.hilt.android.scopes.ActivityScoped
35+
import javax.inject.Inject
36+
import com.infomaniak.lib.core.R as RCore
37+
38+
@ActivityScoped
39+
class InsertLinkDialog @Inject constructor(
40+
@ActivityContext private val activityContext: Context,
41+
) : BaseAlertDialog(activityContext) {
42+
43+
val binding: DialogInsertLinkBinding by lazy { DialogInsertLinkBinding.inflate(activity.layoutInflater) }
44+
private var addLink: ((String, String) -> Unit)? = null
45+
46+
override val alertDialog: AlertDialog = with(binding) {
47+
showDisplayNamePreview()
48+
49+
MaterialAlertDialogBuilder(context)
50+
.setView(root)
51+
.setPositiveButton(R.string.buttonConfirm, null)
52+
.setNegativeButton(RCore.string.buttonCancel, null)
53+
.create()
54+
.also {
55+
it.setOnShowListener { dialog ->
56+
urlEditText.showKeyboard()
57+
resetDialogState()
58+
setConfirmButtonListener(dialog)
59+
}
60+
urlEditText.doOnTextChanged { _, _, _, _ ->
61+
urlLayout.setError(null)
62+
}
63+
}
64+
}
65+
66+
override fun resetCallbacks() {
67+
addLink = null
68+
}
69+
70+
fun show(defaultDisplayNameValue: String = "", defaultUrlValue: String = "", addLinkCallback: (String, String) -> Unit) {
71+
binding.apply {
72+
displayNameEditText.setText(defaultDisplayNameValue)
73+
urlEditText.setText(defaultUrlValue)
74+
}
75+
76+
addLink = addLinkCallback
77+
alertDialog.show()
78+
}
79+
80+
// Pre-fills the display name with the url's content if the fields contain the same value.
81+
private fun showDisplayNamePreview() = with(binding) {
82+
displayNameEditText.setTextColor(activityContext.getColor(R.color.tertiaryTextColor))
83+
84+
var areInputsSynced = false
85+
urlEditText.addTextChangedListener(
86+
beforeTextChanged = { text, _, _, _ ->
87+
areInputsSynced = text.toString() == displayNameEditText.text.toString()
88+
},
89+
onTextChanged = { text, _, _, _ ->
90+
if (areInputsSynced || displayNameEditText.text.isNullOrBlank()) displayNameEditText.setText(text)
91+
}
92+
)
93+
94+
displayNameEditText.setOnFocusChangeListener { _, hasFocus ->
95+
val textColor = activityContext.getColor(if (hasFocus) R.color.primaryTextColor else R.color.tertiaryTextColor)
96+
displayNameEditText.setTextColor(textColor)
97+
}
98+
}
99+
100+
private fun resetDialogState() = with(binding) {
101+
urlLayout.setError(null)
102+
displayNameLayout.placeholderText = null
103+
}
104+
105+
private fun setConfirmButtonListener(dialog: DialogInterface) = with(binding) {
106+
(dialog as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener {
107+
val url = addMissingHttpsProtocol(urlEditText.trimmedText)
108+
109+
if (validate(url)) {
110+
val displayName = (displayNameEditText.text?.takeIf { it.isNotBlank() } ?: urlEditText.text).toString().trim()
111+
addLink?.invoke(displayName, url)
112+
113+
dialog.dismiss()
114+
} else {
115+
urlLayout.setError(activityContext.getString(R.string.snackbarInvalidUrl))
116+
}
117+
}
118+
}
119+
120+
private fun addMissingHttpsProtocol(link: String): String {
121+
val protocolEndIndex = link.indexOf(PROTOCOL_SEPARATOR)
122+
val isProtocolSpecified = protocolEndIndex > 0 // If there is a specified protocol and it is at least 1 char long
123+
124+
if (isProtocolSpecified) return link
125+
126+
val strippedUserInput = if (protocolEndIndex == -1) link else link.substring(PROTOCOL_SEPARATOR.length)
127+
128+
return "https://$strippedUserInput"
129+
}
130+
131+
private fun validate(userUrlInput: String): Boolean = Patterns.WEB_URL.matcher(userUrlInput).matches()
132+
133+
companion object {
134+
private const val PROTOCOL_SEPARATOR = "://"
135+
}
136+
}

app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageEditorManager.kt

+50-16
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ package com.infomaniak.mail.ui.newMessage
2020
import android.content.res.ColorStateList
2121
import androidx.core.view.isGone
2222
import androidx.core.view.isVisible
23+
import androidx.lifecycle.lifecycleScope
2324
import com.google.android.material.button.MaterialButton
2425
import com.infomaniak.mail.MatomoMail
2526
import com.infomaniak.mail.MatomoMail.trackEvent
@@ -28,11 +29,12 @@ import com.infomaniak.mail.databinding.FragmentNewMessageBinding
2829
import com.infomaniak.mail.utils.extensions.getAttributeColor
2930
import com.infomaniak.mail.utils.extensions.notYetImplemented
3031
import dagger.hilt.android.scopes.FragmentScoped
32+
import kotlinx.coroutines.launch
3133
import javax.inject.Inject
3234
import com.google.android.material.R as RMaterial
3335

3436
@FragmentScoped
35-
class NewMessageEditorManager @Inject constructor() : NewMessageManager() {
37+
class NewMessageEditorManager @Inject constructor(private val insertLinkDialog: InsertLinkDialog) : NewMessageManager() {
3638

3739
private var _aiManager: NewMessageAiManager? = null
3840
private inline val aiManager: NewMessageAiManager get() = _aiManager!!
@@ -57,19 +59,30 @@ class NewMessageEditorManager @Inject constructor() : NewMessageManager() {
5759
_openFilePicker = openFilePicker
5860
}
5961

60-
fun observeEditorActions() {
62+
fun observeEditorFormatActions() = with(binding) {
6163
newMessageViewModel.editorAction.observe(viewLifecycleOwner) { (editorAction, _) ->
6264
when (editorAction) {
6365
EditorAction.ATTACHMENT -> _openFilePicker?.invoke()
6466
EditorAction.CAMERA -> fragment.notYetImplemented()
65-
EditorAction.LINK -> fragment.notYetImplemented()
67+
EditorAction.LINK -> if (buttonLink.isActivated) {
68+
editorWebView.unlink()
69+
} else {
70+
insertLinkDialog.show { displayText, url ->
71+
editorWebView.createLink(displayText, url)
72+
}
73+
}
6674
EditorAction.CLOCK -> fragment.notYetImplemented()
6775
EditorAction.AI -> aiManager.openAiPrompt()
76+
EditorAction.BOLD -> editorWebView.toggleBold()
77+
EditorAction.ITALIC -> editorWebView.toggleItalic()
78+
EditorAction.UNDERLINE -> editorWebView.toggleUnderline()
79+
EditorAction.STRIKE_THROUGH -> editorWebView.toggleStrikeThrough()
80+
EditorAction.UNORDERED_LIST -> editorWebView.toggleUnorderedList()
6881
}
6982
}
7083
}
7184

72-
fun setupEditorActions() = with(binding) {
85+
fun setupEditorFormatActions() = with(binding) {
7386
fun linkEditor(view: MaterialButton, action: EditorAction) {
7487
view.setOnClickListener {
7588
context.trackEvent("editorActions", action.matomoValue)
@@ -79,34 +92,55 @@ class NewMessageEditorManager @Inject constructor() : NewMessageManager() {
7992

8093
linkEditor(editorAttachment, EditorAction.ATTACHMENT)
8194
linkEditor(editorCamera, EditorAction.CAMERA)
82-
linkEditor(editorLink, EditorAction.LINK)
8395
linkEditor(editorClock, EditorAction.CLOCK)
8496
linkEditor(editorAi, EditorAction.AI)
97+
98+
linkEditor(buttonBold, EditorAction.BOLD)
99+
linkEditor(buttonItalic, EditorAction.ITALIC)
100+
linkEditor(buttonUnderline, EditorAction.UNDERLINE)
101+
linkEditor(buttonStrikeThrough, EditorAction.STRIKE_THROUGH)
102+
linkEditor(buttonList, EditorAction.UNORDERED_LIST)
103+
linkEditor(buttonLink, EditorAction.LINK)
85104
}
86105

87106
fun setupEditorFormatActionsToggle() = with(binding) {
88107
editorTextOptions.setOnClickListener {
89108
newMessageViewModel.isEditorExpanded = !newMessageViewModel.isEditorExpanded
90-
updateEditorVisibility(newMessageViewModel.isEditorExpanded)
109+
updateEditorFormatActionsVisibility(newMessageViewModel.isEditorExpanded)
91110
}
92111
}
93112

94-
private fun updateEditorVisibility(isEditorExpanded: Boolean) = with(binding) {
113+
private fun updateEditorFormatActionsVisibility(isExpanded: Boolean) = with(binding) {
114+
if (isExpanded) editorWebView.requestFocus()
95115

96-
val color = if (isEditorExpanded) {
116+
val color = if (isExpanded) {
97117
context.getAttributeColor(RMaterial.attr.colorPrimary)
98118
} else {
99119
context.getColor(R.color.iconColor)
100120
}
101-
val resId = if (isEditorExpanded) R.string.buttonTextOptionsClose else R.string.buttonTextOptionsOpen
121+
val resId = if (isExpanded) R.string.buttonTextOptionsClose else R.string.buttonTextOptionsOpen
102122

103123
editorTextOptions.apply {
104124
iconTint = ColorStateList.valueOf(color)
105125
contentDescription = context.getString(resId)
106126
}
107127

108-
editorActions.isGone = isEditorExpanded
109-
textEditing.isVisible = isEditorExpanded
128+
editorActions.isGone = isExpanded
129+
sendButton.isGone = isExpanded
130+
formatOptionsScrollView.isVisible = isExpanded
131+
}
132+
133+
fun observeEditorStatus(): Unit = with(binding) {
134+
viewLifecycleOwner.lifecycleScope.launch {
135+
editorWebView.editorStatusesFlow.collect {
136+
buttonBold.isActivated = it.isBold
137+
buttonItalic.isActivated = it.isItalic
138+
buttonUnderline.isActivated = it.isUnderlined
139+
buttonStrikeThrough.isActivated = it.isStrikeThrough
140+
buttonList.isActivated = it.isUnorderedListSelected
141+
buttonLink.isActivated = it.isLinkSelected
142+
}
143+
}
110144
}
111145

112146
enum class EditorAction(val matomoValue: String) {
@@ -115,10 +149,10 @@ class NewMessageEditorManager @Inject constructor() : NewMessageManager() {
115149
LINK("addLink"),
116150
CLOCK(MatomoMail.ACTION_POSTPONE_NAME),
117151
AI("aiWriter"),
118-
// BOLD("bold"),
119-
// ITALIC("italic"),
120-
// UNDERLINE("underline"),
121-
// STRIKE_THROUGH("strikeThrough"),
122-
// UNORDERED_LIST("unorderedList"),
152+
BOLD("bold"),
153+
ITALIC("italic"),
154+
UNDERLINE("underline"),
155+
STRIKE_THROUGH("strikeThrough"),
156+
UNORDERED_LIST("unorderedList"),
123157
}
124158
}

app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageFragment.kt

+39-16
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import androidx.activity.addCallback
3636
import androidx.appcompat.app.AppCompatActivity
3737
import androidx.appcompat.content.res.AppCompatResources
3838
import androidx.constraintlayout.widget.Group
39+
import androidx.core.view.forEach
3940
import androidx.core.view.isGone
4041
import androidx.core.view.isVisible
4142
import androidx.fragment.app.Fragment
@@ -47,6 +48,7 @@ import com.infomaniak.lib.core.utils.SnackbarUtils.showSnackbar
4748
import com.infomaniak.lib.core.utils.getBackNavigationResult
4849
import com.infomaniak.lib.core.utils.isNightModeEnabled
4950
import com.infomaniak.lib.core.utils.showToast
51+
import com.infomaniak.lib.richhtmleditor.StatusCommand.*
5052
import com.infomaniak.mail.MatomoMail.OPEN_FROM_DRAFT_NAME
5153
import com.infomaniak.mail.MatomoMail.trackAttachmentActionsEvent
5254
import com.infomaniak.mail.MatomoMail.trackNewMessageEvent
@@ -183,7 +185,11 @@ class NewMessageFragment : Fragment() {
183185
observeUiQuote()
184186
observeShimmering()
185187

186-
editorManager.observeEditorActions()
188+
with(editorManager) {
189+
observeEditorFormatActions()
190+
observeEditorStatus()
191+
}
192+
187193
externalsManager.observeExternals(newMessageViewModel.arrivedFromExistingDraft())
188194

189195
with(aiManager) {
@@ -342,23 +348,40 @@ class NewMessageFragment : Fragment() {
342348
}
343349

344350
private fun initEditorUi() {
345-
binding.editorWebView.apply {
346-
enableAlgorithmicDarkening(isEnabled = true)
347-
if (context.isNightModeEnabled()) addCss(context.loadCss(R.raw.custom_dark_mode))
351+
binding.editorWebView.subscribeToStates(setOf(BOLD, ITALIC, UNDERLINE, STRIKE_THROUGH, UNORDERED_LIST, CREATE_LINK))
352+
setEditorStyle()
353+
handleEditorPlaceholderVisibility()
354+
355+
setToolbarEnabledStatus(false)
356+
disableButtonsWhenFocusIsLost()
357+
}
348358

349-
val customColors = listOf(PRIMARY_COLOR_CODE to context.getAttributeColor(RMaterial.attr.colorPrimary))
350-
addCss(context.loadCss(R.raw.style, customColors))
351-
addCss(context.loadCss(R.raw.editor_style, customColors))
359+
private fun setEditorStyle() = with(binding.editorWebView) {
360+
enableAlgorithmicDarkening(isEnabled = true)
361+
if (context.isNightModeEnabled()) addCss(context.loadCss(R.raw.custom_dark_mode))
352362

353-
val isPlaceholderVisible = combine(
354-
isEmptyFlow.filterNotNull(),
355-
newMessageViewModel.isShimmering,
356-
) { isEditorEmpty, isShimmering -> isEditorEmpty && !isShimmering }
363+
val customColors = listOf(PRIMARY_COLOR_CODE to context.getAttributeColor(RMaterial.attr.colorPrimary))
364+
addCss(context.loadCss(R.raw.style, customColors))
365+
addCss(context.loadCss(R.raw.editor_style, customColors))
366+
}
357367

358-
isPlaceholderVisible
359-
.onEach { isVisible -> binding.newMessagePlaceholder.isVisible = isVisible }
360-
.launchIn(lifecycleScope)
361-
}
368+
private fun handleEditorPlaceholderVisibility() {
369+
val isPlaceholderVisible = combine(
370+
binding.editorWebView.isEmptyFlow.filterNotNull(),
371+
newMessageViewModel.isShimmering,
372+
) { isEditorEmpty, isShimmering -> isEditorEmpty && !isShimmering }
373+
374+
isPlaceholderVisible
375+
.onEach { isVisible -> binding.newMessagePlaceholder.isVisible = isVisible }
376+
.launchIn(lifecycleScope)
377+
}
378+
379+
private fun disableButtonsWhenFocusIsLost() {
380+
newMessageViewModel.isEditorWebViewFocusedLiveData.observe(viewLifecycleOwner, ::setToolbarEnabledStatus)
381+
}
382+
383+
private fun setToolbarEnabledStatus(isEnabled: Boolean) {
384+
binding.formatOptionsLayout.forEach { view -> view.isEnabled = isEnabled }
362385
}
363386

364387
private fun initializeDraft() = with(newMessageViewModel) {
@@ -505,7 +528,7 @@ class NewMessageFragment : Fragment() {
505528
newMessageViewModel.initResult.observe(viewLifecycleOwner) { (draft, signatures) ->
506529
configureUiWithDraftData(draft)
507530
setupFromField(signatures)
508-
editorManager.setupEditorActions()
531+
editorManager.setupEditorFormatActions()
509532
editorManager.setupEditorFormatActionsToggle()
510533
}
511534
}

app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageRecipientFieldsManager.kt

+5-5
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
*/
1818
package com.infomaniak.mail.ui.newMessage
1919

20-
import android.view.View
2120
import androidx.core.view.isGone
2221
import androidx.core.view.isVisible
2322
import com.infomaniak.mail.data.models.correspondent.Recipient
@@ -121,10 +120,11 @@ class NewMessageRecipientFieldsManager @Inject constructor(private val snackbarM
121120
initializeFieldsAsOpen.observe(viewLifecycleOwner) { openAdvancedFields(isCollapsed = !it) }
122121
}
123122

124-
fun setOnFocusChangedListeners() = with(binding) {
125-
val listener = View.OnFocusChangeListener { _, hasFocus -> if (hasFocus) fieldGotFocus(null) }
126-
subjectTextField.onFocusChangeListener = listener
127-
editorWebView.onFocusChangeListener = listener
123+
fun setOnFocusChangedListeners() = with(newMessageViewModel) {
124+
binding.subjectTextField.setOnFocusChangeListener { _, hasFocus -> if (hasFocus) fieldGotFocus(field = null) }
125+
binding.editorWebView.setOnFocusChangeListener { _, hasFocus -> isEditorWebViewFocusedLiveData.value = hasFocus }
126+
127+
isEditorWebViewFocusedLiveData.observe(viewLifecycleOwner) { hasFocus -> if (hasFocus) fieldGotFocus(field = null) }
128128
}
129129

130130
fun focusBodyField() {

0 commit comments

Comments
 (0)