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.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<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 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<Event>()
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<CardModel> = 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<OptionModel> =
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<OptionModel>? =
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) {

View File

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

View File

@ -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<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(string: String) = GetString.FromString(string)
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.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
)
}

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