android, desktop: scrolling moves title to app bar (#4703)

* android, desktop: scrolling moves title to app bar

* one place should be without padding

* scroll related changes for both platforms

* adapt code to universal ColumnWithScrollBar

* show in center

* small adjustments

* new chat sheet fix

* divider + mix background color for desktop

* coerce

* different transition

* desktop title starts from left

* host starts from left too

* different coefficient

* settings title
This commit is contained in:
Stanislav Dmitrenko
2024-08-19 18:43:54 +00:00
committed by GitHub
parent 75a468434c
commit 885aa9cfa5
28 changed files with 583 additions and 418 deletions
@@ -52,6 +52,9 @@ actual fun windowOrientation(): WindowOrientation = when (mainActivity.get()?.re
@Composable
actual fun windowWidth(): Dp = LocalConfiguration.current.screenWidthDp.dp
@Composable
actual fun windowHeight(): Dp = LocalConfiguration.current.screenHeightDp.dp
actual fun desktopExpandWindowToWidth(width: Dp) {}
actual fun isRtl(text: CharSequence): Boolean = BidiFormatter.getInstance().isRtl(text)
@@ -4,14 +4,21 @@ import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.FlingBehavior
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.helpers.*
import kotlinx.coroutines.flow.filter
import kotlin.math.absoluteValue
@Composable
actual fun LazyColumnWithScrollBar(
modifier: Modifier,
state: LazyListState,
state: LazyListState?,
contentPadding: PaddingValues,
reverseLayout: Boolean,
verticalArrangement: Arrangement.Vertical,
@@ -20,7 +27,24 @@ actual fun LazyColumnWithScrollBar(
userScrollEnabled: Boolean,
content: LazyListScope.() -> Unit
) {
LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
val state = state ?: LocalAppBarHandler.current?.listState ?: rememberLazyListState()
val connection = LocalAppBarHandler.current?.connection
LaunchedEffect(Unit) {
snapshotFlow { state.firstVisibleItemScrollOffset }
.filter { state.firstVisibleItemIndex == 0 }
.collect { scrollPosition ->
val offset = connection?.appBarOffset
if (offset != null && (offset + scrollPosition).absoluteValue > 1) {
connection.appBarOffset = -scrollPosition.toFloat()
// Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
}
}
}
if (connection != null) {
LazyColumn(modifier.nestedScroll(connection), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
} else {
LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
}
}
@Composable
@@ -28,8 +52,34 @@ actual fun ColumnWithScrollBar(
modifier: Modifier,
verticalArrangement: Arrangement.Vertical,
horizontalAlignment: Alignment.Horizontal,
state: ScrollState,
content: @Composable ColumnScope.() -> Unit
state: ScrollState?,
maxIntrinsicSize: Boolean,
content: @Composable() (ColumnScope.() -> Unit)
) {
Column(modifier.verticalScroll(rememberScrollState()), verticalArrangement, horizontalAlignment, content)
val state = state ?: LocalAppBarHandler.current?.scrollState ?: rememberScrollState()
val connection = LocalAppBarHandler.current?.connection
LaunchedEffect(Unit) {
snapshotFlow { state.value }
.collect { scrollPosition ->
val offset = connection?.appBarOffset
if (offset != null && (offset + scrollPosition).absoluteValue > 1) {
connection.appBarOffset = -scrollPosition.toFloat()
// Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
}
}
}
if (connection != null) {
Column(
if (maxIntrinsicSize) {
modifier.nestedScroll(connection).verticalScroll(state).height(IntrinsicSize.Max)
} else {
modifier.nestedScroll(connection).verticalScroll(state)
}, verticalArrangement, horizontalAlignment, content)
} else {
Column(if (maxIntrinsicSize) {
modifier.verticalScroll(state).height(IntrinsicSize.Max)
} else {
modifier.verticalScroll(state)
}, verticalArrangement, horizontalAlignment, content)
}
}
@@ -17,6 +17,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
import chat.simplex.common.model.*
@@ -50,6 +51,7 @@ data class SettingsViewState(
@Composable
fun AppScreen() {
AppBarHandler.appBarMaxHeightPx = with(LocalDensity.current) { AppBarHeight.roundToPx() }
SimpleXTheme {
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
Surface(color = MaterialTheme.colors.background, contentColor = LocalContentColor.current) {
@@ -28,9 +28,6 @@ interface PlatformInterface {
fun androidRestartNetworkObserver() {}
@Composable fun androidLockPortraitOrientation() {}
suspend fun androidAskToAllowBackgroundCalls(): Boolean = true
@Composable fun desktopScrollBarComponents(): Triple<Animatable<Float, AnimationVector1D>, Modifier, MutableState<Job>> = remember { Triple(Animatable(0f), Modifier, mutableStateOf(Job())) }
@Composable fun desktopScrollBar(state: LazyListState, modifier: Modifier, scrollBarAlpha: Animatable<Float, AnimationVector1D>, scrollJob: MutableState<Job>, reversed: Boolean) {}
@Composable fun desktopScrollBar(state: ScrollState, modifier: Modifier, scrollBarAlpha: Animatable<Float, AnimationVector1D>, scrollJob: MutableState<Job>, reversed: Boolean) {}
@Composable fun desktopShowAppUpdateNotice() {}
}
/**
@@ -30,6 +30,9 @@ expect fun windowOrientation(): WindowOrientation
@Composable
expect fun windowWidth(): Dp
@Composable
expect fun windowHeight(): Dp
expect fun desktopExpandWindowToWidth(width: Dp)
expect fun isRtl(text: CharSequence): Boolean
@@ -13,7 +13,7 @@ import androidx.compose.ui.unit.dp
@Composable
expect fun LazyColumnWithScrollBar(
modifier: Modifier = Modifier,
state: LazyListState = rememberLazyListState(),
state: LazyListState? = null,
contentPadding: PaddingValues = PaddingValues(0.dp),
reverseLayout: Boolean = false,
verticalArrangement: Arrangement.Vertical =
@@ -29,6 +29,8 @@ expect fun ColumnWithScrollBar(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
state: ScrollState = rememberScrollState(),
state: ScrollState? = null,
// set true when you want to show something in the center with respected .fillMaxSize()
maxIntrinsicSize: Boolean = false,
content: @Composable ColumnScope.() -> Unit
)
@@ -125,19 +125,13 @@ fun TerminalLayout(
}
}
private var lazyListState = 0 to 0
@Composable
fun TerminalLog() {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
val reversedTerminalItems by remember {
derivedStateOf { chatModel.terminalItems.value.asReversed() }
}
val clipboard = LocalClipboardManager.current
LazyColumnWithScrollBar(state = listState, reverseLayout = true) {
LazyColumnWithScrollBar(reverseLayout = true) {
items(reversedTerminalItems) { item ->
val rhId = item.remoteHostId
val rhIdStr = if (rhId == null) "" else "$rhId "
@@ -277,7 +277,6 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun HistoryTab() {
// LALAL SCROLLBAR DOESN'T WORK
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
@@ -302,7 +301,6 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun QuoteTab(qi: CIQuote) {
// LALAL SCROLLBAR DOESN'T WORK
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
@@ -316,7 +314,6 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun ForwardedFromTab(forwardedFromItem: AChatItem) {
// LALAL SCROLLBAR DOESN'T WORK
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
@@ -379,7 +376,6 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun DeliveryTab(memberDeliveryStatuses: List<MemberDeliveryStatus>) {
// LALAL SCROLLBAR DOESN'T WORK
ColumnWithScrollBar(Modifier.fillMaxWidth()) {
Details()
SectionDividerSpaced(maxTopPadding = false, maxBottomPadding = true)
@@ -504,24 +504,34 @@ fun ChatView(staleChatId: State<String?>, onComposed: suspend (chatId: String) -
}
is ChatInfo.ContactConnection -> {
val close = { chatModel.chatId.value = null }
ModalView(close, showClose = appPlatform.isAndroid, content = {
ContactConnectionInfoView(chatModel, chatRh, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, false, close)
})
LaunchedEffect(chatInfo.id) {
onComposed(chatInfo.id)
ModalManager.end.closeModals()
chatModel.chatItems.clear()
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ModalView(close, showClose = appPlatform.isAndroid, content = {
ContactConnectionInfoView(chatModel, chatRh, chatInfo.contactConnection.connReqInv, chatInfo.contactConnection, false, close)
})
LaunchedEffect(chatInfo.id) {
onComposed(chatInfo.id)
ModalManager.end.closeModals()
chatModel.chatItems.clear()
}
}
}
is ChatInfo.InvalidJSON -> {
val close = { chatModel.chatId.value = null }
ModalView(close, showClose = appPlatform.isAndroid, endButtons = { ShareButton { clipboard.shareText(chatInfo.json) } }, content = {
InvalidJSONView(chatInfo.json)
})
LaunchedEffect(chatInfo.id) {
onComposed(chatInfo.id)
ModalManager.end.closeModals()
chatModel.chatItems.clear()
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ModalView(close, showClose = appPlatform.isAndroid, endButtons = { ShareButton { clipboard.shareText(chatInfo.json) } }, content = {
InvalidJSONView(chatInfo.json)
})
LaunchedEffect(chatInfo.id) {
onComposed(chatInfo.id)
ModalManager.end.closeModals()
chatModel.chatItems.clear()
}
}
}
else -> {}
@@ -284,7 +284,6 @@ fun GroupChatInfoLayout(
if (s.isEmpty()) members else members.filter { m -> m.anyNameContains(s) }
}
}
// LALAL strange scrolling
LazyColumnWithScrollBar(
Modifier
.fillMaxWidth(),
@@ -185,7 +185,14 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ModalView(showClose = appPlatform.isDesktop, close = { scope.launch { scaffoldState.drawerState.close() } }) {
SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
}
}
}
},
contentColor = LocalContentColor.current,
@@ -615,14 +615,12 @@ fun ModalData.SMPServerSummaryView(
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.smp_server),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.smp_server),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
SMPServerSummaryLayout(summary, statsStartedAt, rh)
}
}
@@ -709,7 +707,7 @@ fun ModalData.XFTPServerSummaryView(
@Composable
fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableState<PresentedServersSummary?>) {
Column(
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
var showUserSelection by remember { mutableStateOf(false) }
@@ -760,14 +758,12 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta
Column(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.servers_info),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.servers_info),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
if (serversSummary.value == null) {
Box(
modifier = Modifier
@@ -827,7 +823,7 @@ fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableSta
verticalAlignment = Alignment.Top,
userScrollEnabled = appPlatform.isAndroid
) { index ->
ColumnWithScrollBar(
Column(
Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Top
@@ -107,101 +107,104 @@ fun DatabaseEncryptionLayout(
migration: Boolean,
onConfirmEncrypt: () -> Unit,
) {
val (scrollBarAlpha, scrollModifier, scrollJob) = platform.desktopScrollBarComponents()
val scrollState = rememberScrollState()
Column(
if (!migration) Modifier.fillMaxWidth().verticalScroll(scrollState).then(if (appPlatform.isDesktop) scrollModifier else Modifier) else Modifier.fillMaxWidth(),
) {
if (!migration) {
AppBarTitle(stringResource(MR.strings.database_passphrase))
} else {
ChatStoppedView()
SectionSpacer()
}
SectionView(if (migration) generalGetString(MR.strings.database_passphrase).uppercase() else null) {
SavePassphraseSetting(
useKeychain.value,
initialRandomDBPassphrase.value,
storedKey.value,
enabled = (!initialRandomDBPassphrase.value && !progressIndicator.value) || migration
) { checked ->
if (checked) {
setUseKeychain(true, useKeychain, migration)
} else if (storedKey.value && !migration) {
// Don't show in migration process since it will remove the key after successful encryption
removePassphraseAlert {
removePassphraseFromKeyChain(useKeychain, storedKey, false)
}
} else {
setUseKeychain(false, useKeychain, migration)
}
@Composable
fun Layout() {
Column {
if (!migration) {
AppBarTitle(stringResource(MR.strings.database_passphrase))
} else {
ChatStoppedView()
SectionSpacer()
}
SectionView(if (migration) generalGetString(MR.strings.database_passphrase).uppercase() else null) {
SavePassphraseSetting(
useKeychain.value,
initialRandomDBPassphrase.value,
storedKey.value,
enabled = (!initialRandomDBPassphrase.value && !progressIndicator.value) || migration
) { checked ->
if (checked) {
setUseKeychain(true, useKeychain, migration)
} else if (storedKey.value && !migration) {
// Don't show in migration process since it will remove the key after successful encryption
removePassphraseAlert {
removePassphraseFromKeyChain(useKeychain, storedKey, false)
}
} else {
setUseKeychain(false, useKeychain, migration)
}
}
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
PassphraseField(
currentKey,
generalGetString(MR.strings.current_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
)
}
if (!initialRandomDBPassphrase.value && chatDbEncrypted == true) {
PassphraseField(
currentKey,
generalGetString(MR.strings.current_passphrase),
newKey,
generalGetString(MR.strings.new_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
showStrength = true,
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
)
}
PassphraseField(
newKey,
generalGetString(MR.strings.new_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
showStrength = true,
isValid = ::validKey,
keyboardActions = KeyboardActions(onNext = { defaultKeyboardAction(ImeAction.Next) }),
)
val onClickUpdate = {
// Don't do things concurrently. Shouldn't be here concurrently, just in case
if (!progressIndicator.value) {
if (currentKey.value == "") {
if (useKeychain.value)
encryptDatabaseSavedAlert(onConfirmEncrypt)
else
encryptDatabaseAlert(onConfirmEncrypt)
} else {
if (useKeychain.value)
changeDatabaseKeySavedAlert(onConfirmEncrypt)
else
changeDatabaseKeyAlert(onConfirmEncrypt)
val onClickUpdate = {
// Don't do things concurrently. Shouldn't be here concurrently, just in case
if (!progressIndicator.value) {
if (currentKey.value == "") {
if (useKeychain.value)
encryptDatabaseSavedAlert(onConfirmEncrypt)
else
encryptDatabaseAlert(onConfirmEncrypt)
} else {
if (useKeychain.value)
changeDatabaseKeySavedAlert(onConfirmEncrypt)
else
changeDatabaseKeyAlert(onConfirmEncrypt)
}
}
}
val disabled = currentKey.value == newKey.value ||
newKey.value != confirmNewKey.value ||
newKey.value.isEmpty() ||
!validKey(currentKey.value) ||
!validKey(newKey.value) ||
progressIndicator.value
PassphraseField(
confirmNewKey,
generalGetString(MR.strings.confirm_new_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
keyboardActions = KeyboardActions(onDone = {
if (!disabled) onClickUpdate()
defaultKeyboardAction(ImeAction.Done)
}),
)
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) {
Text(generalGetString(if (migration) MR.strings.set_passphrase else MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
}
}
val disabled = currentKey.value == newKey.value ||
newKey.value != confirmNewKey.value ||
newKey.value.isEmpty() ||
!validKey(currentKey.value) ||
!validKey(newKey.value) ||
progressIndicator.value
PassphraseField(
confirmNewKey,
generalGetString(MR.strings.confirm_new_passphrase),
modifier = Modifier.padding(horizontal = DEFAULT_PADDING),
isValid = { confirmNewKey.value == "" || newKey.value == confirmNewKey.value },
keyboardActions = KeyboardActions(onDone = {
if (!disabled) onClickUpdate()
defaultKeyboardAction(ImeAction.Done)
}),
)
SectionItemViewSpaceBetween(onClickUpdate, disabled = disabled, minHeight = TextFieldDefaults.MinHeight) {
Text(generalGetString(if (migration) MR.strings.set_passphrase else MR.strings.update_database_passphrase), color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary)
Column {
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase, migration)
}
SectionBottomSpacer()
}
Column {
DatabaseEncryptionFooter(useKeychain, chatDbEncrypted, storedKey, initialRandomDBPassphrase, migration)
}
SectionBottomSpacer()
}
if (appPlatform.isDesktop && !migration) {
Box(Modifier.fillMaxSize()) {
platform.desktopScrollBar(scrollState, Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, false)
if (migration) {
Column(Modifier.fillMaxWidth()) {
Layout()
}
} else {
ColumnWithScrollBar(Modifier.fillMaxWidth(), maxIntrinsicSize = true) {
Layout()
}
}
}
@@ -6,59 +6,90 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.background
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlin.math.absoluteValue
@Composable
fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Color = if (close != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, arrangement: Arrangement.Vertical = Arrangement.Top, closeBarTitle: String? = null, barPaddingValues: PaddingValues = PaddingValues(horizontal = AppBarHorizontalPadding), endButtons: @Composable RowScope.() -> Unit = {}) {
var rowModifier = Modifier
.fillMaxWidth()
.height(AppBarHeight * fontSizeSqrtMultiplier)
val themeBackgroundMix = MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f)
if (!closeBarTitle.isNullOrEmpty()) {
rowModifier = rowModifier.background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f))
rowModifier = rowModifier.background(themeBackgroundMix)
}
val handler = LocalAppBarHandler.current
val connection = LocalAppBarHandler.current?.connection
val title = remember(handler?.title?.value) { handler?.title ?: mutableStateOf("") }
Column(
verticalArrangement = arrangement,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = AppBarHeight * fontSizeSqrtMultiplier)
.drawWithCache {
val backgroundColor = if (appPlatform.isDesktop && connection != null) themeBackgroundMix.copy(alpha = topTitleAlpha(connection)) else Color.Transparent
onDrawBehind {
if (appPlatform.isDesktop) {
drawRect(backgroundColor)
}
}
}
) {
Row(
modifier = Modifier.padding(barPaddingValues),
content = {
Row(
rowModifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (showClose) {
if (showClose) {
NavigationButtonBack(tintColor = tintColor, onButtonClicked = close)
} else {
Spacer(Modifier)
}
if (!closeBarTitle.isNullOrEmpty()) {
Row(
Modifier.weight(1f),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Text(
closeBarTitle,
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
maxLines = 1
)
}
} else if (title.value.isNotEmpty() && connection != null) {
Row(
Modifier
.padding(start = if (showClose) 0.dp else DEFAULT_PADDING_HALF)
.weight(1f) // hides the title if something wants full width (eg, search field in chat profiles screen)
.graphicsLayer {
alpha = topTitleAlpha((connection))
}
.padding(start = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
title.value,
fontWeight = FontWeight.SemiBold,
maxLines = 1
)
}
} else {
Spacer(Modifier.weight(1f))
}
Row {
endButtons()
@@ -66,11 +97,24 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Co
}
}
)
if (closeBarTitle.isNullOrEmpty() && title.value.isNotEmpty() && connection != null) {
Divider(
Modifier
.graphicsLayer {
alpha = topTitleAlpha(connection)
}
)
}
}
}
@Composable
fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPadding: Boolean = true, bottomPadding: Dp = DEFAULT_PADDING * 1.5f + 8.dp) {
val handler = LocalAppBarHandler.current
val connection = handler?.connection
LaunchedEffect(title) {
handler?.title?.value = title
}
val theme = CurrentColors.collectAsState()
val titleColor = MaterialTheme.appColors.title
val brush = if (theme.value.base == DefaultTheme.SIMPLEX)
@@ -81,23 +125,37 @@ fun AppBarTitle(title: String, hostDevice: Pair<Long?, String>? = null, withPad
Text(
title,
Modifier
.fillMaxWidth()
.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp,),
.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, top = DEFAULT_PADDING_HALF, end = if (withPadding) DEFAULT_PADDING else 0.dp,)
.graphicsLayer {
alpha = bottomTitleAlpha(connection)
},
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h1.copy(brush = brush),
color = MaterialTheme.colors.primaryVariant,
textAlign = TextAlign.Center
textAlign = TextAlign.Start
)
if (hostDevice != null) {
HostDeviceTitle(hostDevice)
Box(Modifier.padding(start = if (withPadding) DEFAULT_PADDING else 0.dp, end = if (withPadding) DEFAULT_PADDING else 0.dp).graphicsLayer {
alpha = bottomTitleAlpha(connection)
}) {
HostDeviceTitle(hostDevice)
}
}
Spacer(Modifier.height(bottomPadding))
}
}
private fun topTitleAlpha(connection: CollapsingAppBarNestedScrollConnection) =
if (connection.appBarOffset.absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 0f
else ((-connection.appBarOffset * 1.5f) / (AppBarHandler.appBarMaxHeightPx)).coerceIn(0f, 1f)
private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?) =
if ((connection?.appBarOffset ?: 0f).absoluteValue < AppBarHandler.appBarMaxHeightPx / 3) 1f
else ((AppBarHandler.appBarMaxHeightPx) + (connection?.appBarOffset ?: 0f) / 1.5f).coerceAtLeast(0f) / AppBarHandler.appBarMaxHeightPx
@Composable
private fun HostDeviceTitle(hostDevice: Pair<Long?, String>, extraPadding: Boolean = false) {
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) {
Row(Modifier.fillMaxWidth().padding(top = 5.dp, bottom = if (extraPadding) DEFAULT_PADDING * 2 else DEFAULT_PADDING_HALF), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) {
Icon(painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300), null, Modifier.size(15.dp), tint = MaterialTheme.colors.secondary)
Spacer(Modifier.width(10.dp))
Text(hostDevice.second, color = MaterialTheme.colors.secondary)
@@ -0,0 +1,44 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.unit.Velocity
val LocalAppBarHandler: ProvidableCompositionLocal<AppBarHandler?> = staticCompositionLocalOf { null }
@Stable
class AppBarHandler(
listState: LazyListState = LazyListState(0, 0),
scrollState: ScrollState = ScrollState(initial = 0)
) {
val title = mutableStateOf("")
var listState by mutableStateOf(listState, structuralEqualityPolicy())
internal set
var scrollState by mutableStateOf(scrollState, structuralEqualityPolicy())
internal set
val connection = CollapsingAppBarNestedScrollConnection()
companion object {
var appBarMaxHeightPx: Int = 0
}
}
class CollapsingAppBarNestedScrollConnection(): NestedScrollConnection {
var appBarOffset: Float by mutableFloatStateOf(0f)
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
appBarOffset += available.y
return Offset(0f, 0f)
}
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
appBarOffset -= available.y
return Offset(x = 0f, 0f)
}
}
@@ -2,11 +2,12 @@ package chat.simplex.common.views.helpers
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import chat.simplex.common.model.ChatController.appPrefs
@@ -48,13 +49,15 @@ enum class ModalPlacement {
START, CENTER, END, FULLSCREEN
}
class ModalData {
class ModalData() {
private val state = mutableMapOf<String, MutableState<Any?>>()
fun <T> stateGetOrPut (key: String, default: () -> T): MutableState<T> =
state.getOrPut(key) { mutableStateOf(default() as Any) } as MutableState<T>
fun <T> stateGetOrPutNullable (key: String, default: () -> T?): MutableState<T?> =
state.getOrPut(key) { mutableStateOf(default() as Any?) } as MutableState<T?>
val appBarHandler = AppBarHandler()
}
class ModalManager(private val placement: ModalPlacement? = null) {
@@ -139,7 +142,13 @@ class ModalManager(private val placement: ModalPlacement? = null) {
fun showInView() {
// Without animation
if (modalCount.value > 0 && modalViews.lastOrNull()?.first == false) {
modalViews.lastOrNull()?.let { it.third(it.second, ::closeModal) }
modalViews.lastOrNull()?.let {
CompositionLocalProvider(
LocalAppBarHandler provides it.second.appBarHandler
) {
it.third(it.second, ::closeModal)
}
}
return
}
AnimatedContent(targetState = modalCount.value,
@@ -151,7 +160,13 @@ class ModalManager(private val placement: ModalPlacement? = null) {
}.using(SizeTransform(clip = false))
}
) {
modalViews.getOrNull(it - 1)?.let { it.third(it.second, ::closeModal) }
modalViews.getOrNull(it - 1)?.let {
CompositionLocalProvider(
LocalAppBarHandler provides it.second.appBarHandler
) {
it.third(it.second, ::closeModal)
}
}
// This is needed because if we delete from modalViews immediately on request, animation will be bad
if (toRemove.isNotEmpty() && it == modalCount.value && transition.currentState == EnterExitState.Visible && !transition.isRunning) {
runAtomically { toRemove.removeIf { elem -> modalViews.removeAt(elem); true } }
@@ -4,7 +4,7 @@ import SectionBottomSpacer
import SectionSpacer
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -17,7 +17,6 @@ import androidx.compose.ui.text.font.FontWeight
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.model.ChatController.getNetCfg
import chat.simplex.common.model.ChatController.startChat
import chat.simplex.common.model.ChatController.startChatWithTemporaryDatabase
@@ -147,20 +146,13 @@ private fun MigrateFromDeviceLayout(
) {
val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) }
val (scrollBarAlpha, scrollModifier, scrollJob) = platform.desktopScrollBarComponents()
val scrollState = rememberScrollState()
Column(
Modifier.fillMaxSize().verticalScroll(scrollState).then(if (appPlatform.isDesktop) scrollModifier else Modifier).height(IntrinsicSize.Max),
ColumnWithScrollBar(
Modifier.fillMaxSize(), maxIntrinsicSize = true
) {
AppBarTitle(stringResource(MR.strings.migrate_from_device_title))
SectionByState(migrationState, tempDatabaseFile.value, chatReceiver)
SectionBottomSpacer()
}
if (appPlatform.isDesktop) {
Box(Modifier.fillMaxSize()) {
platform.desktopScrollBar(scrollState, Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, false)
}
}
platform.androidLockPortraitOrientation()
}
@@ -155,20 +155,13 @@ private fun ModalData.MigrateToDeviceLayout(
close: () -> Unit,
) {
val tempDatabaseFile = rememberSaveable { mutableStateOf(fileForTemporaryDatabase()) }
val (scrollBarAlpha, scrollModifier, scrollJob) = platform.desktopScrollBarComponents()
val scrollState = rememberScrollState()
Column(
Modifier.fillMaxSize().verticalScroll(scrollState).then(if (appPlatform.isDesktop) scrollModifier else Modifier).height(IntrinsicSize.Max),
ColumnWithScrollBar(
Modifier.fillMaxSize(), maxIntrinsicSize = true
) {
AppBarTitle(stringResource(MR.strings.migrate_to_device_title))
SectionByState(migrationState, tempDatabaseFile.value, chatReceiver, close)
SectionBottomSpacer()
}
if (appPlatform.isDesktop) {
Box(Modifier.fillMaxSize()) {
platform.desktopScrollBar(scrollState, Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, false)
}
}
platform.androidLockPortraitOrientation()
}
@@ -68,10 +68,10 @@ fun NewChatSheet(rh: RemoteHostInfo?, close: () -> Unit) {
Column(modifier = Modifier.fillMaxSize()) {
NewChatSheetLayout(
addContact = {
ModalManager.start.showModalCloseable { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = closeAll ) }
ModalManager.start.showModalCloseable(endButtons = { AddContactLearnMoreButton() }) { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = closeAll ) }
},
scanPaste = {
ModalManager.start.showModalCloseable { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = closeAll) }
ModalManager.start.showModalCloseable(endButtons = { AddContactLearnMoreButton() }) { _ -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = appPlatform.isAndroid, close = closeAll) }
},
createGroup = {
ModalManager.start.showCustomModal { close -> AddGroupView(chatModel, chatModel.currentRemoteHost.value, close, closeAll) }
@@ -194,7 +194,15 @@ private fun NewChatSheetLayout(
(appPlatform.isAndroid && keyboardState == KeyboardState.Opened)
) {
0
} else if (listState.firstVisibleItemIndex == 0) offsetMultiplier * listState.firstVisibleItemScrollOffset else offsetMultiplier * 1000
} else if (oneHandUI.value && listState.firstVisibleItemIndex == 0) {
listState.firstVisibleItemScrollOffset
} else if (!oneHandUI.value && listState.firstVisibleItemIndex == 0) {
0
} else if (!oneHandUI.value && listState.firstVisibleItemIndex == 1) {
-listState.firstVisibleItemScrollOffset
} else {
offsetMultiplier * 1000
}
} else {
0
}
@@ -5,6 +5,7 @@ import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
@@ -96,66 +97,61 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC
}
}
Column(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = bottomPadding)
Column(Modifier.align(Alignment.CenterEnd).padding(bottom = bottomPadding, end = DEFAULT_PADDING)) {
AddContactLearnMoreButton()
BoxWithConstraints {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.new_chat), hostDevice(rh?.remoteHostId), bottomPadding = DEFAULT_PADDING)
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(
initialPage = selection.value.ordinal,
initialPageOffsetFraction = 0f
) { NewChatOption.values().size }
KeyChangeEffect(pagerState.currentPage) {
selection.value = NewChatOption.values()[pagerState.currentPage]
}
}
val scope = rememberCoroutineScope()
val pagerState = rememberPagerState(
initialPage = selection.value.ordinal,
initialPageOffsetFraction = 0f
) { NewChatOption.values().size }
KeyChangeEffect(pagerState.currentPage) {
selection.value = NewChatOption.values()[pagerState.currentPage]
}
TabRow(
selectedTabIndex = pagerState.currentPage,
backgroundColor = Color.Transparent,
contentColor = MaterialTheme.colors.primary,
) {
tabTitles.forEachIndexed { index, it ->
LeadingIconTab(
selected = pagerState.currentPage == index,
onClick = {
scope.launch {
pagerState.animateScrollToPage(index)
}
},
text = { Text(it, fontSize = 13.sp) },
icon = {
Icon(
if (NewChatOption.INVITE.ordinal == index) painterResource(MR.images.ic_repeat_one) else painterResource(MR.images.ic_qr_code),
it
)
},
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = MaterialTheme.colors.secondary,
)
}
}
HorizontalPager(state = pagerState, Modifier.fillMaxSize(), verticalAlignment = Alignment.Top, userScrollEnabled = appPlatform.isAndroid) { index ->
// LALAL SCROLLBAR DOESN'T WORK
ColumnWithScrollBar(
Modifier
.fillMaxSize(),
verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connReqInvitation.isEmpty()) Arrangement.Center else Arrangement.Top) {
Spacer(Modifier.height(DEFAULT_PADDING))
when (index) {
NewChatOption.INVITE.ordinal -> {
PrepareAndInviteView(rh?.remoteHostId, contactConnection, connReqInvitation, creatingConnReq)
}
NewChatOption.CONNECT.ordinal -> {
ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close)
}
TabRow(
selectedTabIndex = pagerState.currentPage,
backgroundColor = Color.Transparent,
contentColor = MaterialTheme.colors.primary,
) {
tabTitles.forEachIndexed { index, it ->
LeadingIconTab(
selected = pagerState.currentPage == index,
onClick = {
scope.launch {
pagerState.animateScrollToPage(index)
}
},
text = { Text(it, fontSize = 13.sp) },
icon = {
Icon(
if (NewChatOption.INVITE.ordinal == index) painterResource(MR.images.ic_repeat_one) else painterResource(MR.images.ic_qr_code),
it
)
},
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = MaterialTheme.colors.secondary,
)
}
}
HorizontalPager(state = pagerState, Modifier, pageNestedScrollConnection = LocalAppBarHandler.current!!.connection, verticalAlignment = Alignment.Top, userScrollEnabled = appPlatform.isAndroid) { index ->
Column(
Modifier
.fillMaxWidth()
.heightIn(min = this@BoxWithConstraints.maxHeight - 150.dp),
verticalArrangement = if (index == NewChatOption.INVITE.ordinal && connReqInvitation.isEmpty()) Arrangement.Center else Arrangement.Top
) {
Spacer(Modifier.height(DEFAULT_PADDING))
when (index) {
NewChatOption.INVITE.ordinal -> {
PrepareAndInviteView(rh?.remoteHostId, contactConnection, connReqInvitation, creatingConnReq)
}
NewChatOption.CONNECT.ordinal -> {
ConnectView(rh?.remoteHostId, showQRCodeScanner, pastedLink, close)
}
}
SectionBottomSpacer()
}
SectionBottomSpacer()
}
}
}
@@ -228,18 +224,18 @@ private fun InviteView(rhId: Long?, connReqInvitation: String, contactConnection
}
@Composable
private fun AddContactLearnMoreButton() {
fun AddContactLearnMoreButton() {
IconButton(
{
ModalManager.start.showModalCloseable { close ->
AddContactLearnMore(close)
}
},
Modifier.size(18.dp * fontSizeSqrtMultiplier)
}
) {
Icon(
painterResource(MR.images.ic_info),
stringResource(MR.strings.learn_more),
tint = MaterialTheme.colors.primary
)
}
}
@@ -23,9 +23,10 @@ import dev.icerock.moko.resources.StringResource
@Composable
fun HowItWorks(user: User?, onboardingStage: SharedPreference<OnboardingStage>? = null) {
ColumnWithScrollBar(Modifier
.fillMaxWidth()
.padding(DEFAULT_PADDING),
ColumnWithScrollBar(
Modifier
.fillMaxWidth()
.padding(DEFAULT_PADDING),
) {
AppBarTitle(stringResource(MR.strings.how_simplex_works), withPadding = false)
ReadableText(MR.strings.many_people_asked_how_can_it_deliver)
@@ -73,33 +73,30 @@ private fun SetDeliveryReceiptsLayout(
skip: () -> Unit,
userCount: Int,
) {
// This view located in the left panel which means it has to have a padding from right side in order
// to see scroll bar. And this padding should be applied to upper element, not scrollable column modifier
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val (scrollBarAlpha, scrollModifier, scrollJob) = platform.desktopScrollBarComponents()
val scrollState = rememberScrollState()
Column(
Modifier.fillMaxSize().verticalScroll(scrollState).then(if (appPlatform.isDesktop) scrollModifier else Modifier).padding(top = DEFAULT_PADDING, end = endPadding),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarTitle(stringResource(MR.strings.delivery_receipts_title))
Box(Modifier.padding(top = DEFAULT_PADDING, end = endPadding)) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarTitle(stringResource(MR.strings.delivery_receipts_title))
Spacer(Modifier.weight(1f))
Spacer(Modifier.weight(1f))
EnableReceiptsButton(enableReceipts)
if (userCount > 1) {
TextBelowButton(stringResource(MR.strings.sending_delivery_receipts_will_be_enabled_all_profiles))
} else {
TextBelowButton(stringResource(MR.strings.sending_delivery_receipts_will_be_enabled))
}
EnableReceiptsButton(enableReceipts)
if (userCount > 1) {
TextBelowButton(stringResource(MR.strings.sending_delivery_receipts_will_be_enabled_all_profiles))
} else {
TextBelowButton(stringResource(MR.strings.sending_delivery_receipts_will_be_enabled))
}
Spacer(Modifier.weight(1f))
Spacer(Modifier.weight(1f))
SkipButton(skip)
SkipButton(skip)
SectionBottomSpacer()
}
if (appPlatform.isDesktop) {
Box(Modifier.fillMaxSize().padding(end = endPadding)) {
platform.desktopScrollBar(scrollState, Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, false)
SectionBottomSpacer()
}
}
}
@@ -111,85 +111,73 @@ fun SettingsLayout(
}
val theme = CurrentColors.collectAsState()
val uriHandler = LocalUriHandler.current
Box(Modifier.fillMaxSize()) {
ColumnWithScrollBar(
Modifier
.fillMaxSize()
.themedBackground(theme.value.base)
.padding(top = if (appPlatform.isAndroid) DEFAULT_PADDING else DEFAULT_PADDING * 2.8f)
) {
AppBarTitle(stringResource(MR.strings.your_settings))
ColumnWithScrollBar(
Modifier
.fillMaxSize()
.themedBackground(theme.value.base)
) {
AppBarTitle(stringResource(MR.strings.your_settings))
SectionView(stringResource(MR.strings.settings_section_title_you)) {
val profileHidden = rememberSaveable { mutableStateOf(false) }
if (profile != null) {
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
}
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden, drawerState) } } }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true)
ChatPreferencesItem(showCustomModal, stopped = stopped)
} else if (chatModel.localUserCreated.value == false) {
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.create_chat_profile), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) {
closeSettings()
SectionView(stringResource(MR.strings.settings_section_title_you)) {
val profileHidden = rememberSaveable { mutableStateOf(false) }
if (profile != null) {
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(profile, stopped = stopped)
}
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.your_chat_profiles), { withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) { showSettingsModalWithSearch { it, search -> UserProfilesView(it, search, profileHidden, drawerState) } } }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_qr_code), stringResource(MR.strings.your_simplex_contact_address), showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped, extraPadding = true)
ChatPreferencesItem(showCustomModal, stopped = stopped)
} else if (chatModel.localUserCreated.value == false) {
SettingsActionItem(painterResource(MR.images.ic_manage_accounts), stringResource(MR.strings.create_chat_profile), {
withAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) {
closeSettings()
}
CreateProfile(chatModel, close)
}
CreateProfile(chatModel, close)
} } }, disabled = stopped, extraPadding = true)
}
if (appPlatform.isDesktop) {
SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped, extraPadding = true)
} else {
SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal{ it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true)
}
SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } }}, disabled = stopped, extraPadding = true)
}
}, disabled = stopped, extraPadding = true)
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) }, extraPadding = true)
DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
if (appPlatform.isDesktop) {
SettingsActionItem(painterResource(MR.images.ic_smartphone), stringResource(if (remember { chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles), showModal { ConnectMobileView() }, disabled = stopped, extraPadding = true)
} else {
SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal { it, close -> ConnectDesktopView(close) }, disabled = stopped, extraPadding = true)
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_help)) {
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }, extraPadding = true)
if (!chatModel.desktopNoUserNoRemote) {
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true)
}
SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary, extraPadding = true)
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_support)) {
ContributeItem(uriHandler)
RateAppItem(uriHandler)
StarOnGithubItem(uriHandler)
}
SectionDividerSpaced()
SettingsSectionApp(showSettingsModal, showCustomModal, showVersion, withAuth)
SectionBottomSpacer()
SettingsActionItem(painterResource(MR.images.ic_ios_share), stringResource(MR.strings.migrate_from_device_to_another_device), { withAuth(generalGetString(MR.strings.auth_open_migration_to_another_device), generalGetString(MR.strings.auth_log_in_using_credential)) { ModalManager.fullscreen.showCustomModal { close -> MigrateFromDeviceView(close) } } }, disabled = stopped, extraPadding = true)
}
if (appPlatform.isDesktop) {
Box(
Modifier
.fillMaxWidth()
.height(AppBarHeight * fontSizeSqrtMultiplier)
.background(MaterialTheme.colors.background)
.background(if (isInDarkTheme()) ToolbarDark else ToolbarLight)
.padding(start = 4.dp),
contentAlignment = Alignment.CenterStart
) {
NavigationButtonBack(closeSettings, height = 24.sp.toDp())
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_settings)) {
SettingsActionItem(painterResource(if (notificationsMode.value == NotificationsMode.OFF) MR.images.ic_bolt_off else MR.images.ic_bolt), stringResource(MR.strings.notifications), showSettingsModal { NotificationsSettingsView(it) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) }, extraPadding = true)
DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_help)) {
SettingsActionItem(painterResource(MR.images.ic_help), stringResource(MR.strings.how_to_use_simplex_chat), showModal { HelpView(userDisplayName ?: "") }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped, extraPadding = true)
SettingsActionItem(painterResource(MR.images.ic_info), stringResource(MR.strings.about_simplex_chat), showModal { SimpleXInfo(it, onboarding = false) }, extraPadding = true)
if (!chatModel.desktopNoUserNoRemote) {
SettingsActionItem(painterResource(MR.images.ic_tag), stringResource(MR.strings.chat_with_the_founder), { uriHandler.openVerifiedSimplexUri(simplexTeamUri) }, textColor = MaterialTheme.colors.primary, disabled = stopped, extraPadding = true)
}
SettingsActionItem(painterResource(MR.images.ic_mail), stringResource(MR.strings.send_us_an_email), { uriHandler.openUriCatching("mailto:chat@simplex.chat") }, textColor = MaterialTheme.colors.primary, extraPadding = true)
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_support)) {
ContributeItem(uriHandler)
RateAppItem(uriHandler)
StarOnGithubItem(uriHandler)
}
SectionDividerSpaced()
SettingsSectionApp(showSettingsModal, showCustomModal, showVersion, withAuth)
SectionBottomSpacer()
}
}
@@ -13,11 +13,12 @@ import chat.simplex.res.MR
@Composable
fun UserAddressLearnMore() {
ColumnWithScrollBar(Modifier
.fillMaxHeight()
.padding(horizontal = DEFAULT_PADDING)
ColumnWithScrollBar(
Modifier
.fillMaxHeight()
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(MR.strings.simplex_address))
AppBarTitle(stringResource(MR.strings.simplex_address), withPadding = false)
ReadableText(MR.strings.you_can_share_your_address)
ReadableText(MR.strings.you_wont_lose_your_contacts_if_delete_address)
ReadableText(MR.strings.you_can_accept_or_reject_connection)
@@ -174,7 +174,7 @@ private fun UserAddressLayout(
saveAas: (AutoAcceptState, MutableState<AutoAcceptState>) -> Unit,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId), withPadding = false)
AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId))
Column(
Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -77,6 +77,9 @@ actual fun windowOrientation(): WindowOrientation =
@Composable
actual fun windowWidth(): Dp = simplexWindowState.windowState.size.width
@Composable
actual fun windowHeight(): Dp = simplexWindowState.windowState.size.height
actual fun desktopExpandWindowToWidth(width: Dp) {
if (simplexWindowState.windowState.size.width >= width) return
simplexWindowState.windowState.size = simplexWindowState.windowState.size.copy(width = width)
@@ -13,16 +13,18 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.input.pointer.*
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.helpers.detectCursorMove
import chat.simplex.common.views.helpers.mixWith
import chat.simplex.common.views.helpers.*
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.filter
import kotlin.math.absoluteValue
@Composable
actual fun LazyColumnWithScrollBar(
modifier: Modifier,
state: LazyListState,
state: LazyListState?,
contentPadding: PaddingValues,
reverseLayout: Boolean,
verticalArrangement: Arrangement.Vertical,
@@ -30,44 +32,6 @@ actual fun LazyColumnWithScrollBar(
flingBehavior: FlingBehavior,
userScrollEnabled: Boolean,
content: LazyListScope.() -> Unit
) {
if (appPlatform.isAndroid) {
LazyColumn(modifier, state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
} else {
val scope = rememberCoroutineScope()
val scrollBarAlpha = remember { Animatable(0f) }
val scrollJob: MutableState<Job> = remember { mutableStateOf(Job()) }
val scrollModifier = remember {
Modifier
.pointerInput(Unit) {
detectCursorMove {
scope.launch {
scrollBarAlpha.animateTo(1f)
}
scrollJob.value.cancel()
scrollJob.value = scope.launch {
delay(1000L)
scrollBarAlpha.animateTo(0f)
}
}
}
}
Box {
LazyColumn(modifier.then(if (appPlatform.isDesktop) scrollModifier else Modifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout)
}
}
}
}
@Composable
actual fun ColumnWithScrollBar(
modifier: Modifier,
verticalArrangement: Arrangement.Vertical,
horizontalAlignment: Alignment.Horizontal,
state: ScrollState,
content: @Composable ColumnScope.() -> Unit
) {
val scope = rememberCoroutineScope()
val scrollBarAlpha = remember { Animatable(0f) }
@@ -87,20 +51,95 @@ actual fun ColumnWithScrollBar(
}
}
}
Column(modifier.verticalScroll(state).then(scrollModifier), verticalArrangement, horizontalAlignment, content)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.align(Alignment.CenterEnd).fillMaxHeight(), scrollBarAlpha, scrollJob, false)
val state = state ?: LocalAppBarHandler.current?.listState ?: rememberLazyListState()
val connection = LocalAppBarHandler.current?.connection
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on lazy column state
// (only first visible row is useful because LazyColumn doesn't have absolute scroll position, only relative to row)
val scrollBarDraggingState = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { state.firstVisibleItemScrollOffset }
.filter { state.firstVisibleItemIndex == 0 }
.collect { scrollPosition ->
val offset = connection?.appBarOffset
if (offset != null && ((offset + scrollPosition).absoluteValue > 1 || scrollBarDraggingState.value)) {
connection.appBarOffset = -scrollPosition.toFloat()
// Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
}
}
}
Box(if (connection != null) Modifier.nestedScroll(connection) else Modifier) {
LazyColumn(modifier.then(scrollModifier), state, contentPadding, reverseLayout, verticalArrangement, horizontalAlignment, flingBehavior, userScrollEnabled, content)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.fillMaxHeight(), scrollBarAlpha, scrollJob, reverseLayout, scrollBarDraggingState)
}
}
}
@Composable
fun DesktopScrollBar(adapter: androidx.compose.foundation.v2.ScrollbarAdapter, modifier: Modifier, scrollBarAlpha: Animatable<Float, AnimationVector1D>, scrollJob: MutableState<Job>, reversed: Boolean) {
actual fun ColumnWithScrollBar(
modifier: Modifier,
verticalArrangement: Arrangement.Vertical,
horizontalAlignment: Alignment.Horizontal,
state: ScrollState?,
maxIntrinsicSize: Boolean,
content: @Composable() (ColumnScope.() -> Unit)
) {
val scope = rememberCoroutineScope()
val scrollBarAlpha = remember { Animatable(0f) }
val scrollJob: MutableState<Job> = remember { mutableStateOf(Job()) }
val scrollModifier = remember {
Modifier
.pointerInput(Unit) {
detectCursorMove {
scope.launch {
scrollBarAlpha.animateTo(1f)
}
scrollJob.value.cancel()
scrollJob.value = scope.launch {
delay(1000L)
scrollBarAlpha.animateTo(0f)
}
}
}
}
val state = state ?: LocalAppBarHandler.current?.scrollState ?: rememberScrollState()
val connection = LocalAppBarHandler.current?.connection
// When scroll bar is dragging, there is no scroll event in nested scroll modifier. So, listen for changes on column state
// (exact scroll position is available but in Int, not Float)
val scrollBarDraggingState = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
snapshotFlow { state.value }
.collect { scrollPosition ->
val offset = connection?.appBarOffset
if (offset != null && ((offset + scrollPosition).absoluteValue > 1 || scrollBarDraggingState.value)) {
connection.appBarOffset = -scrollPosition.toFloat()
// Log.d(TAG, "Scrolling position changed from $offset to ${connection.appBarOffset}")
}
}
}
Box(if (connection != null) Modifier.nestedScroll(connection) else Modifier) {
Column(
if (maxIntrinsicSize) {
modifier.verticalScroll(state).height(IntrinsicSize.Max).then(scrollModifier)
} else {
modifier.verticalScroll(state).then(scrollModifier)
},
verticalArrangement, horizontalAlignment, content)
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.CenterEnd) {
DesktopScrollBar(rememberScrollbarAdapter(state), Modifier.fillMaxHeight(), scrollBarAlpha, scrollJob, false, scrollBarDraggingState)
}
}
}
@Composable
fun DesktopScrollBar(adapter: androidx.compose.foundation.v2.ScrollbarAdapter, modifier: Modifier, scrollBarAlpha: Animatable<Float, AnimationVector1D>, scrollJob: MutableState<Job>, reversed: Boolean, updateDraggingState: MutableState<Boolean> = remember { mutableStateOf(false) }) {
val scope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val isDragged by interactionSource.collectIsDraggedAsState()
LaunchedEffect(isHovered, isDragged) {
scrollJob.value.cancel()
updateDraggingState.value = isDragged
if (isHovered || isDragged) {
scrollBarAlpha.animateTo(1f)
} else {
@@ -48,38 +48,6 @@ private fun initHaskell() {
initHS()
platform = object: PlatformInterface {
@Composable
override fun desktopScrollBarComponents(): Triple<Animatable<Float, AnimationVector1D>, Modifier, MutableState<Job>> {
val scope = rememberCoroutineScope()
val scrollBarAlpha = remember { Animatable(0f) }
val scrollJob: MutableState<Job> = remember { mutableStateOf(Job()) }
val modifier = remember {
Modifier.pointerInput(Unit) {
detectCursorMove {
scope.launch {
scrollBarAlpha.animateTo(1f)
}
scrollJob.value.cancel()
scrollJob.value = scope.launch {
delay(1000L)
scrollBarAlpha.animateTo(0f)
}
}
}
}
return Triple(scrollBarAlpha, modifier, scrollJob)
}
@Composable
override fun desktopScrollBar(state: LazyListState, modifier: Modifier, scrollBarAlpha: Animatable<Float, AnimationVector1D>, scrollJob: MutableState<Job>, reversed: Boolean) {
DesktopScrollBar(rememberScrollbarAdapter(scrollState = state), modifier, scrollBarAlpha, scrollJob, reversed)
}
@Composable
override fun desktopScrollBar(state: ScrollState, modifier: Modifier, scrollBarAlpha: Animatable<Float, AnimationVector1D>, scrollJob: MutableState<Job>, reversed: Boolean) {
DesktopScrollBar(rememberScrollbarAdapter(scrollState = state), modifier, scrollBarAlpha, scrollJob, reversed)
}
@Composable
override fun desktopShowAppUpdateNotice() {
fun showNoticeIfNeeded() {