This commit is contained in:
andrew 2023-09-18 01:49:41 +09:30
parent 5ce100a4ec
commit 885df1e22b
8 changed files with 425 additions and 387 deletions

View File

@ -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<State> {
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)
)
}
}

View File

@ -3,49 +3,9 @@ package org.thoughtcrime.securesms.conversation.expiration
import android.os.Bundle import android.os.Bundle
import android.widget.Toast import android.widget.Toast
import androidx.activity.viewModels 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.Composable
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue 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.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
@ -53,18 +13,11 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import network.loki.messenger.databinding.ActivityExpirationSettingsBinding import network.loki.messenger.databinding.ActivityExpirationSettingsBinding
import network.loki.messenger.libsession_util.util.ExpiryMode
import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity import org.thoughtcrime.securesms.PassphraseRequiredActionBarActivity
import org.thoughtcrime.securesms.database.RecipientDatabase import org.thoughtcrime.securesms.database.RecipientDatabase
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.ui.AppTheme 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 javax.inject.Inject
import kotlin.math.min
@AndroidEntryPoint @AndroidEntryPoint
class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() { class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() {
@ -132,200 +85,7 @@ class ExpirationSettingsActivity: PassphraseRequiredActionBarActivity() {
fun DisappearingMessagesScreen() { fun DisappearingMessagesScreen() {
val uiState by viewModel.uiState.collectAsState(UiState()) val uiState by viewModel.uiState.collectAsState(UiState())
AppTheme { 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<State> {
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)
)
}
}

View File

@ -50,27 +50,32 @@ data class State(
val isNoteToSelf: Boolean = false, val isNoteToSelf: Boolean = false,
val expiryMode: ExpiryMode? = ExpiryMode.NONE, val expiryMode: ExpiryMode? = ExpiryMode.NONE,
val isNewConfigEnabled: Boolean = true, val isNewConfigEnabled: Boolean = true,
val callbacks: Callbacks = NoOpCallbacks, val persistedMode: ExpiryMode? = null,
val persistedMode: ExpiryMode? = null val showDebugOptions: Boolean = false
) { ) {
val subtitle get() = when { val subtitle get() = when {
isGroup || isNoteToSelf -> GetString(R.string.activity_expiration_settings_subtitle_sent) isGroup || isNoteToSelf -> GetString(R.string.activity_expiration_settings_subtitle_sent)
else -> GetString(R.string.activity_expiration_settings_subtitle) else -> GetString(R.string.activity_expiration_settings_subtitle)
} }
val modeOptionsHidden get() = isNoteToSelf || (isGroup && isNewConfigEnabled)
val duration get() = expiryMode?.duration val duration get() = expiryMode?.duration
val expiryType get() = expiryMode?.type val expiryType get() = expiryMode?.type
val expiryTypeOrNone: ExpiryType = expiryType ?: ExpiryType.NONE
val isTimeOptionsEnabled = isNoteToSelf || isSelfAdmin && (isNewConfigEnabled || expiryType == ExpiryType.LEGACY) val isTimeOptionsEnabled = isNoteToSelf || isSelfAdmin && (isNewConfigEnabled || expiryType == ExpiryType.LEGACY)
} }
interface Callbacks { interface Callbacks {
fun onSetClick(): Any = Unit fun onSetClick(): Any?
fun setType(type: ExpiryType) {} fun setMode(mode: ExpiryMode)
fun setTime(seconds: Long) {}
fun setMode(mode: ExpiryMode) {}
} }
object NoOpCallbacks: Callbacks object NoOpCallbacks: Callbacks {
override fun onSetClick() {}
override fun setMode(mode: ExpiryMode) {}
}
class ExpirationSettingsViewModel( class ExpirationSettingsViewModel(
private val threadId: Long, private val threadId: Long,
@ -80,16 +85,19 @@ class ExpirationSettingsViewModel(
private val threadDb: ThreadDatabase, private val threadDb: ThreadDatabase,
private val groupDb: GroupDatabase, private val groupDb: GroupDatabase,
private val storage: Storage, private val storage: Storage,
isNewConfigEnabled: Boolean isNewConfigEnabled: Boolean,
showDebugOptions: Boolean
) : AndroidViewModel(application), Callbacks { ) : AndroidViewModel(application), Callbacks {
private val _event = Channel<Event>() private val _event = Channel<Event>()
val event = _event.receiveAsFlow() val event = _event.receiveAsFlow()
private val _state = MutableStateFlow(State( private val _state = MutableStateFlow(
isNewConfigEnabled = isNewConfigEnabled, State(
callbacks = this@ExpirationSettingsViewModel isNewConfigEnabled = isNewConfigEnabled,
)) showDebugOptions = showDebugOptions
)
)
val state = _state.asStateFlow() val state = _state.asStateFlow()
val uiState = _state 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) { override fun setMode(mode: ExpiryMode) {
_state.update { it.copy( _state.update { it.copy(
expiryMode = mode expiryMode = mode
@ -202,29 +188,28 @@ class ExpirationSettingsViewModel(
threadDb, threadDb,
groupDb, groupDb,
storage, storage,
ExpirationConfiguration.isNewConfigEnabled ExpirationConfiguration.isNewConfigEnabled,
BuildConfig.DEBUG
) as T ) as T
} }
}
private fun ExpiryType.defaultMode(persistedMode: ExpiryMode?) = when(this) { private fun ExpiryType.defaultMode(persistedMode: ExpiryMode?) = when(this) {
persistedMode?.type -> persistedMode persistedMode?.type -> persistedMode
ExpiryType.AFTER_READ -> mode(12.hours) ExpiryType.AFTER_READ -> mode(12.hours)
else -> mode(1.days) else -> mode(1.days)
}
} }
data class UiState( data class UiState(
val cards: List<CardModel> = emptyList(), val cards: List<CardModel> = emptyList(),
val showGroupFooter: Boolean = false, val showGroupFooter: Boolean = false
val callbacks: Callbacks = NoOpCallbacks
) { ) {
constructor(state: State): this( constructor(state: State): this(
cards = listOfNotNull( cards = listOfNotNull(
typeOptions(state)?.let { CardModel(GetString(R.string.activity_expiration_settings_delete_type), it) }, 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) } timeOptions(state)?.let { CardModel(GetString(R.string.activity_expiration_settings_timer), it) }
), ),
showGroupFooter = state.isGroup && state.isNewConfigEnabled, showGroupFooter = state.isGroup && state.isNewConfigEnabled
callbacks = state.callbacks
) )
} }
@ -234,31 +219,29 @@ data class CardModel(
) )
private fun typeOptions(state: State) = private fun typeOptions(state: State) =
state.takeUnless { state.takeUnless { it.modeOptionsHidden }?.run {
state.isNoteToSelf || state.isGroup && state.isNewConfigEnabled
}?.run {
listOfNotNull( listOfNotNull(
typeOption( typeOption(
ExpiryType.NONE, ExpiryType.NONE,
state, state,
R.string.expiration_off, R.string.expiration_off,
contentDescription = R.string.AccessibilityId_disable_disappearing_messages, contentDescription = R.string.AccessibilityId_disable_disappearing_messages,
enabled = state.isSelfAdmin enabled = isSelfAdmin
), ),
if (!state.isNewConfigEnabled) typeOption( if (!isNewConfigEnabled) typeOption(
ExpiryType.LEGACY, ExpiryType.LEGACY,
state, state,
R.string.expiration_type_disappear_legacy, R.string.expiration_type_disappear_legacy,
contentDescription = R.string.expiration_type_disappear_legacy_description, contentDescription = R.string.expiration_type_disappear_legacy_description,
enabled = state.isSelfAdmin enabled = isSelfAdmin
) else null, ) else null,
if (!state.isGroup) typeOption( if (!isGroup) typeOption(
ExpiryType.AFTER_READ, ExpiryType.AFTER_READ,
state, state,
R.string.expiration_type_disappear_after_read, R.string.expiration_type_disappear_after_read,
R.string.expiration_type_disappear_after_read_description, R.string.expiration_type_disappear_after_read_description,
contentDescription = 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, ) else null,
typeOption( typeOption(
ExpiryType.AFTER_SEND, 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_send,
R.string.expiration_type_disappear_after_read_description, R.string.expiration_type_disappear_after_read_description,
contentDescription = R.string.expiration_type_disappear_after_send_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 subtitle: Int? = null,
@StringRes contentDescription: Int = title, @StringRes contentDescription: Int = title,
enabled: Boolean = true, 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( ) = OptionModel(
GetString(title), GetString(title),
subtitle?.let(::GetString), subtitle?.let(::GetString),
selected = state.expiryType == type, selected = state.expiryType == mode.type,
enabled = enabled, enabled = enabled,
onClick = onClick onClick = onClick
) )
private fun timeOptions(state: State) = private fun debugTimes(isDebug: Boolean) = if (isDebug) listOf(10.seconds, 1.minutes) else emptyList()
if (state.isNoteToSelf || (state.isGroup && state.isNewConfigEnabled)) timeOptionsOnly(state) private fun debugModes(isDebug: Boolean, type: ExpiryType) =
else when (state.expiryMode) { debugTimes(isDebug).map { type.mode(it.inWholeSeconds) }
is ExpiryMode.Legacy -> afterReadTimes private fun debugOptions(state: State): List<OptionModel> =
is ExpiryMode.AfterRead -> afterReadTimes debugModes(state.showDebugOptions, state.expiryType.takeIf { it != ExpiryType.NONE } ?: ExpiryType.AFTER_SEND)
is ExpiryMode.AfterSend -> afterSendTimes .map { timeOption(it, state, subtitle = GetString("for testing purposes")) }
else -> null
}?.map { timeOption(it, state) }
private val DEBUG_TIMES = if (BuildConfig.DEBUG) listOf(10.seconds, 1.minutes) else emptyList()
val defaultTimes = listOf(12.hours, 1.days, 7.days, 14.days) val defaultTimes = listOf(12.hours, 1.days, 7.days, 14.days)
val afterSendTimes = buildList { val afterSendTimes = defaultTimes
addAll(DEBUG_TIMES) val afterSendModes = afterSendTimes.map { it.inWholeSeconds }.map(ExpiryMode::AfterSend)
addAll(defaultTimes)
}
val afterReadTimes = buildList { val afterReadTimes = buildList {
addAll(DEBUG_TIMES)
add(5.minutes) add(5.minutes)
add(1.hours) add(1.hours)
addAll(defaultTimes) addAll(defaultTimes)
} }
val afterReadModes = afterReadTimes.map { it.inWholeSeconds }.map(ExpiryMode::AfterRead)
private fun timeOptionsOnly(state: State) = listOfNotNull( private fun timeOptions(state: State): List<OptionModel>? =
typeOption(ExpiryType.NONE, state, R.string.arrays__off, enabled = state.isSelfAdmin), if (state.modeOptionsHidden) timeOptionsAfterSend(state)
) + afterSendTimes.map { timeOptionOnly(it, 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( private fun timeOptionsAfterSend(state: State) = listOfNotNull(
duration: Duration, typeOption(ExpiryType.NONE, state, R.string.expiration_off, enabled = state.isSelfAdmin),
state: State, ) + debugOptions(state) + afterSendModes.map { timeOption(it, state) }
title: GetString = GetString { ExpirationUtil.getExpirationDisplayValue(it, duration.inWholeSeconds.toInt()) }
) = timeOption(duration, state, title) { state.callbacks.setMode(ExpiryMode.AfterSend(duration.inWholeSeconds)) }
private fun timeOption( private fun timeOption(
duration: Duration, mode: ExpiryMode,
state: State, state: State,
title: GetString = GetString { ExpirationUtil.getExpirationDisplayValue(it, duration.inWholeSeconds.toInt()) }, title: GetString = GetString(mode.duration, ExpirationUtil::getExpirationDisplayValue),
subtitle: GetString? = if (duration in DEBUG_TIMES) GetString("for testing purposes") else null, subtitle: GetString? = null,
onClick: () -> Unit = { state.callbacks.setTime(duration.inWholeSeconds) } onClick: Action = Action.SelectMode(mode)
) = OptionModel( ) = OptionModel(
title = title, title = title,
subtitle = subtitle, subtitle = subtitle,
selected = state.expiryMode?.duration == duration, selected = state.expiryMode == mode,
enabled = state.isTimeOptionsEnabled, enabled = state.isTimeOptionsEnabled,
onClick = onClick 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( data class OptionModel(
val title: GetString, val title: GetString,
val subtitle: GetString? = null, val subtitle: GetString? = null,
val selected: Boolean = false, val selected: Boolean = false,
val enabled: Boolean = true, val enabled: Boolean = true,
val onClick: () -> Unit = {} val onClick: Action
) )
enum class ExpiryType(private val createMode: (Long) -> ExpiryMode) { enum class ExpiryType(private val createMode: (Long) -> ExpiryMode) {

View File

@ -1,42 +1,60 @@
package org.thoughtcrime.securesms.ui package org.thoughtcrime.securesms.ui
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi 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.Box
import androidx.compose.foundation.layout.BoxScope 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.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.ButtonColors import androidx.compose.material.ButtonColors
import androidx.compose.material.ButtonDefaults
import androidx.compose.material.Card import androidx.compose.material.Card
import androidx.compose.material.Colors import androidx.compose.material.Colors
import androidx.compose.material.Icon import androidx.compose.material.Icon
import androidx.compose.material.IconButton import androidx.compose.material.IconButton
import androidx.compose.material.MaterialTheme import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedButton
import androidx.compose.material.RadioButton
import androidx.compose.material.Text import androidx.compose.material.Text
import androidx.compose.material.TextButton import androidx.compose.material.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Color
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
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 androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.pager.HorizontalPagerIndicator import com.google.accompanist.pager.HorizontalPagerIndicator
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import network.loki.messenger.R import network.loki.messenger.R
import org.session.libsession.utilities.recipients.Recipient import org.session.libsession.utilities.recipients.Recipient
import org.thoughtcrime.securesms.components.ProfilePictureView import org.thoughtcrime.securesms.components.ProfilePictureView
import org.thoughtcrime.securesms.conversation.expiration.OptionModel
import kotlin.math.min
@Composable @Composable
fun ItemButton( 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 private val Colors.cellColor: Color
@Composable @Composable
get() = LocalExtraColors.current.settingsBackground 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) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun BoxScope.HorizontalPagerIndicator(pagerState: PagerState) { fun BoxScope.HorizontalPagerIndicator(pagerState: PagerState) {

View File

@ -5,6 +5,8 @@ import androidx.annotation.StringRes
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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. * 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(): String = function(LocalContext.current)
override fun string(context: Context): String = function(context) override fun string(context: Context): String = function(context)
} }
data class FromMap<T>(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(@StringRes resId: Int) = GetString.FromResId(resId)
fun GetString(string: String) = GetString.FromString(string) fun GetString(string: String) = GetString.FromString(string)
fun GetString(function: (Context) -> String) = GetString.FromFun(function) fun GetString(function: (Context) -> String) = GetString.FromFun(function)
fun <T> GetString(value: T, function: (Context, T) -> String) = GetString.FromMap(value, function)
fun GetString(duration: Duration) = GetString.FromMap(duration, ExpirationUtil::getExpirationDisplayValue)
/** /**

View File

@ -5,6 +5,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import network.loki.messenger.libsession_util.util.ExpiryMode import network.loki.messenger.libsession_util.util.ExpiryMode
import network.loki.messenger.R
import org.hamcrest.CoreMatchers import org.hamcrest.CoreMatchers
import org.hamcrest.MatcherAssert import org.hamcrest.MatcherAssert
import org.junit.Rule import org.junit.Rule
@ -24,6 +25,8 @@ import org.thoughtcrime.securesms.MainCoroutineRule
import org.thoughtcrime.securesms.database.GroupDatabase import org.thoughtcrime.securesms.database.GroupDatabase
import org.thoughtcrime.securesms.database.Storage import org.thoughtcrime.securesms.database.Storage
import org.thoughtcrime.securesms.database.ThreadDatabase import org.thoughtcrime.securesms.database.ThreadDatabase
import org.thoughtcrime.securesms.ui.GetString
import kotlin.time.Duration.Companion.days
import kotlin.time.Duration.Companion.hours import kotlin.time.Duration.Companion.hours
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
@ -33,17 +36,17 @@ class ExpirationSettingsViewModelTest {
@get:Rule @get:Rule
var mainCoroutineRule = MainCoroutineRule() var mainCoroutineRule = MainCoroutineRule()
val application: Application = mock(Application::class.java) private val application: Application = mock(Application::class.java)
val textSecurePreferences: TextSecurePreferences = mock(TextSecurePreferences::class.java) private val textSecurePreferences: TextSecurePreferences = mock(TextSecurePreferences::class.java)
val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol = mock(SSKEnvironment.MessageExpirationManagerProtocol::class.java) private val messageExpirationManager: SSKEnvironment.MessageExpirationManagerProtocol = mock(SSKEnvironment.MessageExpirationManagerProtocol::class.java)
val threadDb: ThreadDatabase = mock(ThreadDatabase::class.java) private val threadDb: ThreadDatabase = mock(ThreadDatabase::class.java)
val groupDb: GroupDatabase = mock(GroupDatabase::class.java) private val groupDb: GroupDatabase = mock(GroupDatabase::class.java)
val storage: Storage = mock(Storage::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) private val groupRecord = mock(GroupRecord::class.java)
val optionalGroupRecord = Optional.of(groupRecord) private val optionalGroupRecord = Optional.of(groupRecord)
@Test @Test
fun `UI should show a list of times and an Off option`() = runTest { fun `UI should show a list of times and an Off option`() = runTest {
@ -80,9 +83,18 @@ class ExpirationSettingsViewModelTest {
CoreMatchers.equalTo(1) CoreMatchers.equalTo(1)
) )
val options = viewModel.uiState.value.cards[0].options
MatcherAssert.assertThat( MatcherAssert.assertThat(
viewModel.uiState.value.cards[0].options.count(), options.map { it.title },
CoreMatchers.equalTo(6) 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, threadDb,
groupDb, groupDb,
storage, storage,
isNewConfigEnabled isNewConfigEnabled,
false
) )
} }

View File

@ -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);
}
}
}

View File

@ -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)
}
}
}