Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ class LocalSettings private constructor(context: Context) : SharedValues {
var askEmailAcknowledgement by sharedValue("askEmailAcknowledgmentKey", false)
var hasAlreadyEnabledNotifications by sharedValue("hasAlreadyEnabledNotificationsKey", false)
var isAppLocked by sharedValue("isAppLockedKey", false)
var leftPaneRatio by sharedValue("leftPaneRatioKey", 0.4f)
var threadDensity by sharedValue("threadDensityKey", ThreadDensity.LARGE)
var theme by sharedValue("themeKey", if (SDK_INT >= 29) Theme.SYSTEM else Theme.LIGHT)
var accentColor by sharedValue("accentColorKey", AccentColor.PINK)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ class ThreadListFragment : TwoPaneFragment(), PickerEmojiObserver {

override fun getRightPane(): FragmentContainerView? = _binding?.threadHostFragment

override fun getDragSeparator(): View? = _binding?.dragSeparator

override fun getAnchor(): View? {
return if (isOnlyRightShown()) {
_binding?.threadHostFragment?.getFragment<ThreadFragment?>()?.getAnchor()
Expand Down
137 changes: 118 additions & 19 deletions app/src/main/java/com/infomaniak/mail/ui/main/folder/TwoPaneFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,14 @@
*/
package com.infomaniak.mail.ui.main.folder

import android.annotation.SuppressLint
import android.content.res.Configuration
import android.os.Build.VERSION.SDK_INT
import android.os.Bundle
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
import androidx.core.content.res.ResourcesCompat
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentContainerView
Expand Down Expand Up @@ -52,12 +53,19 @@ import com.infomaniak.mail.utils.extensions.isTabletOrFoldable
import com.infomaniak.mail.utils.extensions.safeNavigateToNewMessageActivity
import io.realm.kotlin.types.RealmInstant
import javax.inject.Inject
import kotlin.math.roundToInt

abstract class TwoPaneFragment : Fragment() {

val mainViewModel: MainViewModel by activityViewModels()
private val twoPaneViewModel: TwoPaneViewModel by activityViewModels()

private var dragStartX = 0f
private var dragStartLeftWidth = 0
private val separatorWidthPx by lazy { resources.getDimensionPixelSize(R.dimen.dragSeparatorWidth) }
val minLeftWidthPx by lazy { resources.getDimensionPixelSize(R.dimen.minLeftPaneWidth) }
val minRightWidthPx by lazy { resources.getDimensionPixelSize(R.dimen.minRightPaneWidth) }

// TODO: When we'll update DragDropSwipeRecyclerViewLib, we'll need to make the adapter nullable.
// For now it causes a memory leak, because we can't remove the strong reference
// between the ThreadList's RecyclerView and its Adapter as it throws an NPE.
Expand All @@ -70,6 +78,7 @@ abstract class TwoPaneFragment : Fragment() {
abstract val substituteClassName: String
abstract fun getLeftPane(): View?
abstract fun getRightPane(): FragmentContainerView?
abstract fun getDragSeparator(): View?
abstract fun getAnchor(): View?
open fun doAfterFolderChanged() = Unit

Expand All @@ -82,6 +91,7 @@ abstract class TwoPaneFragment : Fragment() {
observeCurrentFolder()
observeThreadUid()
observeThreadNavigation()
observeDragSeparator()
}

override fun onConfigurationChanged(newConfig: Configuration) {
Expand Down Expand Up @@ -136,6 +146,91 @@ abstract class TwoPaneFragment : Fragment() {
}
}

override fun onStop() {
twoPaneViewModel.saveLeftPaneRatio()
super.onStop()
}

private fun observeDragSeparator() {
val separator = getDragSeparator() ?: return
val leftPane = getLeftPane() ?: return
val rightPane = getRightPane() ?: return
val lineSeparator = separator.findViewById<View>(R.id.lineDragSeparator)
val parentGroup = view as? ViewGroup ?: return

val widthSelected = resources.getDimensionPixelSize(R.dimen.dragSeparatorWidthSelected)
val widthNormal = resources.getDimensionPixelSize(R.dimen.dragSeparatorWidth)

separator.setOnTouchListener { separatorView, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
dragStartX = event.rawX
dragStartLeftWidth = leftPane.width
updateSeparatorAppearance(separatorView, lineSeparator, true, widthSelected)
true
}

MotionEvent.ACTION_MOVE -> {
handleDragMove(event.rawX, leftPane, rightPane, parentGroup)
true
}

MotionEvent.ACTION_UP -> {
updateSeparatorAppearance(separatorView, lineSeparator, false, widthNormal)
separatorView.performClick()
true
}

MotionEvent.ACTION_CANCEL -> {
updateSeparatorAppearance(separatorView, lineSeparator, false, widthNormal)
true
}

else -> false
}
}
}

private fun updateSeparatorAppearance(
separatorView: View,
lineSeparator: View?,
isPressed: Boolean,
lineWidth: Int
) {
separatorView.isPressed = isPressed
lineSeparator?.layoutParams = lineSeparator.layoutParams?.apply {
width = lineWidth
}
}

private fun handleDragMove(
currentRawX: Float,
leftPane: View,
rightPane: View,
parentGroup: ViewGroup
) {
val deltaX = (currentRawX - dragStartX).toInt()
val parentWidth = parentGroup.width

val availableWidth = parentWidth - separatorWidthPx

val maxLeftWidth = availableWidth - minRightWidthPx
val safeMaxLeftWidth = maxOf(minLeftWidthPx, maxLeftWidth)

val newLeftWidth = (dragStartLeftWidth + deltaX).coerceIn(
minLeftWidthPx,
safeMaxLeftWidth
)

leftPane.layoutParams.width = newLeftWidth
rightPane.layoutParams.width = parentWidth - newLeftWidth - separatorWidthPx
parentGroup.requestLayout()

if (availableWidth > 0) {
twoPaneViewModel.leftPaneRatio = newLeftWidth.toFloat() / availableWidth
}
}

fun handleOnBackPressed() {
when {
isOnlyRightShown() -> {
Expand Down Expand Up @@ -169,37 +264,41 @@ abstract class TwoPaneFragment : Fragment() {
}

private fun updateTwoPaneVisibilities() {

val (leftWidth, rightWidth) = computeTwoPaneWidths(
widthPixels = requireActivity().application.resources.displayMetrics.widthPixels,
isThreadOpen = twoPaneViewModel.isThreadOpen,
)

getLeftPane()?.let { leftPane ->
if (leftWidth == 0) {
leftPane.isGone = true
} else {
if (leftPane.width != leftWidth) leftPane.layoutParams?.width = leftWidth
leftPane.isVisible = true
getLeftPane()?.apply {
isVisible = leftWidth != 0
if (leftWidth != 0 && width != leftWidth) {
layoutParams?.width = leftWidth
}
}

getRightPane()?.let { rightPane ->
if (rightWidth == 0) {
rightPane.isGone = true
} else {
if (rightPane.width != rightWidth) rightPane.layoutParams?.width = rightWidth
rightPane.isVisible = true
getRightPane()?.apply {
isVisible = rightWidth != 0
if (rightWidth != 0 && width != rightWidth) {
layoutParams?.width = rightWidth
}
}

getDragSeparator()?.isVisible = isTabletOrFoldable() && rightWidth != 0
}

private fun computeTwoPaneWidths(widthPixels: Int, isThreadOpen: Boolean): Pair<Int, Int> {
val leftPaneWidthRatio = ResourcesCompat.getFloat(resources, R.dimen.leftPaneWidthRatio)
val rightPaneWidthRatio = ResourcesCompat.getFloat(resources, R.dimen.rightPaneWidthRatio)

private fun computeTwoPaneWidths(widthPixels: Int, isThreadOpen: Boolean): Pair<Int, Int> {
return if (isTabletOrFoldable()) {
Comment thread
Elouan1411 marked this conversation as resolved.
(leftPaneWidthRatio * widthPixels).toInt() to (rightPaneWidthRatio * widthPixels).toInt()
val ratio = twoPaneViewModel.leftPaneRatio

val availableWidth = (widthPixels - separatorWidthPx).coerceAtLeast(0)
val leftWidth = if (availableWidth < minLeftWidthPx + minRightWidthPx) {
(ratio * availableWidth).roundToInt().coerceIn(0, availableWidth)
} else {
(ratio * availableWidth).roundToInt().coerceIn(minLeftWidthPx, availableWidth - minRightWidthPx)
}
leftWidth to (availableWidth - leftWidth)

} else {
if (isThreadOpen) 0 to widthPixels else widthPixels to 0
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.infomaniak.core.legacy.utils.SingleLiveEvent
import com.infomaniak.mail.data.LocalSettings
import com.infomaniak.mail.data.cache.mailboxContent.DraftController
import com.infomaniak.mail.data.models.draft.Draft.DraftMode
import com.infomaniak.mail.data.models.message.Message
Expand All @@ -44,13 +45,14 @@ import javax.inject.Inject
class TwoPaneViewModel @Inject constructor(
private val state: SavedStateHandle,
private val draftController: DraftController,
private val localSettings: LocalSettings,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : ViewModel() {

private val ioCoroutineContext = viewModelScope.coroutineContext(ioDispatcher)

val currentThreadUid: LiveData<String?> = state.getLiveData(CURRENT_THREAD_UID_KEY)

var leftPaneRatio: Float = localSettings.leftPaneRatio
inline val isThreadOpen get() = currentThreadUid.value != null
val rightPaneFolderName = MutableLiveData<String>()
var previousFolderId: String? = null
Expand All @@ -62,6 +64,11 @@ class TwoPaneViewModel @Inject constructor(
val newMessageArgs = SingleLiveEvent<NewMessageActivityArgs>()
val navArgs = SingleLiveEvent<NavData>()

override fun onCleared() {
saveLeftPaneRatio()
super.onCleared()
}

fun openThread(uid: String) {
state[CURRENT_THREAD_UID_KEY] = uid
}
Expand All @@ -70,6 +77,10 @@ class TwoPaneViewModel @Inject constructor(
state[CURRENT_THREAD_UID_KEY] = null
}

fun saveLeftPaneRatio() {
localSettings.leftPaneRatio = leftPaneRatio
}

fun openDraft(thread: Thread) = viewModelScope.launch(ioCoroutineContext) {
navigateToSelectedDraft(thread.messages.single())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ class SearchFragment : TwoPaneFragment() {

override fun getRightPane(): FragmentContainerView? = _binding?.threadHostFragment

override fun getDragSeparator(): View? = _binding?.dragSeparator

override fun getAnchor(): View? {
return if (isOnlyLeftShown()) {
null
Expand Down
30 changes: 30 additions & 0 deletions app/src/main/res/drawable/item_drag_line.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses/>.
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="?attr/colorPrimaryContainer" />
</shape>
</item>

<item>
<shape android:shape="rectangle">
<solid android:color="@color/dividerColor" />
</shape>
</item>
</selector>
86 changes: 86 additions & 0 deletions app/src/main/res/drawable/item_vertical_drag_handle.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?><!--
~ 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 <http://www.gnu.org/licenses/>.
-->
<selector xmlns:android="http://schemas.android.com/apk/res/android">

<item android:state_pressed="true">
<layer-list>
<item>
<shape android:shape="rectangle">
<size android:width="@dimen/dragSeparatorHandleWidth" android:height="@dimen/dragSeparatorHandleHeight" />
<solid android:color="?attr/colorPrimary" />
<corners android:radius="100dp" />
</shape>
</item>
<item android:gravity="top" android:left="3dp" android:right="3dp" android:top="7dp">
<shape android:shape="oval">
<size android:height="2dp" />
<solid android:color="@color/backgroundColor" />
</shape>
</item>

<item android:gravity="center_vertical" android:left="3dp" android:right="3dp">
<shape android:shape="oval">
<size android:height="2dp" />
<solid android:color="@color/backgroundColor" />
</shape>
</item>

<item android:bottom="7dp" android:gravity="bottom" android:left="3dp" android:right="3dp">
<shape android:shape="oval">
<size android:height="2dp" />
<solid android:color="@color/backgroundColor" />
</shape>
</item>
</layer-list>

</item>

<item>
<layer-list>
<item>
<shape android:shape="rectangle">
<size android:width="@dimen/dragSeparatorHandleWidth" android:height="@dimen/dragSeparatorHandleHeight" />
<solid android:color="?attr/colorOutlineVariant" />
<corners android:radius="100dp" />
</shape>
</item>

<item android:gravity="top" android:left="3dp" android:right="3dp" android:top="7dp">
<shape android:shape="oval">
<size android:height="2dp" />
<solid android:color="@color/backgroundColor" />
</shape>
</item>

<item android:gravity="center_vertical" android:left="3dp" android:right="3dp">
<shape android:shape="oval">
<size android:height="2dp" />
<solid android:color="@color/backgroundColor" />
</shape>
</item>

<item android:bottom="7dp" android:gravity="bottom" android:left="3dp" android:right="3dp">
<shape android:shape="oval">
<size android:height="2dp" />
<solid android:color="@color/backgroundColor" />
</shape>
</item>
</layer-list>
</item>

</selector>
Loading
Loading