diff --git a/app/src/main/java/com/infomaniak/mail/MatomoMail.kt b/app/src/main/java/com/infomaniak/mail/MatomoMail.kt index af3921770d9..3ce3bd3556d 100644 --- a/app/src/main/java/com/infomaniak/mail/MatomoMail.kt +++ b/app/src/main/java/com/infomaniak/mail/MatomoMail.kt @@ -282,6 +282,7 @@ object MatomoMail : Matomo { ReplyMaybe("replyMaybe"), ReplyNo("replyNo"), ReplyYes("replyYes"), + ReplyWithEuria("replyWithEuria"), ReprogramSchedule("reprogramSchedule"), RequestPassword("requestPassword"), Restore("restore"), @@ -327,6 +328,7 @@ object MatomoMail : Matomo { Summarize("summarize"), Translate("translate"), AskEuria("askEuria"), + AskEuriaShortCut("askEuriaShortCut"), Switch("switch"), SwitchAccount("switchAccount"), SwitchColor("switchColor"), diff --git a/app/src/main/java/com/infomaniak/mail/data/LocalSettings.kt b/app/src/main/java/com/infomaniak/mail/data/LocalSettings.kt index a15b0f348eb..7ed36c5d56b 100644 --- a/app/src/main/java/com/infomaniak/mail/data/LocalSettings.kt +++ b/app/src/main/java/com/infomaniak/mail/data/LocalSettings.kt @@ -45,6 +45,7 @@ class LocalSettings private constructor(context: Context) : SharedValues { override val sharedPreferences = context.applicationContext.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)!! + var alreadyUsedReplyWithEuria by sharedValue("alreadyUsedReplyWithEuriaKey", false) var cancelDelay by sharedValue("cancelDelayKey", 10) var emailForwarding by sharedValue("emailForwardingKey", EmailForwarding.IN_BODY) var includeMessageInReply by sharedValue("includeMessageInReplyKey", true) diff --git a/app/src/main/java/com/infomaniak/mail/data/cache/AiDraftCache.kt b/app/src/main/java/com/infomaniak/mail/data/cache/AiDraftCache.kt new file mode 100644 index 00000000000..ab9dbc1f033 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/data/cache/AiDraftCache.kt @@ -0,0 +1,27 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.mail.data.cache + +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AiDraftCache @Inject constructor() { + var pendingAiSubject: String? = null + var pendingAiContent: String? = null +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt index bc95be5c611..9567d740352 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadAdapter.kt @@ -1192,6 +1192,10 @@ class ThreadAdapter( isVisible = isExpanded && !message.isScheduledDraft && !message.isScheduledMessage setOnClickListener { threadAdapterCallbacks?.onMenuClicked?.invoke(message) } } + askEuriaButton.apply { + isVisible = isExpanded && !message.isScheduledDraft && !message.isScheduledMessage + setOnClickListener { threadAdapterCallbacks?.onAskEuriaClicked?.invoke(message) } + } sendingProgressText.isVisible = message.isScheduledMessage recipient.text = if (isExpanded) getAllRecipientsFormatted(message) else context.formatSubject(message.subject) @@ -1404,6 +1408,7 @@ class ThreadAdapter( var onAiBannerClose: ((messageUid: String, aiAction: AiAction) -> Unit)? = null, var onShowOriginal: ((messageUid: String) -> Unit)? = null, var getAiState: (() -> AiStateMap)? = null, + var onAskEuriaClicked: ((message: Message) -> Unit)? = null, ) enum class DisplayType(val layout: Int) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt index 60ff0c67cac..9ae5239ad46 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/ThreadFragment.kt @@ -117,6 +117,7 @@ import com.infomaniak.mail.ui.main.thread.actions.AskEuriaBottomSheetDialogArgs import com.infomaniak.mail.ui.main.thread.actions.AttachmentActionsBottomSheetDialogArgs import com.infomaniak.mail.ui.main.thread.actions.ConfirmationToBlockUserDialog import com.infomaniak.mail.ui.main.thread.actions.EmojiReactionsViewModel +import com.infomaniak.mail.ui.main.thread.actions.EuriaPromptBottomSheetArgs import com.infomaniak.mail.ui.main.thread.actions.JunkMessagesViewModel import com.infomaniak.mail.ui.main.thread.actions.MessageActionsBottomSheetDialogArgs import com.infomaniak.mail.ui.main.thread.actions.ReplyBottomSheetDialogArgs @@ -125,6 +126,8 @@ import com.infomaniak.mail.ui.main.thread.actions.ThreadActionsBottomSheetDialog import com.infomaniak.mail.ui.main.thread.calendar.AttendeesBottomSheetDialogArgs import com.infomaniak.mail.ui.main.thread.encryption.UnencryptableRecipientsBottomSheetDialogArgs import com.infomaniak.mail.ui.main.thread.models.MessageUi +import com.infomaniak.mail.ui.newMessage.AiPropositionFragmentArgs +import com.infomaniak.mail.ui.newMessage.AiViewModel import com.infomaniak.mail.utils.FolderRoleUtils import com.infomaniak.mail.utils.PermissionUtils import com.infomaniak.mail.utils.SharedUtils @@ -221,6 +224,7 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { private val junkMessagesViewModel: JunkMessagesViewModel by activityViewModels() private val twoPaneViewModel: TwoPaneViewModel by activityViewModels() private val threadViewModel: ThreadViewModel by viewModels() + private val aiViewModel: AiViewModel by activityViewModels() private val aiActionsViewModel: AiActionsViewModel by viewModels() private val actionsViewModel: ActionsViewModel by activityViewModels() private val emojiReactionsViewModel: EmojiReactionsViewModel by viewModels() @@ -491,6 +495,9 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { onAiBannerClose = { messageUid, aiAction -> aiActionsViewModel.dismissAiAction(messageUid, aiAction) }, onShowOriginal = { messageUid -> aiActionsViewModel.dismissAiAction(messageUid, AiAction.TRANSLATE) }, getAiState = { aiActionsViewModel.aiStateMap.value }, + onAskEuriaClicked = { message -> + trackMessageActionsEvent(MatomoName.AskEuriaShortCut) + navigateToAskEuriaBottomSheet(message.uid) }, ), ) @@ -915,6 +922,27 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { aiActionsViewModel.doAiAction(messageUid, AiAction.TRANSLATE) } } + + getBackNavigationResult(OPEN_AI_REPLY_BOTTOM_SHEET) { (messageUid, _) -> + aiViewModel.resetAiState() + safeNavigate( + resId = R.id.euriaPromptBottomSheetDialog, + args = EuriaPromptBottomSheetArgs( + messageUid = messageUid + ).toBundle(), + ) + } + + getBackNavigationResult(OPEN_AI_REPLY_PROPOSITION) { messageUid: String -> + aiViewModel.aiPropositionStatusLiveData.value = null + safeNavigate( + resId = R.id.aiPropositionFragment, args = AiPropositionFragmentArgs( + isSubjectBlank = true, + isBodyBlank = true, + messageUid = messageUid + ).toBundle() + ) + } } private fun executeSavedSnoozeScheduleType(timestamp: Long) { @@ -1389,8 +1417,10 @@ class ThreadFragment : Fragment(), PickerEmojiObserver { const val OPEN_REACTION_BOTTOM_SHEET = "openReactionBottomSheet" const val OPEN_AI_ACTIONS_BOTTOM_SHEET = "openAiActionsBottomSheet" + const val OPEN_AI_REPLY_BOTTOM_SHEET = "openAiReplyPromptBottomSheet" const val OPEN_AI_SUMMARY_BOTTOM_SHEET = "openAiSummaryBottomSheet" const val OPEN_AI_TRANSLATE_BOTTOM_SHEET = "openAiTranslateBottomSheet" + const val OPEN_AI_REPLY_PROPOSITION = "openAiReplyProposition" private fun allAttachmentsFileName(subject: String) = "infomaniak-mail-attachments-$subject.zip" private fun allSwissTransferFilesName(subject: String) = "infomaniak-mail-swisstransfer-$subject.zip" diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionItemView.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionItemView.kt index 9b1437a8e3c..2e6a580c9ba 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionItemView.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/ActionItemView.kt @@ -55,6 +55,12 @@ class ActionItemView @JvmOverloads constructor( setTrailingContentUi(value) } + var newFeatureBadgeVisible: Boolean + get() = binding.newFeatureBadge.isVisible + set(value) { + binding.newFeatureBadge.isVisible = value + } + init { attrs?.getAttributes(context, R.styleable.ActionItemView) { with(binding) { diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/AskEuriaBottomSheetDialog.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/AskEuriaBottomSheetDialog.kt index a5599a2fd43..8a050ed9027 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/AskEuriaBottomSheetDialog.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/AskEuriaBottomSheetDialog.kt @@ -23,20 +23,36 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.navArgs +import com.infomaniak.core.ksuite.data.KSuite import com.infomaniak.core.legacy.utils.safeBinding import com.infomaniak.core.legacy.utils.setBackNavigationResult import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackBottomSheetThreadActionsEvent +import com.infomaniak.mail.data.LocalSettings +import com.infomaniak.mail.data.models.extensions.kSuite import com.infomaniak.mail.databinding.BottomSheetAskEuriaActionsBinding +import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.main.thread.AiActionNavigationResult +import com.infomaniak.mail.ui.main.thread.ThreadFragment.Companion.OPEN_AI_REPLY_BOTTOM_SHEET import com.infomaniak.mail.ui.main.thread.ThreadFragment.Companion.OPEN_AI_SUMMARY_BOTTOM_SHEET import com.infomaniak.mail.ui.main.thread.ThreadFragment.Companion.OPEN_AI_TRANSLATE_BOTTOM_SHEET +import com.infomaniak.mail.ui.main.thread.actions.ActionItemView.TrailingContent import com.infomaniak.mail.ui.main.thread.actions.multiselection.MultiselectionViewModel +import com.infomaniak.mail.utils.openKSuiteProBottomSheet +import com.infomaniak.mail.utils.openMailPremiumBottomSheet +import com.infomaniak.mail.utils.openMyKSuiteUpgradeBottomSheet +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject +@AndroidEntryPoint class AskEuriaBottomSheetDialog : ActionsBottomSheetDialog() { private var binding: BottomSheetAskEuriaActionsBinding by safeBinding() override val multiselectionViewModel: MultiselectionViewModel by activityViewModels() + private val mainViewModel: MainViewModel by activityViewModels() + + @Inject + lateinit var localSettings: LocalSettings private val navigationArgs: AskEuriaBottomSheetDialogArgs by navArgs() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -55,6 +71,41 @@ class AskEuriaBottomSheetDialog : ActionsBottomSheetDialog() { trackBottomSheetThreadActionsEvent(MatomoName.Translate) setBackNavigationResult(OPEN_AI_TRANSLATE_BOTTOM_SHEET, AiActionNavigationResult(messageUid, isAlreadyTranslated)) } + + setupReplyAction(messageUid) + } + + private fun setupReplyAction(messageUid: String) { + val mailbox = mainViewModel.currentMailbox.value + val kSuite = mailbox?.kSuite + + val matomoName = MatomoName.AiWriter.value + binding.reply.trailingContent = when (kSuite) { + KSuite.Perso.Free -> TrailingContent.KSuitePersoChip + KSuite.Pro.Free, KSuite.StarterPack -> TrailingContent.KSuiteProChip + else -> { + binding.reply.newFeatureBadgeVisible = !localSettings.alreadyUsedReplyWithEuria + TrailingContent.None + } + } + + if (binding.reply.trailingContent != TrailingContent.None) { + binding.reply.setOnClickListener { + when (kSuite) { + KSuite.Perso.Free -> openMyKSuiteUpgradeBottomSheet(matomoName) + KSuite.Pro.Free -> openKSuiteProBottomSheet(kSuite, mailbox.isAdmin, matomoName) + KSuite.StarterPack -> openMailPremiumBottomSheet(matomoName) + else -> Unit + } + } + } else { + binding.reply.setOnClickListener { + trackBottomSheetThreadActionsEvent(MatomoName.ReplyWithEuria) + localSettings.alreadyUsedReplyWithEuria = true + binding.reply.newFeatureBadgeVisible = false + setBackNavigationResult(OPEN_AI_REPLY_BOTTOM_SHEET, AiActionNavigationResult(messageUid, false)) + } + } } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/EuriaPromptBottomSheet.kt b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/EuriaPromptBottomSheet.kt new file mode 100644 index 00000000000..e402a4c774f --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/ui/main/thread/actions/EuriaPromptBottomSheet.kt @@ -0,0 +1,60 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package com.infomaniak.mail.ui.main.thread.actions + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.activityViewModels +import androidx.navigation.fragment.navArgs +import com.infomaniak.core.legacy.utils.safeBinding +import com.infomaniak.core.legacy.utils.setBackNavigationResult +import com.infomaniak.mail.R +import com.infomaniak.mail.databinding.ViewAiPromptBinding +import com.infomaniak.mail.ui.bottomSheetDialogs.EdgeToEdgeBottomSheetDialog +import com.infomaniak.mail.ui.main.thread.ThreadFragment.Companion.OPEN_AI_REPLY_PROPOSITION +import com.infomaniak.mail.ui.newMessage.AiViewModel +import dagger.hilt.android.AndroidEntryPoint + +@AndroidEntryPoint +class EuriaPromptBottomSheet : EdgeToEdgeBottomSheetDialog() { + + private var binding: ViewAiPromptBinding by safeBinding() + private val aiViewModel: AiViewModel by activityViewModels() + private val navigationArgs: EuriaPromptBottomSheetArgs by navArgs() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + return ViewAiPromptBinding.inflate(inflater, container, false).also { binding = it }.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.root.bind( + prompt = aiViewModel.aiPrompt, + placeholder = getString(R.string.aiPromptAnswer), + onCloseClick = { dismiss() }, + onGenerateClick = { + setBackNavigationResult(OPEN_AI_REPLY_PROPOSITION, navigationArgs.messageUid) + }, + onPromptChanged = { aiViewModel.aiPrompt = it }, + ) + binding.root.focusPrompt() + } +} diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt index 00e935c0813..0c81b7aee23 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPromptFragment.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2023-2025 Infomaniak Network SA + * Copyright (C) 2023-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,62 +19,41 @@ package com.infomaniak.mail.ui.newMessage import android.content.res.Configuration import android.os.Bundle -import android.text.Editable -import android.text.TextWatcher import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.widget.doAfterTextChanged import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.withStarted import com.infomaniak.core.legacy.utils.safeBinding -import com.infomaniak.core.legacy.utils.showKeyboard import com.infomaniak.core.ui.view.extension.setMargins import com.infomaniak.core.ui.view.extension.setMarginsRelative import com.infomaniak.core.ui.view.utils.toPx import com.infomaniak.mail.R -import com.infomaniak.mail.data.LocalSettings -import com.infomaniak.mail.databinding.FragmentAiPromptBinding +import com.infomaniak.mail.databinding.ViewAiPromptBinding import com.infomaniak.mail.utils.extensions.applyWindowInsetsListener import com.infomaniak.mail.utils.extensions.ime -import com.infomaniak.mail.utils.extensions.systemBars import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import javax.inject.Inject import com.google.android.material.R as RMaterial @AndroidEntryPoint class AiPromptFragment : Fragment() { - @Inject - lateinit var localSettings: LocalSettings - - private var binding: FragmentAiPromptBinding by safeBinding() + private var binding: ViewAiPromptBinding by safeBinding() private val aiViewModel: AiViewModel by activityViewModels() - private val newMessageFragment by lazy { parentFragment as NewMessageFragment } - private val promptTextWatcher by lazy { - object : TextWatcher { - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit - override fun afterTextChanged(s: Editable?) = onPromptChanged(s) - } - } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - return FragmentAiPromptBinding.inflate(inflater, container, false).also { binding = it }.root + return ViewAiPromptBinding.inflate(inflater, container, false).also { binding = it }.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + binding.applyWindowInsetsListener { root, insets -> val imeBottomInsets = insets.ime().bottom root.setMargins(bottom = imeBottomInsets) - binding.aiCardLayout.setMargins(bottom = if (imeBottomInsets == 0) insets.systemBars().bottom else 0) } setUi() } @@ -84,78 +63,40 @@ class AiPromptFragment : Fragment() { setCorrectSheetMargins() } - private fun setUi() = with(binding) { + private fun setUi() = with(binding.root) { setCorrectSheetMargins() - - prompt.showKeyboard() - initPromptTextAndPlaceholder() - closeButton.setOnClickListener { newMessageFragment.closeAiPrompt() } - - generateButton.setOnClickListener { - viewLifecycleOwner.lifecycleScope.launch { - newMessageFragment.navigateToPropositionFragment() - } - } - - // When the app is recreated or the prompt is opened when coming back from AiPropositionFragment, - // the enabled state of the button is not recomputed when using `onPromptChanged()`. This means - // that the button may remain disabled even though it should be enabled based on the current - // prompt. `onPromptChanged()` is not enough which is why it's done in `doAfterTextChanged()`. - prompt.doAfterTextChanged(::updateButtonEnabledState) + focusPrompt() + + bind( + prompt = aiViewModel.aiPrompt, + placeholder = getPromptPlaceholder(), + onCloseClick = { newMessageFragment.closeAiPrompt() }, + onGenerateClick = { + viewLifecycleOwner.lifecycleScope.launch { + newMessageFragment.navigateToPropositionFragment() + } + }, + onPromptChanged = { aiViewModel.aiPrompt = it }, + ) } private fun setCorrectSheetMargins() = with(binding.root) { - val m3BottomSheetMaxWidthPx = resources.getDimension(RMaterial.dimen.material_bottom_sheet_max_width) - val m3BottomSheetHorizontalMarginPx = 56.toPx(this) - - val screenWidth = resources.displayMetrics.widthPixels - val isScreenTooBig = screenWidth > m3BottomSheetMaxWidthPx - val horizontalMargin = if (isScreenTooBig) m3BottomSheetHorizontalMarginPx else NO_MARGIN - + val maxWidthPx = resources.getDimension(RMaterial.dimen.material_bottom_sheet_max_width) + val horizontalMarginPx = 56.toPx(this) + val horizontalMargin = if (resources.displayMetrics.widthPixels > maxWidthPx) horizontalMarginPx else 0 setMarginsRelative(start = horizontalMargin, end = horizontalMargin) } - private fun initPromptTextAndPlaceholder() = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { - viewLifecycleOwner.withStarted { - with(binding.prompt) { - setText(aiViewModel.aiPrompt) - setSelection(length()) - - val isReplying = aiViewModel.previousMessageBodyPlainText != null - val placeholder = if (isReplying) getReplyPlaceholder() else getPlaceholderFromScratch() - hint = placeholder - } + private fun getPromptPlaceholder(): String { + val isReplying = aiViewModel.previousMessageBodyPlainText != null + return if (isReplying) { + getString(R.string.aiPromptAnswer) + } else { + getString(R.string.aiPromptPlaceholder, getString(promptExamples.random())) } } - private fun getReplyPlaceholder(): String = getString(R.string.aiPromptAnswer) - - private fun getPlaceholderFromScratch(): String { - val promptExample = getString(promptExamples.random()) - return getString(R.string.aiPromptPlaceholder, promptExample) - } - - private fun updateButtonEnabledState(prompt: Editable?) { - binding.generateButton.isEnabled = prompt?.isNotEmpty() == true - } - - override fun onResume() { - super.onResume() - binding.prompt.addTextChangedListener(promptTextWatcher) - } - - override fun onPause() { - binding.prompt.removeTextChangedListener(promptTextWatcher) - super.onPause() - } - - private fun onPromptChanged(prompt: Editable?) { - aiViewModel.aiPrompt = prompt.toString() - } - companion object { - private const val NO_MARGIN = 0 - private val promptExamples = listOf( R.string.aiPromptExample1, R.string.aiPromptExample2, diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPropositionFragment.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPropositionFragment.kt index 35a311638bc..ff3719cf8c8 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPropositionFragment.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiPropositionFragment.kt @@ -1,6 +1,6 @@ /* * Infomaniak Mail - Android - * Copyright (C) 2023-2024 Infomaniak Network SA + * Copyright (C) 2023-2026 Infomaniak Network SA * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -19,6 +19,7 @@ package com.infomaniak.mail.ui.newMessage +import android.R.id.message import android.os.Bundle import android.transition.AutoTransition import android.transition.ChangeBounds @@ -40,23 +41,36 @@ import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.infomaniak.core.fragmentnavigation.safelyNavigate +import com.infomaniak.core.legacy.utils.getBackNavigationResult import com.infomaniak.core.matomo.Matomo.TrackerAction import com.infomaniak.core.ui.view.extension.setMargins import com.infomaniak.mail.MatomoMail.MatomoName import com.infomaniak.mail.MatomoMail.trackAiWriterEvent import com.infomaniak.mail.R import com.infomaniak.mail.data.LocalSettings +import com.infomaniak.mail.data.cache.AiDraftCache +import com.infomaniak.mail.data.cache.mailboxContent.MessageController import com.infomaniak.mail.data.models.ai.AiPromptOpeningStatus +import com.infomaniak.mail.data.models.correspondent.Recipient +import com.infomaniak.mail.data.models.draft.Draft.DraftMode +import com.infomaniak.mail.data.models.extensions.getRecipientsForReplyTo +import com.infomaniak.mail.data.models.mailbox.Mailbox import com.infomaniak.mail.databinding.DialogAiReplaceContentBinding import com.infomaniak.mail.databinding.FragmentAiPropositionBinding +import com.infomaniak.mail.ui.MainViewModel import com.infomaniak.mail.ui.alertDialogs.AiDescriptionAlertDialog +import com.infomaniak.mail.ui.main.thread.ThreadFragment.Companion.OPEN_AI_REPLY_PROPOSITION +import com.infomaniak.mail.ui.main.thread.actions.EuriaPromptBottomSheetArgs import com.infomaniak.mail.ui.newMessage.AiViewModel.PropositionStatus import com.infomaniak.mail.ui.newMessage.AiViewModel.Shortcut +import com.infomaniak.mail.utils.MessageBodyUtils.asPlainText import com.infomaniak.mail.utils.SimpleIconPopupMenu import com.infomaniak.mail.utils.extensions.applyStatusBarInsets import com.infomaniak.mail.utils.extensions.applyWindowInsetsListener import com.infomaniak.mail.utils.extensions.changeToolbarColorOnScroll import com.infomaniak.mail.utils.extensions.safeArea +import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity import com.infomaniak.mail.utils.extensions.valueOrEmpty import dagger.hilt.android.AndroidEntryPoint import io.sentry.Sentry @@ -73,11 +87,13 @@ class AiPropositionFragment : Fragment() { private var _binding: FragmentAiPropositionBinding? = null private val binding get() = _binding!! // This property is only valid between onCreateView and onDestroyView private val newMessageViewModel: NewMessageViewModel by activityViewModels() + private val mainViewModel: MainViewModel by activityViewModels() private val aiViewModel: AiViewModel by activityViewModels() private val navigationArgs: AiPropositionFragmentArgs by navArgs() private var currentRequestJob: Job? = null + lateinit var mailbox: Mailbox private val refinePopupMenu by lazy { SimpleIconPopupMenu(requireContext(), R.menu.ai_refining_options, binding.refineButton, ::onMenuItemClicked) @@ -93,6 +109,12 @@ class AiPropositionFragment : Fragment() { @Inject lateinit var localSettings: LocalSettings + @Inject + lateinit var aiDraftCache: AiDraftCache + + @Inject + lateinit var messageController: MessageController + @Inject lateinit var subjectReplacementDialog: AiDescriptionAlertDialog @@ -102,12 +124,15 @@ class AiPropositionFragment : Fragment() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + initMailbox() + handleEdgeToEdge() handleBackDispatcher() setUi() if (aiViewModel.aiPropositionStatusLiveData.value == null) generateNewAiProposition() observeAiProposition() + observeBackNavigationResult() } override fun onDestroyView() { @@ -125,6 +150,16 @@ class AiPropositionFragment : Fragment() { requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { trackDismissalAndPopBack() } } + private fun initMailbox() { + viewLifecycleOwner.lifecycleScope.launch { + mailbox = if (isHostedByNewMessageActivity()) { + newMessageViewModel.currentMailbox() + } else { + mainViewModel.currentMailbox.value ?: return@launch + } + } + } + private fun handleEdgeToEdge() = with(binding) { applyWindowInsetsListener { _, insets -> appBarLayout.applyStatusBarInsets(insets) @@ -157,11 +192,18 @@ class AiPropositionFragment : Fragment() { retryButton.setOnClickListener { trackAiWriterEvent(MatomoName.Retry) - aiViewModel.aiPromptOpeningStatus.value = AiPromptOpeningStatus( - isOpened = true, - shouldResetPrompt = aiViewModel.aiPropositionStatusLiveData.value != PropositionStatus.CONTEXT_TOO_LONG, - ) - findNavController().popBackStack() + if (isHostedByNewMessageActivity()) { + aiViewModel.aiPromptOpeningStatus.value = AiPromptOpeningStatus( + isOpened = true, + shouldResetPrompt = aiViewModel.aiPropositionStatusLiveData.value != PropositionStatus.CONTEXT_TOO_LONG, + ) + findNavController().popBackStack() + } else { + safelyNavigate( + resId = R.id.euriaPromptBottomSheetDialog, + args = EuriaPromptBottomSheetArgs(messageUid = navigationArgs.messageUid).toBundle(), + ) + } } errorBlock.setOnCloseListener { @@ -171,6 +213,15 @@ class AiPropositionFragment : Fragment() { } } + private fun isHostedByNewMessageActivity() = requireActivity() is NewMessageActivity + + private fun observeBackNavigationResult() { + getBackNavigationResult(OPEN_AI_REPLY_PROPOSITION) { _ -> + aiViewModel.aiPropositionStatusLiveData.value = null + generateNewAiProposition() + } + } + private fun setToolbar() = with(binding) { changeToolbarColorOnScroll(appBarLayout, nestedScrollView) toolbar.setNavigationOnClickListener { trackDismissalAndPopBack() } @@ -184,8 +235,14 @@ class AiPropositionFragment : Fragment() { private fun choosePropositionAndPopBack() = with(aiViewModel) { fun applyProposition(subject: String?, content: String) { + if (isHostedByNewMessageActivity()) { + aiOutputToInsert.value = subject to content + } else { + navigateToNewMessageActivityWithAiContent(subject, content) + return + } + trackInsertionType() - aiOutputToInsert.value = subject to content findNavController().popBackStack() } @@ -214,6 +271,19 @@ class AiPropositionFragment : Fragment() { } } + private fun navigateToNewMessageActivityWithAiContent(subject: String?, content: String) { + aiDraftCache.pendingAiSubject = subject + aiDraftCache.pendingAiContent = content + safeNavigateToNewMessageActivity( + args = NewMessageActivityArgs( + draftMode = DraftMode.REPLY_ALL, + previousMessageUid = navigationArgs.messageUid, + shouldLoadDistantResources = true, + ).toBundle(), + ) + findNavController().popBackStack() + } + private fun trackInsertionType() { trackAiWriterEvent( name = if (navigationArgs.isBodyBlank) MatomoName.InsertProposition else MatomoName.ReplaceProposition, @@ -226,13 +296,20 @@ class AiPropositionFragment : Fragment() { trackAiWriterEvent(shortcut.matomoName) if (shortcut == Shortcut.MODIFY) { - aiPromptOpeningStatus.value = AiPromptOpeningStatus(isOpened = true, shouldResetPrompt = false) - findNavController().popBackStack() + if (isHostedByNewMessageActivity()) { + aiPromptOpeningStatus.value = AiPromptOpeningStatus(isOpened = true, shouldResetPrompt = false) + findNavController().popBackStack() + } else { + safelyNavigate( + resId = R.id.euriaPromptBottomSheetDialog, + args = EuriaPromptBottomSheetArgs(messageUid = navigationArgs.messageUid).toBundle(), + ) + } } else { binding.loadingPlaceholder.text = getLastMessage() aiPropositionStatusLiveData.value = null lifecycleScope.launch { - currentRequestJob = performShortcut(shortcut, newMessageViewModel.currentMailbox().uuid) + currentRequestJob = performShortcut(shortcut, mailbox.uuid) } } } @@ -253,12 +330,21 @@ class AiPropositionFragment : Fragment() { } private fun generateNewAiProposition() { - val formattedRecipientsString = newMessageViewModel.toLiveData.valueOrEmpty() - .joinToString(separator = ", ") { it.name } - .takeIf { it.isNotBlank() } + lifecycleScope.launch { - val currentMailboxUuid = newMessageViewModel.currentMailbox().uuid - currentRequestJob = aiViewModel.generateNewAiProposition(currentMailboxUuid, formattedRecipientsString) + var allRecipients: List + if (isHostedByNewMessageActivity()) { + allRecipients = newMessageViewModel.toLiveData.valueOrEmpty() + } else { + val message = messageController.getMessage(navigationArgs.messageUid) + val recipients = message?.getRecipientsForReplyTo(replyAll = true) + allRecipients = recipients?.first.orEmpty() + recipients?.second.orEmpty() // to + cc + } + + val formattedRecipientsString = allRecipients + .joinToString(separator = ", ") { it.name.ifBlank { it.email } } + .takeIf { it.isNotBlank() } + currentRequestJob = aiViewModel.generateNewAiProposition(mailbox.uuid, formattedRecipientsString) } } @@ -324,6 +410,7 @@ class AiPropositionFragment : Fragment() { } private fun displayLoadingVisibility() = with(binding) { + loadingPlaceholder.text = aiViewModel.aiPrompt loadingPlaceholder.isVisible = true setGenerationLoaderVisible(true) diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiViewModel.kt index 47187d2b3d7..fe32d1f4768 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/AiViewModel.kt @@ -70,9 +70,16 @@ class AiViewModel @Inject constructor( var aiPromptOpeningStatus = MutableLiveData() var previousMessageBodyPlainText by aiSharedData::previousMessageBodyPlainText - val aiPropositionStatusLiveData = MutableLiveData() + val aiPropositionStatusLiveData = MutableLiveData() val aiOutputToInsert = SingleLiveEvent>() + fun resetAiState() { + aiPrompt = "" + history.clear() + conversationContextId = null + aiPropositionStatusLiveData.value = null + } + fun generateNewAiProposition( currentMailboxUuid: String, formattedRecipientsString: String?, diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt index 1440718db39..b9805304ab4 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/BodyContentPayload.kt @@ -17,6 +17,8 @@ */ package com.infomaniak.mail.ui.newMessage +import com.infomaniak.mail.ui.newMessage.EditorContentManager.Companion.toSanitizedHtml + /** * @param content The string representation of the body in either html or plain text format. * @param type The type of representation of [content]. Each type will lead to different processing of the content. @@ -26,6 +28,11 @@ data class BodyContentPayload(val content: String, val type: BodyContentType) { companion object { // Add some empty lines in the body, so the body focuses on these lines and not in the signature fun emptyBody() = BodyContentPayload(content = "


", type = BodyContentType.HTML_SANITIZED) + + fun bodyOf(body: BodyContentPayload) = BodyContentPayload( + content = "
${body.toSanitizedHtml()}

", + type = BodyContentType.HTML_SANITIZED + ) } } diff --git a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt index feffa90df38..5821216f4cd 100644 --- a/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt +++ b/app/src/main/java/com/infomaniak/mail/ui/newMessage/NewMessageViewModel.kt @@ -54,6 +54,7 @@ import com.infomaniak.mail.MatomoMail.trackSendingDraftEvent import com.infomaniak.mail.R import com.infomaniak.mail.data.LocalSettings import com.infomaniak.mail.data.api.ApiRepository +import com.infomaniak.mail.data.cache.AiDraftCache import com.infomaniak.mail.data.cache.RealmDatabase import com.infomaniak.mail.data.cache.mailboxContent.DraftController import com.infomaniak.mail.data.cache.mailboxContent.MessageController @@ -97,10 +98,10 @@ import com.infomaniak.mail.utils.DraftInitManager import com.infomaniak.mail.utils.HtmlFormatter.Companion.MENTIONS_STYLE import com.infomaniak.mail.utils.JsoupParserUtil.jsoupParseWithLog import com.infomaniak.mail.utils.LocalStorageUtils -import com.infomaniak.mail.utils.MessageBodyUtils import com.infomaniak.mail.utils.MessageBodyUtils.EDITOR_LOCAL_SIGNATURE_ID import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_FORWARD_QUOTE_HTML_CLASS_NAME import com.infomaniak.mail.utils.MessageBodyUtils.INFOMANIAK_REPLY_QUOTE_HTML_CLASS_NAME +import com.infomaniak.mail.utils.MessageBodyUtils.asPlainText import com.infomaniak.mail.utils.MessageBodyUtils.isHtmlBlank import com.infomaniak.mail.utils.MessageBodyUtils.splitSignatureAndQuoteFromBody import com.infomaniak.mail.utils.SentryDebug @@ -109,7 +110,6 @@ import com.infomaniak.mail.utils.Utils import com.infomaniak.mail.utils.coroutineContext import com.infomaniak.mail.utils.extensions.AttachmentExt.findSpecificAttachment import com.infomaniak.mail.utils.extensions.appContext -import com.infomaniak.mail.utils.extensions.htmlToText import com.infomaniak.mail.utils.extensions.valueOrEmpty import com.infomaniak.mail.utils.uploadAttachmentsWithMutex import dagger.hilt.android.lifecycle.HiltViewModel @@ -158,6 +158,7 @@ class NewMessageViewModel @Inject constructor( application: Application, private val savedStateHandle: SavedStateHandle, private val aiSharedData: AiSharedData, + private val aiDraftCache: AiDraftCache, private val draftController: DraftController, private val globalCoroutineScope: CoroutineScope, private val mailboxContentRealm: RealmDatabase.MailboxContent, @@ -368,6 +369,17 @@ class NewMessageViewModel @Inject constructor( dismissNotification() markAsRead(currentMailbox(), realm) + if (isNewMessage) { + aiDraftCache.pendingAiContent?.let { pendingAiContent -> + aiDraftCache.pendingAiSubject?.let { subject -> draft.subject = subject } + initialBody = BodyContentPayload.bodyOf( + BodyContentPayload(pendingAiContent, BodyContentType.TEXT_PLAIN_WITHOUT_HTML) + ) + aiDraftCache.pendingAiSubject = null + aiDraftCache.pendingAiContent = null + } + } + realm.write { DraftController.upsertDraftBlocking(it, realm = this) } it.initLiveData(signatures) @@ -673,18 +685,6 @@ class NewMessageViewModel @Inject constructor( } } - private suspend fun Body.asPlainText(): String? { - // TODO: When the API handles blank characters, remove ifBlank - return when (type) { - Utils.TEXT_HTML -> { - val splitBodyContent = MessageBodyUtils.splitContentAndQuote(this).content - val fullBody = MessageBodyUtils.mergeSplitBodyAndSubBodies(splitBodyContent, subBodies) - fullBody.htmlToText() - } - else -> value - }.ifBlank { null } - } - private suspend fun handleSingleSendIntent(draft: Draft, intent: Intent) = with(intent) { if (hasExtra(Intent.EXTRA_EMAIL)) { diff --git a/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt index d0e8d0d7ae6..16167521c14 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/MessageBodyUtils.kt @@ -107,6 +107,18 @@ object MessageBodyUtils { ) } + suspend fun Body.asPlainText(): String? { + // TODO: When the API handles blank characters, remove ifBlank + return when (type) { + Utils.TEXT_HTML -> { + val splitBodyContent = splitContentAndQuote(this).content + val fullBody = mergeSplitBodyAndSubBodies(splitBodyContent, subBodies) + fullBody.htmlToText() + } + else -> value + }.ifBlank { null } + } + private fun CoroutineScope.splitContentAndQuotes(htmlDocument: Document): Pair> { val quotes = mutableListOf() diff --git a/app/src/main/java/com/infomaniak/mail/utils/date/DateFormatUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/date/DateFormatUtils.kt index 43b9380eb4d..9209081d6fe 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/date/DateFormatUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/date/DateFormatUtils.kt @@ -32,9 +32,12 @@ object DateFormatUtils { private const val FORMAT_DATE_12_HOUR = "hh:mm a" private const val FORMAT_DATE_WITH_YEAR = "d MMM yyyy" private const val FORMAT_DATE_WITHOUT_YEAR = "d MMM" + private const val FORMAT_DATE_SHORT_NUMERIC = "dd/MM/yy" fun Context.formatTime(date: Date): String = date.format(localHourFormat()) + fun shortDateWithoutYear(date: Date): String = date.format(FORMAT_DATE_WITHOUT_YEAR) + fun Context.fullDateWithoutYear(date: Date): String = formatDateTime(date, FORMAT_DATE_WITHOUT_YEAR, localHourFormat()) fun Context.fullDateWithYear(date: Date): String = formatDateTime(date, FORMAT_DATE_WITH_YEAR, localHourFormat()) @@ -43,6 +46,8 @@ object DateFormatUtils { fun Context.dayOfWeekDateWithYear(date: Date): String = formatDateTime(date, FORMAT_DATE_DAY_MONTH_YEAR, localHourFormat()) + fun shortNumericDate(date: Date): String = date.format(FORMAT_DATE_SHORT_NUMERIC) + private fun Context.formatDateTime(date: Date, dateFormat: String, timeFormat: String) = getString( R.string.messageDetailsDateAt, date.format(dateFormat), diff --git a/app/src/main/java/com/infomaniak/mail/utils/date/MailDateFormatUtils.kt b/app/src/main/java/com/infomaniak/mail/utils/date/MailDateFormatUtils.kt index 791af096e1e..cfef65cb2cc 100644 --- a/app/src/main/java/com/infomaniak/mail/utils/date/MailDateFormatUtils.kt +++ b/app/src/main/java/com/infomaniak/mail/utils/date/MailDateFormatUtils.kt @@ -27,8 +27,8 @@ import com.infomaniak.mail.R import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithYear import com.infomaniak.mail.utils.date.DateFormatUtils.dayOfWeekDateWithoutYear import com.infomaniak.mail.utils.date.DateFormatUtils.formatTime -import com.infomaniak.mail.utils.date.DateFormatUtils.fullDateWithYear -import com.infomaniak.mail.utils.date.DateFormatUtils.fullDateWithoutYear +import com.infomaniak.mail.utils.date.DateFormatUtils.shortDateWithoutYear +import com.infomaniak.mail.utils.date.DateFormatUtils.shortNumericDate import java.time.format.FormatStyle import java.util.Date @@ -37,13 +37,9 @@ object MailDateFormatUtils { fun Context.mailFormattedDate(date: Date): CharSequence = with(date) { return when { isToday() -> formatTime(this) - isYesterday() -> getString( - R.string.messageDetailsDateAt, - getString(R.string.messageDetailsYesterday), - formatTime(this), - ) - isThisYear() -> fullDateWithoutYear(this) - else -> fullDateWithYear(this) + isYesterday() -> getString(R.string.messageDetailsYesterday) + isThisYear() -> shortDateWithoutYear(this) + else -> shortNumericDate(this) } } diff --git a/app/src/main/java/com/infomaniak/mail/views/AiPromptView.kt b/app/src/main/java/com/infomaniak/mail/views/AiPromptView.kt new file mode 100644 index 00000000000..be99a60c7f4 --- /dev/null +++ b/app/src/main/java/com/infomaniak/mail/views/AiPromptView.kt @@ -0,0 +1,63 @@ +/* + * Infomaniak Mail - Android + * Copyright (C) 2026 Infomaniak Network SA + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.infomaniak.mail.views + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.FrameLayout +import androidx.core.widget.doAfterTextChanged +import com.infomaniak.core.legacy.utils.showKeyboard +import com.infomaniak.mail.databinding.ViewAiPromptContentBinding + +class AiPromptView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0, +) : FrameLayout(context, attrs, defStyleAttr) { + + private val binding = ViewAiPromptContentBinding.inflate(LayoutInflater.from(context), this, true) + private var onPromptChanged: ((String) -> Unit)? = null + + fun bind( + prompt: CharSequence?, + placeholder: CharSequence, + onCloseClick: () -> Unit, + onGenerateClick: () -> Unit, + onPromptChanged: (String) -> Unit, + ) = with(binding) { + this@AiPromptView.onPromptChanged = onPromptChanged + + this.prompt.setText(prompt) + this.prompt.setSelection(this.prompt.text?.length ?: 0) + this.prompt.hint = placeholder + + closeButton.setOnClickListener { onCloseClick() } + generateButton.setOnClickListener { onGenerateClick() } + + this.prompt.doAfterTextChanged { text -> + generateButton.isEnabled = text?.isNotEmpty() == true + this@AiPromptView.onPromptChanged?.invoke(text?.toString().orEmpty()) + } + + generateButton.isEnabled = this.prompt.text?.isNotEmpty() == true + } + + fun focusPrompt() = binding.prompt.showKeyboard() +} diff --git a/app/src/main/res/drawable/bg_new_feature_badge.xml b/app/src/main/res/drawable/bg_new_feature_badge.xml new file mode 100644 index 00000000000..5323ed953a4 --- /dev/null +++ b/app/src/main/res/drawable/bg_new_feature_badge.xml @@ -0,0 +1,22 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_reply_euria.xml b/app/src/main/res/drawable/ic_reply_euria.xml new file mode 100644 index 00000000000..b6ad0e11f0f --- /dev/null +++ b/app/src/main/res/drawable/ic_reply_euria.xml @@ -0,0 +1,27 @@ + + + + diff --git a/app/src/main/res/layout/bottom_sheet_ask_euria_actions.xml b/app/src/main/res/layout/bottom_sheet_ask_euria_actions.xml index 950f5cc3b6e..3c5286af734 100644 --- a/app/src/main/res/layout/bottom_sheet_ask_euria_actions.xml +++ b/app/src/main/res/layout/bottom_sheet_ask_euria_actions.xml @@ -30,6 +30,13 @@ app:title="@string/actionSummarize" app:visibleDivider="false" /> + + + + + diff --git a/app/src/main/res/layout/item_message.xml b/app/src/main/res/layout/item_message.xml index fa43bcbd84b..8bb9e61fdfb 100644 --- a/app/src/main/res/layout/item_message.xml +++ b/app/src/main/res/layout/item_message.xml @@ -175,6 +175,14 @@ app:iconTint="@color/redDestructiveAction" tools:visibility="visible" /> + + + diff --git a/app/src/main/res/layout/fragment_ai_prompt.xml b/app/src/main/res/layout/view_ai_prompt_content.xml similarity index 99% rename from app/src/main/res/layout/fragment_ai_prompt.xml rename to app/src/main/res/layout/view_ai_prompt_content.xml index b3f2911c872..8d06e0d66b7 100644 --- a/app/src/main/res/layout/fragment_ai_prompt.xml +++ b/app/src/main/res/layout/view_ai_prompt_content.xml @@ -1,6 +1,6 @@