ui: move operators selection to sheet on onboarding (#5783)

* ios: show updated conditions always on what's new screen

* rework onboarding

* update text

* android whatsnew

* android wip

* layout

* improve what's new layout

* remove

* fix desktop

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
spaced4ndy
2025-03-28 15:37:39 +00:00
committed by GitHub
parent f8fddb1daf
commit 4443786474
12 changed files with 348 additions and 468 deletions
@@ -194,7 +194,7 @@ fun MainScreen() {
OnboardingStage.Step2_5_SetupDatabasePassphrase -> SetupDatabasePassphrase(chatModel)
OnboardingStage.Step3_ChooseServerOperators -> {
val modalData = remember { ModalData() }
modalData.ChooseServerOperators(true)
modalData.OnboardingConditionsView()
if (appPlatform.isDesktop) {
ModalManager.fullscreen.showInView()
}
@@ -127,31 +127,13 @@ fun ToggleChatListCard() {
@Composable
fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
val rhId = chatModel.remoteHostId()
LaunchedEffect(Unit) {
val showWhatsNew = shouldShowWhatsNew(chatModel)
val showUpdatedConditions = chatModel.conditions.value.conditionsAction?.shouldShowNotice ?: false
if (showWhatsNew) {
if (showWhatsNew || showUpdatedConditions) {
delay(1000L)
ModalManager.center.showCustomModal { close -> WhatsNewView(close = close, updatedConditions = showUpdatedConditions) }
} else if (showUpdatedConditions) {
ModalManager.center.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
LaunchedEffect(Unit) {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
try {
setConditionsNotified(rh = rhId, conditionsId = conditionsId)
} catch (e: Exception) {
Log.d(TAG, "UsageConditionsView setConditionsNotified error: ${e.message}")
}
}
UsageConditionsView(
userServers = mutableStateOf(emptyList()),
currUserServers = mutableStateOf(emptyList()),
close = close,
rhId = rhId
)
}
}
}
@@ -7,15 +7,18 @@ import SectionTextFooter
import SectionView
import TextIconSpaced
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
@@ -27,11 +30,7 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@Composable
fun ModalData.ChooseServerOperators(
onboarding: Boolean,
close: (() -> Unit) = { ModalManager.fullscreen.closeModals() },
modalManager: ModalManager = ModalManager.fullscreen
) {
fun ModalData.OnboardingConditionsView() {
LaunchedEffect(Unit) {
prepareChatBeforeFinishingOnboarding()
}
@@ -41,6 +40,73 @@ fun ModalData.ChooseServerOperators(
val selectedOperatorIds = remember { stateGetOrPut("selectedOperatorIds") { serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet() } }
val selectedOperators = remember { derivedStateOf { serverOperators.value.filter { selectedOperatorIds.value.contains(it.operatorId) } } }
ColumnWithScrollBar(
Modifier
.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer),
maxIntrinsicSize = true
) {
Box(Modifier.align(Alignment.CenterHorizontally)) {
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), bottomPadding = DEFAULT_PADDING)
}
Spacer(Modifier.weight(1f))
Column(
(if (appPlatform.isDesktop) Modifier.width(450.dp).align(Alignment.CenterHorizontally) else Modifier)
.fillMaxWidth()
.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING),
horizontalAlignment = Alignment.Start
) {
Text(
stringResource(MR.strings.onboarding_conditions_private_chats_not_accessible),
style = TextStyle(fontSize = 17.sp, lineHeight = 23.sp)
)
Spacer(Modifier.height(DEFAULT_PADDING))
Text(
stringResource(MR.strings.onboarding_conditions_by_using_you_agree),
style = TextStyle(fontSize = 17.sp, lineHeight = 23.sp)
)
Spacer(Modifier.height(DEFAULT_PADDING))
Text(
stringResource(MR.strings.onboarding_conditions_privacy_policy_and_conditions_of_use),
style = TextStyle(fontSize = 17.sp),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
ModalManager.fullscreen.showModal(endButtons = { ConditionsLinkButton() }) {
SimpleConditionsView(rhId = null)
}
}
)
}
Spacer(Modifier.weight(1f))
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
AcceptConditionsButton(enabled = selectedOperatorIds.value.isNotEmpty(), selectedOperators, selectedOperatorIds)
TextButtonBelowOnboardingButton(stringResource(MR.strings.onboarding_conditions_configure_server_operators)) {
ModalManager.fullscreen.showModalCloseable { close ->
ChooseServerOperators(serverOperators, selectedOperatorIds, close)
}
}
}
}
}
}
}
@Composable
fun ModalData.ChooseServerOperators(
serverOperators: State<List<ServerOperator>>,
selectedOperatorIds: MutableState<Set<Long>>,
close: (() -> Unit)
) {
LaunchedEffect(Unit) {
prepareChatBeforeFinishingOnboarding()
}
CompositionLocalProvider(LocalAppBarHandler provides rememberAppBarHandler()) {
ModalView({}, showClose = false) {
ColumnWithScrollBar(
Modifier
.themedBackground(bgLayerSize = LocalAppBarHandler.current?.backgroundGraphicsLayerSize, bgLayer = LocalAppBarHandler.current?.backgroundGraphicsLayer),
@@ -53,7 +119,7 @@ fun ModalData.ChooseServerOperators(
Column(Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING), horizontalAlignment = Alignment.CenterHorizontally) {
OnboardingInformationButton(
stringResource(MR.strings.how_it_helps_privacy),
onClick = { modalManager.showModal { ChooseServerOperatorsInfoView(modalManager) } }
onClick = { ModalManager.fullscreen.showModal { ChooseServerOperatorsInfoView() } }
)
}
@@ -77,37 +143,11 @@ fun ModalData.ChooseServerOperators(
}
Spacer(Modifier.weight(1f))
val reviewForOperators = selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted }
val canReviewLater = reviewForOperators.all { it.conditionsAcceptance.usageAllowed }
val currEnabledOperatorIds = serverOperators.value.filter { it.enabled }.map { it.operatorId }.toSet()
Column(Modifier.widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
val enabled = selectedOperatorIds.value.isNotEmpty()
when {
reviewForOperators.isNotEmpty() -> ReviewConditionsButton(enabled, onboarding, selectedOperators, selectedOperatorIds, modalManager)
selectedOperatorIds.value != currEnabledOperatorIds && enabled -> SetOperatorsButton(true, onboarding, serverOperators, selectedOperatorIds, close)
else -> ContinueButton(enabled, onboarding, close)
}
if (onboarding && reviewForOperators.isEmpty()) {
TextButtonBelowOnboardingButton(stringResource(MR.strings.operator_conditions_of_use)) {
modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
SimpleConditionsView(rhId = null)
}
}
} else if (onboarding || reviewForOperators.isEmpty()) {
// Reserve space
TextButtonBelowOnboardingButton("", null)
}
if (!onboarding && reviewForOperators.isNotEmpty()) {
ReviewLaterButton(canReviewLater, close)
SectionTextFooter(
annotatedStringResource(MR.strings.onboarding_network_operators_conditions_will_be_accepted) +
AnnotatedString(" ") +
annotatedStringResource(MR.strings.onboarding_network_operators_conditions_you_can_configure),
textAlign = TextAlign.Center
)
SectionBottomSpacer()
}
SetOperatorsButton(enabled, close)
// Reserve space
TextButtonBelowOnboardingButton("", null)
}
}
}
@@ -162,115 +202,36 @@ private fun CircleCheckbox(checked: Boolean) {
}
@Composable
private fun ReviewConditionsButton(
enabled: Boolean,
onboarding: Boolean,
selectedOperators: State<List<ServerOperator>>,
selectedOperatorIds: State<Set<Long>>,
modalManager: ModalManager
) {
private fun SetOperatorsButton(enabled: Boolean, close: () -> Unit) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.operator_review_conditions,
labelId = MR.strings.ok,
onboarding = null,
enabled = enabled,
onclick = {
modalManager.showModalCloseable(endButtons = { ConditionsLinkButton() }) { close ->
ReviewConditionsView(onboarding, selectedOperators, selectedOperatorIds, close)
}
close()
}
)
}
@Composable
private fun SetOperatorsButton(enabled: Boolean, onboarding: Boolean, serverOperators: State<List<ServerOperator>>, selectedOperatorIds: State<Set<Long>>, close: () -> Unit) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.onboarding_network_operators_update,
onboarding = null,
enabled = enabled,
onclick = {
withBGApi {
val enabledOperators = enabledOperators(serverOperators.value, selectedOperatorIds.value)
if (enabledOperators != null) {
val r = chatController.setServerOperators(rh = chatModel.remoteHostId(), operators = enabledOperators)
if (r != null) {
chatModel.conditions.value = r
}
continueToNextStep(onboarding, close)
}
}
}
)
}
@Composable
private fun ContinueButton(enabled: Boolean, onboarding: Boolean, close: () -> Unit) {
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.onboarding_network_operators_continue,
onboarding = null,
enabled = enabled,
onclick = {
continueToNextStep(onboarding, close)
}
)
}
@Composable
private fun ReviewLaterButton(enabled: Boolean, close: () -> Unit) {
TextButtonBelowOnboardingButton(
stringResource(MR.strings.onboarding_network_operators_review_later),
onClick = if (!enabled) null else {{ continueToNextStep(false, close) }}
)
}
@Composable
private fun ReviewConditionsView(
onboarding: Boolean,
selectedOperators: State<List<ServerOperator>>,
selectedOperatorIds: State<Set<Long>>,
close: () -> Unit
) {
// remembering both since we don't want to reload the view after the user accepts conditions
val operatorsWithConditionsAccepted = remember { chatModel.conditions.value.serverOperators.filter { it.conditionsAcceptance.conditionsAccepted } }
val acceptForOperators = remember { selectedOperators.value.filter { !it.conditionsAcceptance.conditionsAccepted } }
ColumnWithScrollBar(modifier = Modifier.fillMaxSize().padding(horizontal = if (onboarding) DEFAULT_ONBOARDING_HORIZONTAL_PADDING else DEFAULT_PADDING)) {
AppBarTitle(stringResource(MR.strings.operator_conditions_of_use), withPadding = false, enableAlphaChanges = false, bottomPadding = DEFAULT_PADDING)
if (operatorsWithConditionsAccepted.isNotEmpty()) {
ReadableText(MR.strings.operator_conditions_accepted_for_some, args = operatorsWithConditionsAccepted.joinToString(", ") { it.legalName_ })
ReadableText(MR.strings.operator_same_conditions_will_apply_to_operators, args = acceptForOperators.joinToString(", ") { it.legalName_ })
} else {
ReadableText(MR.strings.operator_conditions_will_be_accepted_for_some, args = acceptForOperators.joinToString(", ") { it.legalName_ })
}
Column(modifier = Modifier.weight(1f).padding(top = DEFAULT_PADDING_HALF)) {
ConditionsTextView(chatModel.remoteHostId())
}
Column(Modifier.padding(vertical = DEFAULT_PADDING).widthIn(max = if (appPlatform.isAndroid) 450.dp else 1000.dp).align(Alignment.CenterHorizontally), horizontalAlignment = Alignment.CenterHorizontally) {
AcceptConditionsButton(onboarding, selectedOperators, selectedOperatorIds, close)
}
}
}
@Composable
private fun AcceptConditionsButton(
onboarding: Boolean,
enabled: Boolean,
selectedOperators: State<List<ServerOperator>>,
selectedOperatorIds: State<Set<Long>>,
close: () -> Unit
selectedOperatorIds: State<Set<Long>>
) {
fun continueOnAccept() {
if (appPlatform.isDesktop || !onboarding) {
if (onboarding) { close() }
continueToNextStep(onboarding, close)
if (appPlatform.isDesktop) {
continueToNextStep()
} else {
continueToSetNotificationsAfterAccept()
}
}
OnboardingActionButton(
modifier = if (appPlatform.isAndroid) Modifier.fillMaxWidth() else Modifier,
labelId = MR.strings.accept_conditions,
modifier = if (appPlatform.isAndroid) Modifier.padding(horizontal = DEFAULT_ONBOARDING_HORIZONTAL_PADDING).fillMaxWidth() else Modifier.widthIn(min = 300.dp),
labelId = MR.strings.onboarding_conditions_accept,
onboarding = null,
enabled = enabled,
onclick = {
withBGApi {
val conditionsId = chatModel.conditions.value.currentConditions.conditionsId
@@ -295,12 +256,8 @@ private fun AcceptConditionsButton(
)
}
private fun continueToNextStep(onboarding: Boolean, close: () -> Unit) {
if (onboarding) {
private fun continueToNextStep() {
appPrefs.onboardingStage.set(if (appPlatform.isAndroid) OnboardingStage.Step4_SetNotificationsMode else OnboardingStage.OnboardingComplete)
} else {
close()
}
}
private fun continueToSetNotificationsAfterAccept() {
@@ -339,9 +296,7 @@ private fun enabledOperators(operators: List<ServerOperator>, selectedOperatorId
}
@Composable
private fun ChooseServerOperatorsInfoView(
modalManager: ModalManager
) {
private fun ChooseServerOperatorsInfoView() {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.onboarding_network_operators))
@@ -357,21 +312,20 @@ private fun ChooseServerOperatorsInfoView(
SectionView(title = stringResource(MR.strings.onboarding_network_about_operators).uppercase()) {
chatModel.conditions.value.serverOperators.forEach { op ->
ServerOperatorRow(op, modalManager)
ServerOperatorRow(op)
}
}
SectionBottomSpacer()
}
}
@Composable()
@Composable
private fun ServerOperatorRow(
operator: ServerOperator,
modalManager: ModalManager
operator: ServerOperator
) {
SectionItemView(
{
modalManager.showModalCloseable { close ->
ModalManager.fullscreen.showModalCloseable { close ->
OperatorInfoView(operator)
}
}
@@ -14,7 +14,7 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.ChatController.appPrefs
@@ -161,10 +161,14 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool
}
if (updatedConditions) {
Row(
Text(
stringResource(MR.strings.view_updated_conditions),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clip(shape = CircleShape)
.clickable {
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
modalManager.showModalCloseable { close ->
UsageConditionsView(
userServers = mutableStateOf(emptyList()),
@@ -174,15 +178,7 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool
)
}
}
.padding(horizontal = 6.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
stringResource(MR.strings.view_updated_conditions),
color = MaterialTheme.colors.primary
)
}
)
}
if (!viaSettings) {
@@ -190,14 +186,21 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool
Box(
Modifier.fillMaxWidth(), contentAlignment = Alignment.Center
) {
Text(
generalGetString(MR.strings.ok),
modifier = Modifier.clickable(onClick = {
close()
}),
style = MaterialTheme.typography.h3,
color = MaterialTheme.colors.primary
)
Box(Modifier.clip(RoundedCornerShape(20.dp))) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.clickable { close() }
.padding(8.dp)
) {
Text(
generalGetString(MR.strings.ok),
style = MaterialTheme.typography.h3,
color = MaterialTheme.colors.primary
)
}
}
}
Spacer(Modifier.fillMaxHeight().weight(1f))
}
@@ -213,8 +216,17 @@ fun ModalData.WhatsNewView(updatedConditions: Boolean = false, viaSettings: Bool
fun ReadMoreButton(url: String) {
val uriHandler = LocalUriHandler.current
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = DEFAULT_PADDING.div(4))) {
Text(stringResource(MR.strings.whats_new_read_more), color = MaterialTheme.colors.primary,
modifier = Modifier.clickable { uriHandler.openUriCatching(url) })
Text(
stringResource(MR.strings.whats_new_read_more),
color = MaterialTheme.colors.primary,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
uriHandler.openUriCatching(url)
}
)
Icon(painterResource(MR.images.ic_open_in_new), stringResource(MR.strings.whats_new_read_more), tint = MaterialTheme.colors.primary)
}
}
@@ -751,17 +763,7 @@ private val versionDescriptions: List<VersionDescription> = listOf(
val src = (operatorsInfo[OperatorTag.Flux] ?: dummyOperatorInfo).largeLogo
Image(painterResource(src), null, modifier = Modifier.height(48.dp))
Text(stringResource(MR.strings.v6_2_network_decentralization_descr), modifier = Modifier.padding(top = 8.dp))
Row {
Text(
stringResource(MR.strings.v6_2_network_decentralization_enable_flux),
color = MaterialTheme.colors.primary,
modifier = Modifier.clickable {
modalManager.showModalCloseable { close -> ChooseServerOperators(onboarding = false, close, modalManager) }
}
)
Text(" ")
Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux_reason))
}
Text(stringResource(MR.strings.v6_2_network_decentralization_enable_flux))
}
}
),
@@ -1162,6 +1162,11 @@
<string name="use_random_passphrase">Use random passphrase</string>
<!-- ChooseServerOperators.kt -->
<string name="onboarding_conditions_private_chats_not_accessible">Private chats, groups and your contacts are not accessible to server operators.</string>
<string name="onboarding_conditions_by_using_you_agree">By using SimpleX Chat you agree to:\n- send only legal content in public groups.\n- respect other users no spam.</string>
<string name="onboarding_conditions_privacy_policy_and_conditions_of_use">Privacy policy and conditions of use.</string>
<string name="onboarding_conditions_accept">Accept</string>
<string name="onboarding_conditions_configure_server_operators">Configure server operators</string>
<string name="onboarding_choose_server_operators">Server operators</string>
<string name="onboarding_network_operators">Network operators</string>
<string name="onboarding_network_operators_simplex_flux_agreement">SimpleX Chat and Flux made an agreement to include Flux-operated servers into the app.</string>
@@ -2291,7 +2296,7 @@
<string name="v6_1_delete_many_messages_descr">Delete or moderate up to 200 messages.</string>
<string name="v6_2_network_decentralization">Network decentralization</string>
<string name="v6_2_network_decentralization_descr">The second preset operator in the app!</string>
<string name="v6_2_network_decentralization_enable_flux">Enable flux</string>
<string name="v6_2_network_decentralization_enable_flux">Enable Flux in Network &amp; servers settings for better metadata privacy.</string>
<string name="v6_2_network_decentralization_enable_flux_reason">for better metadata privacy.</string>
<string name="v6_2_improved_chat_navigation">Improved chat navigation</string>
<string name="v6_2_improved_chat_navigation_descr">- Open chat on the first unread message.\n- Jump to quoted messages.</string>