diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettings.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettings.kt new file mode 100644 index 0000000000..bb30a7bca8 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettings.kt @@ -0,0 +1,141 @@ +package org.thoughtcrime.securesms.conversation.expiration + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import network.loki.messenger.R +import network.loki.messenger.libsession_util.util.ExpiryMode +import org.thoughtcrime.securesms.ui.CellNoMargin +import org.thoughtcrime.securesms.ui.Divider +import org.thoughtcrime.securesms.ui.OutlineButton +import org.thoughtcrime.securesms.ui.PreviewTheme +import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider +import org.thoughtcrime.securesms.ui.TitledRadioButton +import org.thoughtcrime.securesms.ui.fadingEdges + +@Composable +fun DisappearingMessages( + state: UiState, + modifier: Modifier = Modifier, + callbacks: Callbacks = NoOpCallbacks +) { + val scrollState = rememberScrollState() + + Column(modifier = modifier) { + Box(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier + .padding(horizontal = 32.dp) + .padding(bottom = 20.dp) + .verticalScroll(scrollState) + .fadingEdges(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + state.cards.forEach { + OptionsCard(it, callbacks) + } + + if (state.showGroupFooter) Text(text = stringResource(R.string.activity_expiration_settings_group_footer), + style = TextStyle( + fontSize = 11.sp, + fontWeight = FontWeight(400), + color = Color(0xFFA1A2A1), + textAlign = TextAlign.Center), + modifier = Modifier.fillMaxWidth()) + } + } + + OutlineButton( + stringResource(R.string.expiration_settings_set_button_title), + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 20.dp), + onClick = callbacks::onSetClick + ) + } +} + +@Composable +fun OptionsCard(card: CardModel, callbacks: Callbacks) { + Text(text = card.title()) + CellNoMargin { + LazyColumn( + modifier = Modifier.heightIn(max = 5000.dp) + ) { + itemsIndexed(card.options) { i, it -> + if (i != 0) Divider() + TitledRadioButton(it) { it.onClick(callbacks) } + } + } + } +} + +@Preview(widthDp = 450, heightDp = 700) +@Composable +fun PreviewStates( + @PreviewParameter(StatePreviewParameterProvider::class) state: State +) { + PreviewTheme(R.style.Classic_Dark) { + DisappearingMessages( + UiState(state) + ) + } +} + +class StatePreviewParameterProvider : PreviewParameterProvider { + override val values = newConfigValues.filter { it.expiryType != ExpiryType.LEGACY } + newConfigValues.map { it.copy(isNewConfigEnabled = false) } + + private val newConfigValues get() = sequenceOf( + // new 1-1 + State(expiryMode = ExpiryMode.NONE), + State(expiryMode = ExpiryMode.Legacy(43200)), + State(expiryMode = ExpiryMode.AfterRead(300)), + State(expiryMode = ExpiryMode.AfterSend(43200)), + // new group non-admin + State(isGroup = true, isSelfAdmin = false), + State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.Legacy(43200)), + State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.AfterSend(43200)), + // new group admin + State(isGroup = true), + State(isGroup = true, expiryMode = ExpiryMode.Legacy(43200)), + State(isGroup = true, expiryMode = ExpiryMode.AfterSend(43200)), + // new note-to-self + State(isNoteToSelf = true), + ) +} + + +@Preview +@Composable +fun PreviewThemes( + @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int +) { + PreviewTheme(themeResId) { + DisappearingMessages( + UiState(State(expiryMode = ExpiryMode.AfterSend(43200))), + modifier = Modifier.size(400.dp, 600.dp) + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsActivity.kt index 06f6ad6816..3339bd78bf 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsActivity.kt @@ -3,49 +3,9 @@ package org.thoughtcrime.securesms.conversation.expiration import android.os.Bundle import android.widget.Toast import androidx.activity.viewModels -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.ScrollState -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ButtonDefaults -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedButton -import androidx.compose.material.RadioButton -import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle @@ -53,18 +13,11 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import network.loki.messenger.R import network.loki.messenger.databinding.ActivityExpirationSettingsBinding -import network.loki.messenger.libsession_util.util.ExpiryMode import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.ui.AppTheme -import org.thoughtcrime.securesms.ui.CellNoMargin -import org.thoughtcrime.securesms.ui.Divider -import org.thoughtcrime.securesms.ui.LocalExtraColors -import org.thoughtcrime.securesms.ui.PreviewTheme -import org.thoughtcrime.securesms.ui.ThemeResPreviewParameterProvider import javax.inject.Inject -import kotlin.math.min @AndroidEntryPoint class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() { @@ -132,200 +85,7 @@ class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() { fun DisappearingMessagesScreen() { val uiState by viewModel.uiState.collectAsState(UiState()) AppTheme { - DisappearingMessages(uiState) + DisappearingMessages(uiState, callbacks = viewModel) } } } - -@Composable -fun DisappearingMessages( - state: UiState, - modifier: Modifier = Modifier -) { - val scrollState = rememberScrollState() - - Column(modifier = modifier) { - Box(modifier = Modifier.weight(1f)) { - Column( - modifier = Modifier - .padding(horizontal = 32.dp) - .padding(bottom = 20.dp) - .verticalScroll(scrollState) - .fadingEdges(scrollState), - verticalArrangement = Arrangement.spacedBy(16.dp) - ) { - state.cards.forEach { - OptionsCard(it) - } - - if (state.showGroupFooter) Text(text = stringResource(R.string.activity_expiration_settings_group_footer), - style = TextStyle( - fontSize = 11.sp, - fontWeight = FontWeight(400), - color = Color(0xFFA1A2A1), - textAlign = TextAlign.Center), - modifier = Modifier.fillMaxWidth()) - } - } - - OutlineButton( - stringResource(R.string.expiration_settings_set_button_title), - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(bottom = 20.dp), - onClick = state.callbacks::onSetClick - ) - } -} - -fun Modifier.fadingEdges( - scrollState: ScrollState, - topEdgeHeight: Dp = 0.dp, - bottomEdgeHeight: Dp = 20.dp -): Modifier = this.then( - Modifier - // adding layer fixes issue with blending gradient and content - .graphicsLayer { alpha = 0.99F } - .drawWithContent { - drawContent() - - val topColors = listOf(Color.Transparent, Color.Black) - val topStartY = scrollState.value.toFloat() - val topGradientHeight = min(topEdgeHeight.toPx(), topStartY) - drawRect( - brush = Brush.verticalGradient( - colors = topColors, - startY = topStartY, - endY = topStartY + topGradientHeight - ), - blendMode = BlendMode.DstIn - ) - - val bottomColors = listOf(Color.Black, Color.Transparent) - val bottomEndY = size.height - scrollState.maxValue + scrollState.value - val bottomGradientHeight = - min(bottomEdgeHeight.toPx(), scrollState.maxValue.toFloat() - scrollState.value) - if (bottomGradientHeight != 0f) drawRect( - brush = Brush.verticalGradient( - colors = bottomColors, - startY = bottomEndY - bottomGradientHeight, - endY = bottomEndY - ), - blendMode = BlendMode.DstIn - ) - } -) - -@Composable -fun OptionsCard(card: CardModel) { - Text(text = card.title()) - CellNoMargin { - LazyColumn( - modifier = Modifier.heightIn(max = 5000.dp) - ) { - itemsIndexed(card.options) { i, it -> - if (i != 0) Divider() - TitledRadioButton(it) - } - } - } -} - -@Composable -fun TitledRadioButton(option: OptionModel) { - Row(modifier = Modifier - .clickable { option.onClick() } - .heightIn(min = 60.dp) - .padding(horizontal = 34.dp)) { - Column(modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically)) { - Column { - Text( - text = option.title(), - fontSize = 16.sp, - modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) - ) - option.subtitle?.let { - Text( - text = it(), - fontSize = 11.sp, - modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) - ) - } - } - } - RadioButton( - selected = option.selected, - onClick = null, - enabled = option.enabled, - modifier = Modifier - .height(26.dp) - .align(Alignment.CenterVertically) - ) - } -} - -@Composable -fun OutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) { - OutlinedButton( - modifier = modifier.size(108.dp, 34.dp), - onClick = onClick, - border = BorderStroke(1.dp, LocalExtraColors.current.prominentButtonColor), - shape = RoundedCornerShape(50), // = 50% percent - colors = ButtonDefaults.outlinedButtonColors( - contentColor = LocalExtraColors.current.prominentButtonColor, - backgroundColor = MaterialTheme.colors.background - ) - ){ - Text(text = text) - } -} - -@Preview(widthDp = 450, heightDp = 700) -@Composable -fun PreviewStates( - @PreviewParameter(StatePreviewParameterProvider::class) state: State -) { - PreviewTheme(R.style.Classic_Dark) { - DisappearingMessages( - UiState(state) - ) - } -} - -class StatePreviewParameterProvider : PreviewParameterProvider { - override val values = newConfigValues.filter { it.expiryType != ExpiryType.LEGACY } + newConfigValues.map { it.copy(isNewConfigEnabled = false) } - - private val newConfigValues get() = sequenceOf( - // new 1-1 - State(expiryMode = ExpiryMode.NONE), - State(expiryMode = ExpiryMode.Legacy(43200)), - State(expiryMode = ExpiryMode.AfterRead(300)), - State(expiryMode = ExpiryMode.AfterSend(43200)), - // new group non-admin - State(isGroup = true, isSelfAdmin = false), - State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.Legacy(43200)), - State(isGroup = true, isSelfAdmin = false, expiryMode = ExpiryMode.AfterSend(43200)), - // new group admin - State(isGroup = true), - State(isGroup = true, expiryMode = ExpiryMode.Legacy(43200)), - State(isGroup = true, expiryMode = ExpiryMode.AfterSend(43200)), - // new note-to-self - State(isNoteToSelf = true), - ) -} - - -@Preview -@Composable -fun PreviewThemes( - @PreviewParameter(ThemeResPreviewParameterProvider::class) themeResId: Int -) { - PreviewTheme(themeResId) { - DisappearingMessages( - UiState(State(expiryMode = ExpiryMode.AfterSend(43200))), - modifier = Modifier.size(400.dp, 600.dp) - ) - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModel.kt index 978d1f762d..756c2f4aed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModel.kt @@ -50,27 +50,32 @@ data class State( val isNoteToSelf: Boolean = false, val expiryMode: ExpiryMode? = ExpiryMode.NONE, val isNewConfigEnabled: Boolean = true, - val callbacks: Callbacks = NoOpCallbacks, - val persistedMode: ExpiryMode? = null + val persistedMode: ExpiryMode? = null, + val showDebugOptions: Boolean = false ) { val subtitle get() = when { isGroup || isNoteToSelf -> GetString(R.string.activity_expiration_settings_subtitle_sent) else -> GetString(R.string.activity_expiration_settings_subtitle) } + + val modeOptionsHidden get() = isNoteToSelf || (isGroup && isNewConfigEnabled) + val duration get() = expiryMode?.duration val expiryType get() = expiryMode?.type + val expiryTypeOrNone: ExpiryType = expiryType ?: ExpiryType.NONE val isTimeOptionsEnabled = isNoteToSelf || isSelfAdmin && (isNewConfigEnabled || expiryType == ExpiryType.LEGACY) } interface Callbacks { - fun onSetClick(): Any = Unit - fun setType(type: ExpiryType) {} - fun setTime(seconds: Long) {} - fun setMode(mode: ExpiryMode) {} + fun onSetClick(): Any? + fun setMode(mode: ExpiryMode) } -object NoOpCallbacks: Callbacks +object NoOpCallbacks: Callbacks { + override fun onSetClick() {} + override fun setMode(mode: ExpiryMode) {} +} class ExpirationSettingsViewModel( private val threadId: Long, @@ -80,16 +85,19 @@ class ExpirationSettingsViewModel( private val threadDb: ThreadDatabase, private val groupDb: GroupDatabase, private val storage: Storage, - isNewConfigEnabled: Boolean + isNewConfigEnabled: Boolean, + showDebugOptions: Boolean ) : AndroidViewModel(application), Callbacks { private val _event = Channel() val event = _event.receiveAsFlow() - private val _state = MutableStateFlow(State( - isNewConfigEnabled = isNewConfigEnabled, - callbacks = this@ExpirationSettingsViewModel - )) + private val _state = MutableStateFlow( + State( + isNewConfigEnabled = isNewConfigEnabled, + showDebugOptions = showDebugOptions + ) + ) val state = _state.asStateFlow() val uiState = _state @@ -119,28 +127,6 @@ class ExpirationSettingsViewModel( } } - /** - * When enabling Disappearing Messages (for screens which provide the `Delete Type` options) the default `Timer` selection should be: - * Disappear After Read: `12 Hours` - * Disappear After Send: `1 Day` - * Legacy: `1 Day` - * */ - override fun setType(type: ExpiryType) { - val state = state.value - - if (state.expiryType == type) return - - _state.update { - it.copy(expiryMode = type.defaultMode(state.persistedMode)) - } - } - - override fun setTime(seconds: Long) { - _state.update { it.copy( - expiryMode = it.expiryType?.mode(seconds) - ) } - } - override fun setMode(mode: ExpiryMode) { _state.update { it.copy( expiryMode = mode @@ -202,29 +188,28 @@ class ExpirationSettingsViewModel( threadDb, groupDb, storage, - ExpirationConfiguration.isNewConfigEnabled + ExpirationConfiguration.isNewConfigEnabled, + BuildConfig.DEBUG ) as T } +} - private fun ExpiryType.defaultMode(persistedMode: ExpiryMode?) = when(this) { - persistedMode?.type -> persistedMode - ExpiryType.AFTER_READ -> mode(12.hours) - else -> mode(1.days) - } +private fun ExpiryType.defaultMode(persistedMode: ExpiryMode?) = when(this) { + persistedMode?.type -> persistedMode + ExpiryType.AFTER_READ -> mode(12.hours) + else -> mode(1.days) } data class UiState( val cards: List = emptyList(), - val showGroupFooter: Boolean = false, - val callbacks: Callbacks = NoOpCallbacks + val showGroupFooter: Boolean = false ) { constructor(state: State): this( cards = listOfNotNull( typeOptions(state)?.let { CardModel(GetString(R.string.activity_expiration_settings_delete_type), it) }, timeOptions(state)?.let { CardModel(GetString(R.string.activity_expiration_settings_timer), it) } ), - showGroupFooter = state.isGroup && state.isNewConfigEnabled, - callbacks = state.callbacks + showGroupFooter = state.isGroup && state.isNewConfigEnabled ) } @@ -234,31 +219,29 @@ data class CardModel( ) private fun typeOptions(state: State) = - state.takeUnless { - state.isNoteToSelf || state.isGroup && state.isNewConfigEnabled - }?.run { + state.takeUnless { it.modeOptionsHidden }?.run { listOfNotNull( typeOption( ExpiryType.NONE, state, R.string.expiration_off, contentDescription = R.string.AccessibilityId_disable_disappearing_messages, - enabled = state.isSelfAdmin + enabled = isSelfAdmin ), - if (!state.isNewConfigEnabled) typeOption( + if (!isNewConfigEnabled) typeOption( ExpiryType.LEGACY, state, R.string.expiration_type_disappear_legacy, contentDescription = R.string.expiration_type_disappear_legacy_description, - enabled = state.isSelfAdmin + enabled = isSelfAdmin ) else null, - if (!state.isGroup) typeOption( + if (!isGroup) typeOption( ExpiryType.AFTER_READ, state, R.string.expiration_type_disappear_after_read, R.string.expiration_type_disappear_after_read_description, contentDescription = R.string.expiration_type_disappear_after_read_description, - enabled = state.isNewConfigEnabled && state.isSelfAdmin + enabled = isNewConfigEnabled && isSelfAdmin ) else null, typeOption( ExpiryType.AFTER_SEND, @@ -266,7 +249,7 @@ private fun typeOptions(state: State) = R.string.expiration_type_disappear_after_send, R.string.expiration_type_disappear_after_read_description, contentDescription = R.string.expiration_type_disappear_after_send_description, - enabled = state.isNewConfigEnabled && state.isSelfAdmin + enabled = isNewConfigEnabled && isSelfAdmin ) ) } @@ -278,70 +261,89 @@ private fun typeOption( @StringRes subtitle: Int? = null, @StringRes contentDescription: Int = title, enabled: Boolean = true, - onClick: () -> Unit = { state.callbacks.setType(type) } +) = typeOption( + mode = type.defaultMode(state.persistedMode), + state = state, + title = title, + subtitle = subtitle, + contentDescription = contentDescription, + enabled = enabled +) +private fun typeOption( + mode: ExpiryMode, + state: State, + @StringRes title: Int, + @StringRes subtitle: Int? = null, + @StringRes contentDescription: Int = title, + enabled: Boolean = true, + onClick: Action = Action.SelectMode(mode) ) = OptionModel( GetString(title), subtitle?.let(::GetString), - selected = state.expiryType == type, + selected = state.expiryType == mode.type, enabled = enabled, onClick = onClick ) -private fun timeOptions(state: State) = - if (state.isNoteToSelf || (state.isGroup && state.isNewConfigEnabled)) timeOptionsOnly(state) - else when (state.expiryMode) { - is ExpiryMode.Legacy -> afterReadTimes - is ExpiryMode.AfterRead -> afterReadTimes - is ExpiryMode.AfterSend -> afterSendTimes - else -> null - }?.map { timeOption(it, state) } - -private val DEBUG_TIMES = if (BuildConfig.DEBUG) listOf(10.seconds, 1.minutes) else emptyList() +private fun debugTimes(isDebug: Boolean) = if (isDebug) listOf(10.seconds, 1.minutes) else emptyList() +private fun debugModes(isDebug: Boolean, type: ExpiryType) = + debugTimes(isDebug).map { type.mode(it.inWholeSeconds) } +private fun debugOptions(state: State): List = + debugModes(state.showDebugOptions, state.expiryType.takeIf { it != ExpiryType.NONE } ?: ExpiryType.AFTER_SEND) + .map { timeOption(it, state, subtitle = GetString("for testing purposes")) } val defaultTimes = listOf(12.hours, 1.days, 7.days, 14.days) -val afterSendTimes = buildList { - addAll(DEBUG_TIMES) - addAll(defaultTimes) -} +val afterSendTimes = defaultTimes +val afterSendModes = afterSendTimes.map { it.inWholeSeconds }.map(ExpiryMode::AfterSend) val afterReadTimes = buildList { - addAll(DEBUG_TIMES) add(5.minutes) add(1.hours) addAll(defaultTimes) } +val afterReadModes = afterReadTimes.map { it.inWholeSeconds }.map(ExpiryMode::AfterRead) -private fun timeOptionsOnly(state: State) = listOfNotNull( - typeOption(ExpiryType.NONE, state, R.string.arrays__off, enabled = state.isSelfAdmin), -) + afterSendTimes.map { timeOptionOnly(it, state) } +private fun timeOptions(state: State): List? = + if (state.modeOptionsHidden) timeOptionsAfterSend(state) + else when (state.expiryMode) { + is ExpiryMode.Legacy, is ExpiryMode.AfterRead -> debugOptions(state) + afterReadModes.map { timeOption(it, state) } + is ExpiryMode.AfterSend -> debugOptions(state) + afterSendModes.map { timeOption(it, state) } + else -> null + } -private fun timeOptionOnly( - duration: Duration, - state: State, - title: GetString = GetString { ExpirationUtil.getExpirationDisplayValue(it, duration.inWholeSeconds.toInt()) } -) = timeOption(duration, state, title) { state.callbacks.setMode(ExpiryMode.AfterSend(duration.inWholeSeconds)) } +private fun timeOptionsAfterSend(state: State) = listOfNotNull( + typeOption(ExpiryType.NONE, state, R.string.expiration_off, enabled = state.isSelfAdmin), +) + debugOptions(state) + afterSendModes.map { timeOption(it, state) } private fun timeOption( - duration: Duration, + mode: ExpiryMode, state: State, - title: GetString = GetString { ExpirationUtil.getExpirationDisplayValue(it, duration.inWholeSeconds.toInt()) }, - subtitle: GetString? = if (duration in DEBUG_TIMES) GetString("for testing purposes") else null, - onClick: () -> Unit = { state.callbacks.setTime(duration.inWholeSeconds) } + title: GetString = GetString(mode.duration, ExpirationUtil::getExpirationDisplayValue), + subtitle: GetString? = null, + onClick: Action = Action.SelectMode(mode) ) = OptionModel( title = title, subtitle = subtitle, - selected = state.expiryMode?.duration == duration, + selected = state.expiryMode == mode, enabled = state.isTimeOptionsEnabled, onClick = onClick ) +sealed interface Action { + operator fun invoke(callbacks: Callbacks) {} + + data class SelectMode(val mode: ExpiryMode): Action { + override operator fun invoke(callbacks: Callbacks) = callbacks.setMode(mode) + } +} + data class OptionModel( val title: GetString, val subtitle: GetString? = null, val selected: Boolean = false, val enabled: Boolean = true, - val onClick: () -> Unit = {} + val onClick: Action ) enum class ExpiryType(private val createMode: (Long) -> ExpiryMode) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt index 1724bde8a6..d9073f6885 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Components.kt @@ -1,42 +1,60 @@ package org.thoughtcrime.securesms.ui import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.ButtonColors +import androidx.compose.material.ButtonDefaults import androidx.compose.material.Card import androidx.compose.material.Colors import androidx.compose.material.Icon import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedButton +import androidx.compose.material.RadioButton import androidx.compose.material.Text import androidx.compose.material.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import com.google.accompanist.pager.HorizontalPagerIndicator import kotlinx.coroutines.launch import network.loki.messenger.R import org.session.libsession.utilities.recipients.Recipient import org.thoughtcrime.securesms.components.ProfilePictureView +import org.thoughtcrime.securesms.conversation.expiration.OptionModel +import kotlin.math.min @Composable fun ItemButton( @@ -95,10 +113,99 @@ fun CellWithPaddingAndMargin( } } +@Composable +fun TitledRadioButton(option: OptionModel, onClick: () -> Unit) { + Row(modifier = Modifier + .clickable { if (!option.selected) onClick() } + .heightIn(min = 60.dp) + .padding(horizontal = 34.dp)) { + Column(modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically)) { + Column { + Text( + text = option.title(), + fontSize = 16.sp, + modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) + ) + option.subtitle?.let { + Text( + text = it(), + fontSize = 11.sp, + modifier = Modifier.alpha(if (option.enabled) 1f else 0.5f) + ) + } + } + } + RadioButton( + selected = option.selected, + onClick = null, + enabled = option.enabled, + modifier = Modifier + .height(26.dp) + .align(Alignment.CenterVertically) + ) + } +} + +@Composable +fun OutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) { + OutlinedButton( + modifier = modifier.size(108.dp, 34.dp), + onClick = onClick, + border = BorderStroke(1.dp, LocalExtraColors.current.prominentButtonColor), + shape = RoundedCornerShape(50), // = 50% percent + colors = ButtonDefaults.outlinedButtonColors( + contentColor = LocalExtraColors.current.prominentButtonColor, + backgroundColor = MaterialTheme.colors.background + ) + ){ + Text(text = text) + } +} + private val Colors.cellColor: Color @Composable get() = LocalExtraColors.current.settingsBackground +fun Modifier.fadingEdges( + scrollState: ScrollState, + topEdgeHeight: Dp = 0.dp, + bottomEdgeHeight: Dp = 20.dp +): Modifier = this.then( + Modifier + // adding layer fixes issue with blending gradient and content + .graphicsLayer { alpha = 0.99F } + .drawWithContent { + drawContent() + + val topColors = listOf(Color.Transparent, Color.Black) + val topStartY = scrollState.value.toFloat() + val topGradientHeight = min(topEdgeHeight.toPx(), topStartY) + drawRect( + brush = Brush.verticalGradient( + colors = topColors, + startY = topStartY, + endY = topStartY + topGradientHeight + ), + blendMode = BlendMode.DstIn + ) + + val bottomColors = listOf(Color.Black, Color.Transparent) + val bottomEndY = size.height - scrollState.maxValue + scrollState.value + val bottomGradientHeight = + min(bottomEdgeHeight.toPx(), scrollState.maxValue.toFloat() - scrollState.value) + if (bottomGradientHeight != 0f) drawRect( + brush = Brush.verticalGradient( + colors = bottomColors, + startY = bottomEndY - bottomGradientHeight, + endY = bottomEndY + ), + blendMode = BlendMode.DstIn + ) + } +) + @OptIn(ExperimentalFoundationApi::class) @Composable fun BoxScope.HorizontalPagerIndicator(pagerState: PagerState) { diff --git a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt index fa6c7761b0..e472209005 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/ui/Data.kt @@ -5,6 +5,8 @@ import androidx.annotation.StringRes import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import org.session.libsession.utilities.ExpirationUtil +import kotlin.time.Duration /** * Compatibility class to allow ViewModels to use strings and string resources interchangeably. @@ -34,11 +36,20 @@ sealed class GetString { override fun string(): String = function(LocalContext.current) override fun string(context: Context): String = function(context) } + + data class FromMap(val value: T, val function: (Context, T) -> String): GetString() { + @Composable + override fun string(): String = function(LocalContext.current, value) + + override fun string(context: Context): String = function(context, value) + } } fun GetString(@StringRes resId: Int) = GetString.FromResId(resId) fun GetString(string: String) = GetString.FromString(string) fun GetString(function: (Context) -> String) = GetString.FromFun(function) +fun GetString(value: T, function: (Context, T) -> String) = GetString.FromMap(value, function) +fun GetString(duration: Duration) = GetString.FromMap(duration, ExpirationUtil::getExpirationDisplayValue) /** diff --git a/app/src/test/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModelTest.kt index 67b7aeabb1..f3b0000543 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/conversation/expiration/ExpirationSettingsViewModelTest.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import network.loki.messenger.libsession_util.util.ExpiryMode +import network.loki.messenger.R import org.hamcrest.CoreMatchers import org.hamcrest.MatcherAssert import org.junit.Rule @@ -24,6 +25,8 @@ import org.thoughtcrime.securesms.MainCoroutineRule import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.ThreadDatabase +import org.thoughtcrime.securesms.ui.GetString +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.hours @OptIn(ExperimentalCoroutinesApi::class) @@ -33,17 +36,17 @@ class ExpirationSettingsViewModelTest { @get:Rule var mainCoroutineRule = MainCoroutineRule() - val application: Application = mock(Application::class.java) - val textSecurePreferences: TextSecurePreferences = mock(TextSecurePreferences::class.java) - val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol = mock(SSKEnvironment.MessageExpirationManagerProtocol::class.java) - val threadDb: ThreadDatabase = mock(ThreadDatabase::class.java) - val groupDb: GroupDatabase = mock(GroupDatabase::class.java) - val storage: Storage = mock(Storage::class.java) + private val application: Application = mock(Application::class.java) + private val textSecurePreferences: TextSecurePreferences = mock(TextSecurePreferences::class.java) + private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol = mock(SSKEnvironment.MessageExpirationManagerProtocol::class.java) + private val threadDb: ThreadDatabase = mock(ThreadDatabase::class.java) + private val groupDb: GroupDatabase = mock(GroupDatabase::class.java) + private val storage: Storage = mock(Storage::class.java) - val recipient = mock(Recipient::class.java) + private val recipient = mock(Recipient::class.java) - val groupRecord = mock(GroupRecord::class.java) - val optionalGroupRecord = Optional.of(groupRecord) + private val groupRecord = mock(GroupRecord::class.java) + private val optionalGroupRecord = Optional.of(groupRecord) @Test fun `UI should show a list of times and an Off option`() = runTest { @@ -80,9 +83,18 @@ class ExpirationSettingsViewModelTest { CoreMatchers.equalTo(1) ) + val options = viewModel.uiState.value.cards[0].options MatcherAssert.assertThat( - viewModel.uiState.value.cards[0].options.count(), - CoreMatchers.equalTo(6) + options.map { it.title }, + CoreMatchers.equalTo( + listOf( + GetString(R.string.expiration_off), + GetString(12.hours), + GetString(1.days), + GetString(7.days), + GetString(14.days) + ) + ) ) } @@ -94,6 +106,7 @@ class ExpirationSettingsViewModelTest { threadDb, groupDb, storage, - isNewConfigEnabled + isNewConfigEnabled, + false ) } diff --git a/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.java b/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.java deleted file mode 100644 index 634a6ea19a..0000000000 --- a/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.session.libsession.utilities; - -import android.content.Context; - -import org.session.libsession.R; - -import java.util.concurrent.TimeUnit; - -public class ExpirationUtil { - - public static String getExpirationDisplayValue(Context context, int expirationTime) { - if (expirationTime <= 0) { - return context.getString(R.string.expiration_off); - } else if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { - return context.getResources().getQuantityString(R.plurals.expiration_seconds, expirationTime, expirationTime); - } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { - int minutes = expirationTime / (int)TimeUnit.MINUTES.toSeconds(1); - return context.getResources().getQuantityString(R.plurals.expiration_minutes, minutes, minutes); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { - int hours = expirationTime / (int)TimeUnit.HOURS.toSeconds(1); - return context.getResources().getQuantityString(R.plurals.expiration_hours, hours, hours); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { - int days = expirationTime / (int)TimeUnit.DAYS.toSeconds(1); - return context.getResources().getQuantityString(R.plurals.expiration_days, days, days); - } else { - int weeks = expirationTime / (int)TimeUnit.DAYS.toSeconds(7); - return context.getResources().getQuantityString(R.plurals.expiration_weeks, weeks, weeks); - } - } - - public static String getExpirationAbbreviatedDisplayValue(Context context, long expirationTime) { - if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { - return context.getResources().getString(R.string.expiration_seconds_abbreviated, expirationTime); - } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { - long minutes = expirationTime / TimeUnit.MINUTES.toSeconds(1); - return context.getResources().getString(R.string.expiration_minutes_abbreviated, minutes); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { - long hours = expirationTime / TimeUnit.HOURS.toSeconds(1); - return context.getResources().getString(R.string.expiration_hours_abbreviated, hours); - } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { - long days = expirationTime / TimeUnit.DAYS.toSeconds(1); - return context.getResources().getString(R.string.expiration_days_abbreviated, days); - } else { - long weeks = expirationTime / TimeUnit.DAYS.toSeconds(7); - return context.getResources().getString(R.string.expiration_weeks_abbreviated, weeks); - } - } - - -} diff --git a/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt b/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt new file mode 100644 index 0000000000..9834ee36fb --- /dev/null +++ b/libsession/src/main/java/org/session/libsession/utilities/ExpirationUtil.kt @@ -0,0 +1,54 @@ +package org.session.libsession.utilities + +import android.content.Context +import org.session.libsession.R +import java.util.concurrent.TimeUnit +import kotlin.time.Duration + +object ExpirationUtil { + @JvmStatic + fun getExpirationDisplayValue(context: Context, duration: Duration): String = getExpirationDisplayValue(context, duration.inWholeSeconds.toInt()) + + @JvmStatic + fun getExpirationDisplayValue(context: Context, expirationTime: Int): String { + return if (expirationTime <= 0) { + context.getString(R.string.expiration_off) + } else if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { + context.resources.getQuantityString( + R.plurals.expiration_seconds, + expirationTime, + expirationTime + ) + } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { + val minutes = expirationTime / TimeUnit.MINUTES.toSeconds(1).toInt() + context.resources.getQuantityString(R.plurals.expiration_minutes, minutes, minutes) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { + val hours = expirationTime / TimeUnit.HOURS.toSeconds(1).toInt() + context.resources.getQuantityString(R.plurals.expiration_hours, hours, hours) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { + val days = expirationTime / TimeUnit.DAYS.toSeconds(1).toInt() + context.resources.getQuantityString(R.plurals.expiration_days, days, days) + } else { + val weeks = expirationTime / TimeUnit.DAYS.toSeconds(7).toInt() + context.resources.getQuantityString(R.plurals.expiration_weeks, weeks, weeks) + } + } + + fun getExpirationAbbreviatedDisplayValue(context: Context, expirationTime: Long): String { + return if (expirationTime < TimeUnit.MINUTES.toSeconds(1)) { + context.resources.getString(R.string.expiration_seconds_abbreviated, expirationTime) + } else if (expirationTime < TimeUnit.HOURS.toSeconds(1)) { + val minutes = expirationTime / TimeUnit.MINUTES.toSeconds(1) + context.resources.getString(R.string.expiration_minutes_abbreviated, minutes) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(1)) { + val hours = expirationTime / TimeUnit.HOURS.toSeconds(1) + context.resources.getString(R.string.expiration_hours_abbreviated, hours) + } else if (expirationTime < TimeUnit.DAYS.toSeconds(7)) { + val days = expirationTime / TimeUnit.DAYS.toSeconds(1) + context.resources.getString(R.string.expiration_days_abbreviated, days) + } else { + val weeks = expirationTime / TimeUnit.DAYS.toSeconds(7) + context.resources.getString(R.string.expiration_weeks_abbreviated, weeks) + } + } +}