android, desktop: new user picker (#4796)

* user picker as modal

* android dirty layout

* color mode switcher

* close picker on desktop opening modals

* cleanup

* remote hosts working

* icon buttons

* profile picker modal for external shares

* remove stroke

* color changes

* add unread badge to users row

* chat database settings section

* safe remove of you settings section

* picker should now open for single user

* remove create profile from settings

* paddings

* handle big names

* fonts and align

* simple animations and shadow

* address messaging

* active is grey

* padding

* hide non active devices from pills

* picker positioning

* pills order

* change view all profiles icon

* bigger space between profiles

* hosts ordering and fixes

* device pill in app bar

* simplex address -> public

* better switch of opacity bg

* create public address

* font match

* add icon for dark mode

* padding

* profile name as header

* h2 is too big

* icon colors

* icons

* settings as modal

* center settings

* fix use from desktop

* remove logs

* bar colors

* remove drawer unused code

* animate shading

* fade in fade out

* add system mode toast

* shading colors

* stop pushing shade up

* same button as ios for opening all profiles

* simplify nav bar color set

* broken transition change

* color mix

* gradient and horizontal scroll

* separate title

* align avatars to top

* picker should always remain open

* use chevron icon to see all profiles

* improvements on status and nav color set

* best case bars switching working

* change bar and shading on theme change

* remove unused var

* reset navbar colors on navigate

* updated icon color

* protect android calls

* desktop menu matching size of right side modals

* remove shading from desktop

* close user picker on settings click in desktop

* bigger profile image smaller gap to name

* fix spacer for row scroll on android

* smaller profile name

* remove unused code

* small refactor

* unused

* move desktop/mobile connection

* close drawer on swipe down 30%

* progress dump on new android design

* paddings in scroller

* gradient

* android paddings

* split inactive user picker between platforms

* move your chat profiles inside android specific

* always show your chat profiles in desktop

* fix profile creation in desktop

* remove unused var

* update android space between badges

* initial desktop design

* center android icons with avatar

* centered avatars

* unread badge

* extra space in the end of user list for android

* aligned paddings on desktop

* desktop paddings

* paddings

* remove you

* unread badge same style as chatlist

* use bedtime moon for dark mode

* chevron same size as sun/moon

* chevron and gradient

* paddings

* split android and desktop scaffold for picker

* move bars logic to android

* remove android check

* more android checks

* initial version of swipable modal

* muted as grey

* unused

* close drawer on 3/4

* better close control

* make all animations match

* move shadow with offset

* always close pciker on selection

* animated float doing nothing

* sync animation

* animation using single float

* fixed warnings

* better state update

* fix scrim color

* better handling of picker closure on desktop

* landscape mode

* intentation

* rename UserPickerScaffold

* hide shadow when picker not open

* reset inactive user scroll position on pick

* unused class

* left panel after new menu can be without padding

* small changes

* make ActiveProfilePicker reusable to reduce code duplication

* make picker scrollable

* refactor

* refactor and fix instant reload of profiles

* refactor

* icon sizes

* returned back ability to scroll to the picker on Android

* setting system theme on desktop's right click

* box

* refactor

* picker pill

* fix desktop shadow

* small change

* hiding keyboard when opening picker

* state specifying

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
This commit is contained in:
Diogo
2024-09-11 15:51:28 +01:00
committed by GitHub
parent 1a853d4eea
commit acf2f1fbbe
23 changed files with 945 additions and 501 deletions
@@ -7,6 +7,7 @@ import chat.simplex.common.platform.Log
import android.content.Intent
import android.content.pm.ActivityInfo
import android.os.*
import androidx.compose.animation.core.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.graphics.Color
@@ -259,7 +260,6 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
override fun androidSetNightModeIfSupported() {
if (Build.VERSION.SDK_INT < 31) return
val light = if (CurrentColors.value.name == DefaultTheme.SYSTEM_THEME_NAME) {
null
} else {
@@ -274,6 +274,31 @@ class SimplexApp: Application(), LifecycleEventObserver, Configuration.Provider
uiModeManager.setApplicationNightMode(mode)
}
override fun androidSetDrawerStatusAndNavBarColor(
isLight: Boolean,
drawerShadingColor: Color,
toolbarOnTop: Boolean,
navBarColor: Color,
) {
val window = mainActivity.get()?.window ?: return
@Suppress("DEPRECATION")
val windowInsetController = ViewCompat.getWindowInsetsController(window.decorView)
// Blend status bar color to the animated color
val colors = CurrentColors.value.colors
val baseBackgroundColor = if (toolbarOnTop) colors.background.mixWith(colors.onBackground, 0.97f) else colors.background
window.statusBarColor = baseBackgroundColor.mixWith(drawerShadingColor.copy(1f), 1 - drawerShadingColor.alpha).toArgb()
val navBar = navBarColor.toArgb()
if (window.navigationBarColor != navBar) {
window.navigationBarColor = navBar
}
if (windowInsetController?.isAppearanceLightNavigationBars != isLight) {
windowInsetController?.isAppearanceLightNavigationBars = isLight
}
}
override fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {
val window = mainActivity.get()?.window ?: return
@Suppress("DEPRECATION")
@@ -0,0 +1,222 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.foundation.*
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.DrawerDefaults.ScrimOpacity
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.User
import chat.simplex.common.model.UserInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.OnboardingStage
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
actual fun UserPickerInactiveUsersSection(
users: List<UserInfo>,
stopped: Boolean,
onShowAllProfilesClicked: () -> Unit,
onUserClicked: (user: User) -> Unit,
) {
val scrollState = rememberScrollState()
if (users.isNotEmpty()) {
SectionItemView(
padding = PaddingValues(
start = 16.dp,
top = if (windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL else DEFAULT_PADDING_HALF,
bottom = DEFAULT_PADDING_HALF),
disabled = stopped
) {
Box {
Row(
modifier = Modifier.padding(end = DEFAULT_PADDING + 30.dp).horizontalScroll(scrollState)
) {
users.forEach { u ->
UserPickerInactiveUserBadge(u, stopped) {
onUserClicked(it)
withBGApi {
delay(500)
scrollState.scrollTo(0)
}
}
Spacer(Modifier.width(20.dp))
}
Spacer(Modifier.width(60.dp))
}
Row(
horizontalArrangement = Arrangement.End,
modifier = Modifier
.fillMaxWidth()
.padding(end = DEFAULT_PADDING + 30.dp)
.height(60.dp)
) {
Canvas(modifier = Modifier.size(60.dp)) {
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(
Color.Transparent,
CurrentColors.value.colors.surface,
)
),
)
}
}
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.height(60.dp)
.fillMaxWidth()
.padding(end = DEFAULT_PADDING)
) {
IconButton(
onClick = onShowAllProfilesClicked,
enabled = !stopped
) {
Icon(
painterResource(MR.images.ic_chevron_right),
stringResource(MR.strings.your_chat_profiles),
tint = MaterialTheme.colors.secondary,
modifier = Modifier.size(34.dp)
)
}
}
}
}
} else {
UserPickerOptionRow(
painterResource(MR.images.ic_manage_accounts),
stringResource(MR.strings.your_chat_profiles),
onShowAllProfilesClicked
)
}
}
private fun calculateFraction(pos: Float) =
(pos / 1f).coerceIn(0f, 1f)
@Composable
actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<AnimatedViewState>, content: @Composable () -> Unit) {
val pickerIsVisible = pickerState.collectAsState().value.isVisible()
val dismissState = rememberDismissState(initialValue = if (pickerIsVisible) DismissValue.Default else DismissValue.DismissedToEnd) {
if (it == DismissValue.DismissedToEnd && pickerState.value.isVisible()) {
pickerState.value = AnimatedViewState.HIDING
}
true
}
val height = remember { mutableIntStateOf(0) }
val heightValue = height.intValue
val clickableModifier = if (pickerIsVisible) {
Modifier.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING })
} else {
Modifier
}
Box(
Modifier
.fillMaxSize()
.then(clickableModifier)
.drawBehind {
val pos = when {
dismissState.progress.from == DismissValue.Default && dismissState.progress.to == DismissValue.Default -> 1f
dismissState.progress.from == DismissValue.DismissedToEnd && dismissState.progress.to == DismissValue.DismissedToEnd -> 0f
dismissState.progress.to == DismissValue.Default -> dismissState.progress.fraction
else -> 1 - dismissState.progress.fraction
}
val colors = CurrentColors.value.colors
val resultingColor = if (colors.isLight) colors.onSurface.copy(alpha = ScrimOpacity) else Color.Black.copy(0.64f)
val adjustedAlpha = resultingColor.alpha * calculateFraction(pos = pos)
val shadingColor = resultingColor.copy(alpha = adjustedAlpha)
if (pickerState.value.isVisible()) {
platform.androidSetDrawerStatusAndNavBarColor(
isLight = colors.isLight,
drawerShadingColor = shadingColor,
toolbarOnTop = !appPrefs.oneHandUI.get(),
navBarColor = colors.surface
)
} else if (ModalManager.start.modalCount.value == 0) {
platform.androidSetDrawerStatusAndNavBarColor(
isLight = colors.isLight,
drawerShadingColor = shadingColor,
toolbarOnTop = !appPrefs.oneHandUI.get(),
navBarColor = (if (appPrefs.oneHandUI.get() && appPrefs.onboardingStage.get() == OnboardingStage.OnboardingComplete) {
colors.background.mixWith(CurrentColors.value.colors.onBackground, 0.97f)
} else {
colors.background
})
)
}
drawRect(
if (pos != 0f) resultingColor else Color.Transparent,
alpha = calculateFraction(pos = pos)
)
}
.graphicsLayer {
if (heightValue == 0) {
alpha = 0f
}
translationY = dismissState.offset.value
},
contentAlignment = Alignment.BottomCenter
) {
Box(
Modifier.onSizeChanged { height.intValue = it.height }
) {
KeyChangeEffect(pickerIsVisible) {
if (pickerState.value.isVisible()) {
try {
dismissState.animateTo(DismissValue.Default, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.GONE
}
} else {
try {
dismissState.animateTo(DismissValue.DismissedToEnd, userPickerAnimSpec())
} catch (e: CancellationException) {
Log.e(TAG, "Cancelled animateTo2: ${e.stackTraceToString()}")
pickerState.value = AnimatedViewState.VISIBLE
}
}
}
val draggableModifier = if (height.intValue != 0)
Modifier.draggableBottomDrawerModifier(
state = dismissState,
swipeDistance = height.intValue.toFloat(),
)
else Modifier
Box(draggableModifier.then(modifier)) {
content()
}
}
}
}
private fun Modifier.draggableBottomDrawerModifier(
state: DismissState,
swipeDistance: Float,
): Modifier = this.swipeable(
state = state,
anchors = mapOf(0f to DismissValue.Default, swipeDistance to DismissValue.DismissedToEnd),
thresholds = { _, _ -> FractionalThreshold(0.3f) },
orientation = Orientation.Vertical,
resistance = null
)
@@ -6,14 +6,11 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.*
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
@@ -42,12 +39,6 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.math.sqrt
data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>,
val scaffoldState: ScaffoldState
)
@Composable
fun AppScreen() {
@@ -145,13 +136,11 @@ fun MainScreen() {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
val scaffoldState = rememberScaffoldState()
val settingsState = remember { SettingsViewState(userPickerState, scaffoldState) }
SetupClipboardListener()
if (appPlatform.isAndroid) {
AndroidScreen(settingsState)
AndroidScreen(userPickerState)
} else {
DesktopScreen(settingsState)
DesktopScreen(userPickerState)
}
}
}
@@ -249,7 +238,7 @@ fun MainScreen() {
val ANDROID_CALL_TOP_PADDING = 40.dp
@Composable
fun AndroidScreen(settingsState: SettingsViewState) {
fun AndroidScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
BoxWithConstraints {
val call = remember { chatModel.activeCall} .value
val showCallArea = call != null && call.callState != CallState.WaitCapabilities && call.callState != CallState.InvitationAccepted
@@ -262,7 +251,7 @@ fun AndroidScreen(settingsState: SettingsViewState) {
}
.padding(top = if (showCallArea) ANDROID_CALL_TOP_PADDING else 0.dp)
) {
StartPartOfScreen(settingsState)
StartPartOfScreen(userPickerState)
}
val scope = rememberCoroutineScope()
val onComposed: suspend (chatId: String?) -> Unit = { chatId ->
@@ -318,15 +307,15 @@ fun AndroidScreen(settingsState: SettingsViewState) {
}
@Composable
fun StartPartOfScreen(settingsState: SettingsViewState) {
fun StartPartOfScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
if (chatModel.setDeliveryReceipts.value) {
SetDeliveryReceiptsView(chatModel)
} else {
val stopped = chatModel.chatRunning.value == false
if (chatModel.sharedContent.value == null)
ChatListView(chatModel, settingsState, AppLock::setPerformLA, stopped)
ChatListView(chatModel, userPickerState, AppLock::setPerformLA, stopped)
else
ShareListView(chatModel, settingsState, stopped)
ShareListView(chatModel, stopped)
}
}
@@ -367,49 +356,41 @@ fun EndPartOfScreen() {
}
@Composable
fun DesktopScreen(settingsState: SettingsViewState) {
Box {
// 56.dp is a size of unused space of settings drawer
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier + 56.dp)) {
StartPartOfScreen(settingsState)
}
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
ModalManager.start.showInView()
SwitchingUsersView()
}
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
CenterPartOfScreen()
}
if (ModalManager.end.hasModalsOpen()) {
VerticalDivider()
}
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
EndPartOfScreen()
}
}
val (userPickerState, scaffoldState ) = settingsState
val scope = rememberCoroutineScope()
if (scaffoldState.drawerState.isOpen || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
Box(
Modifier
.fillMaxSize()
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
ModalManager.start.closeModals()
scope.launch { settingsState.scaffoldState.drawerState.close() }
})
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
fun DesktopScreen(userPickerState: MutableStateFlow<AnimatedViewState>) {
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
StartPartOfScreen(userPickerState)
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
UserPicker(chatModel, userPickerState, setPerformLA = AppLock::setPerformLA)
}
ModalManager.fullscreen.showInView()
}
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
ModalManager.start.showInView()
SwitchingUsersView()
}
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
CenterPartOfScreen()
}
if (ModalManager.end.hasModalsOpen()) {
VerticalDivider()
}
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
EndPartOfScreen()
}
}
if (userPickerState.collectAsState().value.isVisible() || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
Box(
Modifier
.fillMaxSize()
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
ModalManager.start.closeModals()
userPickerState.value = AnimatedViewState.HIDING
})
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
ModalManager.fullscreen.showInView()
}
@Composable
@@ -1,7 +1,6 @@
package chat.simplex.common.platform
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.AnimationVector1D
import androidx.compose.animation.core.*
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
@@ -22,6 +21,7 @@ interface PlatformInterface {
fun androidIsBackgroundCallAllowed(): Boolean = true
fun androidSetNightModeIfSupported() {}
fun androidSetStatusAndNavBarColors(isLight: Boolean, backgroundColor: Color, hasTop: Boolean, hasBottom: Boolean) {}
fun androidSetDrawerStatusAndNavBarColor(isLight: Boolean, drawerShadingColor: Color, toolbarOnTop: Boolean, navBarColor: Color) {}
fun androidStartCallActivity(acceptCall: Boolean, remoteHostId: Long? = null, chatId: ChatId? = null) {}
fun androidPictureInPictureAllowed(): Boolean = true
fun androidCallEnded() {}
@@ -109,7 +109,6 @@ fun TerminalLayout(
}
},
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
modifier = Modifier.navigationBarsWithImePadding()
) { contentPadding ->
Surface(
@@ -660,7 +660,6 @@ fun ChatLayout(
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
backgroundColor = Color.Unspecified
) { contentPadding ->
val wallpaperImage = MaterialTheme.wallpaper.type.image
@@ -23,7 +23,7 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.*
import chat.simplex.common.SettingsViewState
import chat.simplex.common.AppLock
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
@@ -135,7 +135,7 @@ fun ToggleChatListCard() {
}
@Composable
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
fun ChatListView(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
val oneHandUI = remember { appPrefs.oneHandUI.state }
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
@@ -153,18 +153,15 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
VideoPlayerHolder.stopAll()
}
}
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState ) = settingsState
Scaffold(
topBar = {
if (!oneHandUI.value) {
Column(Modifier.padding(end = endPadding)) {
Column {
ChatListToolbar(
scaffoldState.drawerState,
userPickerState,
stopped,
setPerformLA,
)
Divider()
}
@@ -172,33 +169,17 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
},
bottomBar = {
if (oneHandUI.value) {
Column(Modifier.padding(end = endPadding)) {
Column {
Divider()
ChatListToolbar(
scaffoldState.drawerState,
userPickerState,
stopped,
)
}
}
},
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
val handler = remember { AppBarHandler() }
CompositionLocalProvider(
LocalAppBarHandler provides handler
) {
ModalView(showClose = appPlatform.isDesktop, close = { scope.launch { scaffoldState.drawerState.close() } }) {
SettingsView(chatModel, setPerformLA, scaffoldState.drawerState)
}
setPerformLA,
)
}
}
},
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
drawerScrimColor = MaterialTheme.colors.onSurface.copy(alpha = if (isInDarkTheme()) 0.16f else 0.32f),
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (!oneHandUI.value && searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
FloatingActionButton(
@@ -208,7 +189,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
},
Modifier
.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp)
.padding(end = DEFAULT_PADDING - 16.dp, bottom = DEFAULT_PADDING - 16.dp)
.size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
@@ -224,7 +205,7 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
}
}
) {
Box(Modifier.padding(it).padding(end = endPadding)) {
Box(Modifier.padding(it)) {
Box(
modifier = Modifier
.fillMaxSize()
@@ -252,11 +233,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
UserPicker(
chatModel = chatModel,
userPickerState = userPickerState,
contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart
) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
userPickerState.value = AnimatedViewState.GONE
}
setPerformLA = AppLock::setPerformLA
)
}
}
}
@@ -278,7 +256,7 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
}
@Composable
private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
private fun ChatListToolbar(userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, setPerformLA: (Boolean) -> Unit) {
val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) }
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
val updatingProgress = remember { chatModel.updatingProgress }.value
@@ -344,23 +322,22 @@ private fun ChatListToolbar(drawerState: DrawerState, userPickerState: MutableSt
}
}
}
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
DefaultTopAppBar(
navigationButton = {
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
NavigationButtonMenu {
ModalManager.start.showModalCloseable { close ->
SettingsView(chatModel, setPerformLA, close)
}
}
} else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
val allRead = users
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
if (users.size == 1 && chatModel.remoteHosts.isEmpty()) {
scope.launch { drawerState.open() }
} else {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
}
},
@@ -11,31 +11,24 @@ import androidx.compose.ui.graphics.Color
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import chat.simplex.common.SettingsViewState
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.newchat.ActiveProfilePicker
import chat.simplex.res.MR
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stopped: Boolean) {
fun ShareListView(chatModel: ChatModel, stopped: Boolean) {
var searchInList by rememberSaveable { mutableStateOf("") }
val (userPickerState, scaffoldState) = settingsState
val endPadding = if (appPlatform.isDesktop) 56.dp else 0.dp
val oneHandUI = remember { appPrefs.oneHandUI.state }
Scaffold(
Modifier.padding(end = endPadding),
contentColor = LocalContentColor.current,
drawerContentColor = LocalContentColor.current,
scaffoldState = scaffoldState,
topBar = {
if (!oneHandUI.value) {
Column {
ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() }
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
Divider()
}
}
@@ -44,7 +37,7 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
if (oneHandUI.value) {
Column {
Divider()
ShareListToolbar(chatModel, userPickerState, stopped) { searchInList = it.trim() }
ShareListToolbar(chatModel, stopped) { searchInList = it.trim() }
}
}
}
@@ -92,21 +85,6 @@ fun ShareListView(chatModel: ChatModel, settingsState: SettingsViewState, stoppe
}
}
}
if (appPlatform.isAndroid) {
tryOrShowError("UserPicker", error = {}) {
UserPicker(
chatModel,
userPickerState,
showSettings = false,
showCancel = true,
contentAlignment = if (oneHandUI.value) Alignment.BottomStart else Alignment.TopStart,
cancelClicked = {
chatModel.sharedContent.value = null
userPickerState.value = AnimatedViewState.GONE
}
)
}
}
}
private fun hasSimplexLink(msg: String): Boolean {
@@ -122,7 +100,7 @@ private fun EmptyList() {
}
@Composable
private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
private fun ShareListToolbar(chatModel: ChatModel, stopped: Boolean, onSearchValueChanged: (String) -> Unit) {
var showSearch by rememberSaveable { mutableStateOf(false) }
val hideSearchOnBack = { onSearchValueChanged(""); showSearch = false }
if (showSearch) {
@@ -138,7 +116,24 @@ private fun ShareListToolbar(chatModel: ChatModel, userPickerState: MutableState
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
userPickerState.value = AnimatedViewState.VISIBLE
ModalManager.start.showCustomModal { close ->
val search = rememberSaveable { mutableStateOf("") }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
content = {
ActiveProfilePicker(
search = search,
rhId = chatModel.remoteHostId,
close = close,
contactConnection = null,
showIncognito = false
)
}
)
}
}
}
else -> NavigationButtonBack(onButtonClicked = {
@@ -1,53 +1,51 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.animation.core.*
import SectionView
import TextIconSpaced
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.*
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextStyle
import dev.icerock.moko.resources.compose.painterResource
import androidx.compose.ui.text.capitalize
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.stopRemoteHostAndReloadHosts
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.platform.*
import chat.simplex.common.views.CreateProfile
import chat.simplex.common.views.localauth.VerticalDivider
import chat.simplex.common.views.newchat.*
import chat.simplex.common.views.remote.*
import chat.simplex.common.views.usersettings.doWithAuth
import chat.simplex.common.views.usersettings.*
import chat.simplex.common.views.usersettings.AppearanceScope.ColorModeSwitcher
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.math.roundToInt
@Composable
fun UserPicker(
chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>,
showSettings: Boolean = true,
contentAlignment: Alignment = Alignment.TopStart,
showCancel: Boolean = false,
cancelClicked: () -> Unit = {},
useFromDesktopClicked: () -> Unit = {},
settingsClicked: () -> Unit = {},
setPerformLA: (Boolean) -> Unit,
) {
val scope = rememberCoroutineScope()
var newChat by remember { mutableStateOf(userPickerState.value) }
if (newChat.isVisible()) {
BackHandler {
@@ -67,18 +65,32 @@ fun UserPicker(
.sortedBy { it.hostDeviceName }
}
}
val animatedFloat = remember { Animatable(if (newChat.isVisible()) 0f else 1f) }
val view = LocalMultiplatformView()
LaunchedEffect(Unit) {
launch {
userPickerState.collect {
newChat = it
if (it.isVisible()) {
hideKeyboard(view)
}
launch {
animatedFloat.animateTo(if (newChat.isVisible()) 1f else 0f, newChatSheetAnimSpec())
if (newChat.isHiding()) userPickerState.value = AnimatedViewState.GONE
}
}
}
}
LaunchedEffect(Unit) {
launch {
snapshotFlow { ModalManager.start.modalCount.value }
.filter { it > 0 }
.collect {
closePicker(userPickerState)
}
}
}
LaunchedEffect(Unit) {
snapshotFlow { newChat.isVisible() }
.distinctUntilChanged()
@@ -124,110 +136,143 @@ fun UserPicker(
}
}
}
val UsersView: @Composable ColumnScope.() -> Unit = {
users.forEach { u ->
UserProfilePickerItem(u.user, u.unreadCount, openSettings = settingsClicked) {
userPickerState.value = AnimatedViewState.HIDING
if (!u.user.activeUser) {
withBGApi {
controller.showProgressIfNeeded {
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(u.user.remoteHostId, u.user.userId, null)
}
}
}
}
Divider(Modifier.requiredHeight(1.dp))
if (u.user.activeUser) Divider(Modifier.requiredHeight(0.5.dp))
}
}
val xOffset = with(LocalDensity.current) { 10.dp.roundToPx() }
val maxWidth = with(LocalDensity.current) { windowWidth() * density }
Box(Modifier
.fillMaxSize()
.offset { IntOffset(if (newChat.isGone()) -maxWidth.value.roundToInt() else xOffset, 0) }
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { userPickerState.value = AnimatedViewState.HIDING })
.padding(bottom = 10.dp, top = 10.dp)
.graphicsLayer {
alpha = animatedFloat.value
translationY = (if (appPrefs.oneHandUI.state.value) -1 else 1) * (animatedFloat.value - 1) * xOffset
},
contentAlignment = contentAlignment
PlatformUserPicker(
modifier = Modifier
.height(IntrinsicSize.Min)
.fillMaxWidth()
.then(if (newChat.isVisible()) Modifier.shadow(8.dp, clip = true) else Modifier)
.background(MaterialTheme.colors.surface)
.padding(vertical = DEFAULT_PADDING),
pickerState = userPickerState
) {
Column(
Modifier
.widthIn(min = 260.dp)
.width(IntrinsicSize.Min)
.height(IntrinsicSize.Min)
.shadow(8.dp, RoundedCornerShape(corner = CornerSize(25.dp)), clip = true)
.background(MaterialTheme.colors.surface, RoundedCornerShape(corner = CornerSize(25.dp)))
.clip(RoundedCornerShape(corner = CornerSize(25.dp)))
) {
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
Column(Modifier.weight(1f).verticalScroll(rememberScrollState())) {
if (remoteHosts.isNotEmpty()) {
if (currentRemoteHost == null && chatModel.localUserCreated.value == true) {
LocalDevicePickerItem(true) {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
}
Divider(Modifier.requiredHeight(1.dp))
} else if (currentRemoteHost != null) {
val connecting = rememberSaveable { mutableStateOf(false) }
RemoteHostPickerItem(currentRemoteHost,
actionButtonClick = {
userPickerState.value = AnimatedViewState.HIDING
stopRemoteHostAndReloadHosts(currentRemoteHost, true)
}) {
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(currentRemoteHost, connecting)
}
Divider(Modifier.requiredHeight(1.dp))
}
}
@Composable
fun FirstSection() {
if (remoteHosts.isNotEmpty()) {
val currentRemoteHost = remember { chatModel.currentRemoteHost }.value
val localDeviceActive = currentRemoteHost == null && chatModel.localUserCreated.value == true
UsersView()
if (remoteHosts.isNotEmpty() && currentRemoteHost != null && chatModel.localUserCreated.value == true) {
LocalDevicePickerItem(false) {
DevicePickerRow(
localDeviceActive = localDeviceActive,
remoteHosts = remoteHosts,
onRemoteHostClick = { h, connecting ->
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(h, connecting)
},
onLocalDeviceClick = {
userPickerState.value = AnimatedViewState.HIDING
switchToLocalDevice()
},
onRemoteHostActionButtonClick = { h ->
userPickerState.value = AnimatedViewState.HIDING
stopRemoteHostAndReloadHosts(h, true)
}
)
}
ActiveUserSection(
chatModel = chatModel,
userPickerState = userPickerState,
)
}
@Composable
fun SecondSection() {
GlobalSettingsSection(
chatModel = chatModel,
userPickerState = userPickerState,
setPerformLA = setPerformLA,
onUserClicked = { user ->
userPickerState.value = AnimatedViewState.HIDING
if (!user.activeUser) {
withBGApi {
controller.showProgressIfNeeded {
ModalManager.closeAllModalsEverywhere()
chatModel.controller.changeActiveUser(user.remoteHostId, user.userId, null)
}
}
}
},
onShowAllProfilesClicked = {
doWithAuth(
generalGetString(MR.strings.auth_open_chat_profiles),
generalGetString(MR.strings.auth_log_in_using_credential)
) {
ModalManager.start.showCustomModal { close ->
val search = rememberSaveable { mutableStateOf("") }
val profileHidden = rememberSaveable { mutableStateOf(false) }
ModalView(
{ close() },
endButtons = {
SearchTextField(Modifier.fillMaxWidth(), placeholder = stringResource(MR.strings.search_verb), alwaysVisible = true) { search.value = it }
},
content = { UserProfilesView(chatModel, search, profileHidden) })
}
}
Divider(Modifier.requiredHeight(1.dp))
}
remoteHosts.filter { !it.activeHost }.forEach { h ->
val connecting = rememberSaveable { mutableStateOf(false) }
RemoteHostPickerItem(h,
actionButtonClick = {
userPickerState.value = AnimatedViewState.HIDING
stopRemoteHostAndReloadHosts(h, false)
}) {
userPickerState.value = AnimatedViewState.HIDING
switchToRemoteHost(h, connecting)
}
Divider(Modifier.requiredHeight(1.dp))
)
}
if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) {
Column {
FirstSection()
Divider(Modifier.padding(DEFAULT_PADDING))
SecondSection()
}
} else {
Row {
Box(Modifier.weight(1f)) {
FirstSection()
}
VerticalDivider()
Box(Modifier.weight(1f)) {
SecondSection()
}
}
if (appPlatform.isAndroid) {
UseFromDesktopPickerItem {
ModalManager.start.showCustomModal { close ->
ConnectDesktopView(close)
}
userPickerState.value = AnimatedViewState.GONE
}
Divider(Modifier.requiredHeight(1.dp))
} else {
if (remoteHosts.isEmpty()) {
LinkAMobilePickerItem {
ModalManager.start.showModal {
ConnectMobileView()
}
userPickerState.value = AnimatedViewState.GONE
}
Divider(Modifier.requiredHeight(1.dp))
}
if (chatModel.desktopNoUserNoRemote) {
CreateInitialProfile {
}
}
}
@Composable
private fun ActiveUserSection(
chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>,
) {
val showCustomModal: (@Composable() (ModalData.(ChatModel, () -> Unit) -> Unit)) -> () -> Unit = { modalView ->
{
ModalManager.start.showCustomModal { close -> modalView(chatModel, close) }
}
}
val currentUser = remember { chatModel.currentUser }.value
val stopped = chatModel.chatRunning.value == false
if (currentUser != null) {
SectionView {
SectionItemView(showCustomModal { chatModel, close -> UserProfileView(chatModel, close) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(currentUser.profile, stopped = stopped)
}
UserPickerOptionRow(
painterResource(MR.images.ic_qr_code),
if (chatModel.userAddress.value != null) generalGetString(MR.strings.your_public_contact_address) else generalGetString(MR.strings.create_public_contact_address),
showCustomModal { it, close -> UserAddressView(it, shareViaProfile = it.currentUser.value!!.addressShared, close = close) }, disabled = stopped
)
UserPickerOptionRow(
painterResource(MR.images.ic_toggle_on),
stringResource(MR.strings.chat_preferences),
click = if (stopped) null else ({
showCustomModal { m, close ->
PreferencesView(m, m.currentUser.value ?: return@showCustomModal, close)
}()
}),
disabled = stopped
)
}
} else {
SectionView {
if (chatModel.desktopNoUserNoRemote) {
UserPickerOptionRow(
painterResource(MR.images.ic_manage_accounts),
generalGetString(MR.strings.create_chat_profile),
{
doWithAuth(generalGetString(MR.strings.auth_open_chat_profiles), generalGetString(MR.strings.auth_log_in_using_credential)) {
ModalManager.center.showModalCloseable { close ->
LaunchedEffect(Unit) {
@@ -237,15 +282,76 @@ fun UserPicker(
}
}
}
Divider(Modifier.requiredHeight(1.dp))
)
}
}
}
}
@Composable
private fun GlobalSettingsSection(
chatModel: ChatModel,
userPickerState: MutableStateFlow<AnimatedViewState>,
setPerformLA: (Boolean) -> Unit,
onUserClicked: (user: User) -> Unit,
onShowAllProfilesClicked: () -> Unit
) {
val stopped = chatModel.chatRunning.value == false
val users by remember {
derivedStateOf {
chatModel.users
.filter { u -> !u.user.hidden && !u.user.activeUser }
}
}
SectionView(headerBottomPadding = if (appPlatform.isDesktop || windowOrientation() == WindowOrientation.PORTRAIT) DEFAULT_PADDING else 0.dp) {
UserPickerInactiveUsersSection(
users = users,
onShowAllProfilesClicked = onShowAllProfilesClicked,
onUserClicked = onUserClicked,
stopped = stopped
)
if (appPlatform.isAndroid) {
val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current)
UserPickerOptionRow(
painterResource(MR.images.ic_desktop),
text,
click = {
ModalManager.start.showCustomModal { close ->
ConnectDesktopView(close)
}
}
}
if (showSettings) {
SettingsPickerItem(settingsClicked)
}
if (showCancel) {
CancelPickerItem(cancelClicked)
}
)
} else {
UserPickerOptionRow(
icon = painterResource(MR.images.ic_smartphone_300),
text = stringResource(if (remember { chat.simplex.common.platform.chatModel.remoteHosts }.isEmpty()) MR.strings.link_a_mobile else MR.strings.linked_mobiles),
click = {
userPickerState.value = AnimatedViewState.HIDING
ModalManager.start.showModal {
ConnectMobileView()
}
},
disabled = stopped
)
}
SectionItemView(
click = {
ModalManager.start.showModalCloseable { close ->
SettingsView(chatModel, setPerformLA, close)
}
},
padding = PaddingValues(start = DEFAULT_PADDING * 1.7f, end = DEFAULT_PADDING + 2.dp)
) {
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
Icon(painterResource(MR.images.ic_settings), text, tint = MaterialTheme.colors.secondary)
TextIconSpaced()
Text(text, color = Color.Unspecified)
Spacer(Modifier.weight(1f))
ColorModeSwitcher()
}
}
}
@@ -296,7 +402,7 @@ fun UserProfilePickerItem(
}
} else if (!u.showNtfs) {
Icon(painterResource(MR.images.ic_notifications_off), null, Modifier.size(20.dp), tint = MaterialTheme.colors.secondary)
} else {
} else {
Box(Modifier.size(20.dp))
}
}
@@ -325,136 +431,157 @@ fun UserProfileRow(u: User, enabled: Boolean = chatModel.chatRunning.value == tr
}
@Composable
fun RemoteHostPickerItem(h: RemoteHostInfo, onLongClick: () -> Unit = {}, actionButtonClick: () -> Unit = {}, onClick: () -> Unit) {
Row(
Modifier
.fillMaxWidth()
.background(color = if (h.activeHost) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
.onRightClick { onLongClick() }
.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
RemoteHostRow(h)
if (h.sessionState is RemoteHostSessionState.Connected) {
HostDisconnectButton(actionButtonClick)
} else {
Box(Modifier.size(20.dp))
fun UserPickerOptionRow(icon: Painter, text: String, click: (() -> Unit)? = null, disabled: Boolean = false) {
SectionItemView(click, disabled = disabled, extraPadding = true) {
Icon(icon, text, tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.secondary)
TextIconSpaced()
Text(text = text, color = if (disabled) MaterialTheme.colors.secondary else Color.Unspecified)
}
}
@Composable
fun UserPickerInactiveUserBadge(userInfo: UserInfo, stopped: Boolean, size: Dp = 60.dp, onClick: (user: User) -> Unit) {
Box {
IconButton(
onClick = { onClick(userInfo.user) },
enabled = !stopped
) {
Box {
ProfileImage(size = size, image = userInfo.user.profile.image, color = MaterialTheme.colors.secondaryVariant)
if (userInfo.unreadCount > 0) {
unreadBadge(userInfo.unreadCount, userInfo.user.showNtfs)
}
}
}
}
}
@Composable
fun RemoteHostRow(h: RemoteHostInfo) {
Row(
Modifier
.widthIn(max = windowWidth() * 0.7f)
.padding(start = 17.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(painterResource(MR.images.ic_smartphone_300), h.hostDeviceName, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Text(
h.hostDeviceName,
modifier = Modifier.padding(start = 26.dp, end = 8.dp),
color = if (h.activeHost) MaterialTheme.colors.onBackground else MenuTextColor,
fontSize = 14.sp,
)
}
}
@Composable
fun LocalDevicePickerItem(active: Boolean, onLongClick: () -> Unit = {}, onClick: () -> Unit) {
private fun DevicePickerRow(
localDeviceActive: Boolean,
remoteHosts: List<RemoteHostInfo>,
onLocalDeviceClick: () -> Unit,
onRemoteHostClick: (rh: RemoteHostInfo, connecting: MutableState<Boolean>) -> Unit,
onRemoteHostActionButtonClick: (rh: RemoteHostInfo) -> Unit,
) {
Row(
Modifier
.fillMaxWidth()
.background(color = if (active) MaterialTheme.colors.surface.mixWith(MaterialTheme.colors.onBackground, 0.95f) else Color.Unspecified)
.sizeIn(minHeight = DEFAULT_MIN_SECTION_ITEM_HEIGHT)
.combinedClickable(
onClick = if (active) {{}} else onClick,
onLongClick = onLongClick,
interactionSource = remember { MutableInteractionSource() },
indication = if (!active) LocalIndication.current else null
)
.onRightClick { onLongClick() }
.padding(start = DEFAULT_PADDING_HALF, end = DEFAULT_PADDING),
horizontalArrangement = Arrangement.SpaceBetween,
.padding(start = DEFAULT_PADDING, end = DEFAULT_PADDING, bottom = DEFAULT_PADDING, top = DEFAULT_MIN_SECTION_ITEM_PADDING_VERTICAL),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
LocalDeviceRow(active)
Box(Modifier.size(20.dp))
val activeHost = remoteHosts.firstOrNull { h -> h.activeHost }
if (activeHost != null) {
val connecting = rememberSaveable { mutableStateOf(false) }
DevicePill(
active = true,
icon = painterResource(MR.images.ic_smartphone_300),
text = activeHost.hostDeviceName,
actionButtonVisible = activeHost.sessionState is RemoteHostSessionState.Connected,
onActionButtonClick = { onRemoteHostActionButtonClick(activeHost) }
) {
onRemoteHostClick(activeHost, connecting)
}
}
DevicePill(
active = localDeviceActive,
icon = painterResource(MR.images.ic_desktop),
text = stringResource(MR.strings.this_device),
actionButtonVisible = false
) {
onLocalDeviceClick()
}
remoteHosts.filter { h -> h.sessionState is RemoteHostSessionState.Connected && !h.activeHost }.forEach { h ->
val connecting = rememberSaveable { mutableStateOf(false) }
DevicePill(
active = h.activeHost,
icon = painterResource(MR.images.ic_smartphone_300),
text = h.hostDeviceName,
actionButtonVisible = h.sessionState is RemoteHostSessionState.Connected,
onActionButtonClick = { onRemoteHostActionButtonClick(h) }
) {
onRemoteHostClick(h, connecting)
}
}
}
}
@Composable
fun LocalDeviceRow(active: Boolean) {
expect fun UserPickerInactiveUsersSection(
users: List<UserInfo>,
stopped: Boolean,
onShowAllProfilesClicked: () -> Unit,
onUserClicked: (user: User) -> Unit,
)
@Composable
expect fun PlatformUserPicker(
modifier: Modifier,
pickerState: MutableStateFlow<AnimatedViewState>,
content: @Composable () -> Unit
)
@Composable
fun DevicePill(
active: Boolean,
icon: Painter,
text: String,
actionButtonVisible: Boolean,
onActionButtonClick: (() -> Unit)? = null,
onClick: () -> Unit) {
Row(
Modifier
.widthIn(max = windowWidth() * 0.7f)
.padding(start = 17.dp, end = DEFAULT_PADDING),
.clip(RoundedCornerShape(8.dp))
.border(
BorderStroke(1.dp, MaterialTheme.colors.secondaryVariant),
shape = RoundedCornerShape(8.dp)
)
.background(if (active) MaterialTheme.colors.secondaryVariant else Color.Transparent)
.clickable(
enabled = !active,
onClick = onClick,
interactionSource = remember { MutableInteractionSource() },
indication = LocalIndication.current
),
verticalAlignment = Alignment.CenterVertically
) {
Icon(painterResource(MR.images.ic_desktop), stringResource(MR.strings.this_device), Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Text(
stringResource(MR.strings.this_device),
modifier = Modifier.padding(start = 26.dp, end = 8.dp),
color = if (active) MaterialTheme.colors.onBackground else MenuTextColor,
fontSize = 14.sp,
)
}
}
@Composable
private fun UseFromDesktopPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current)
Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
}
@Composable
private fun LinkAMobilePickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.link_a_mobile)
Icon(painterResource(MR.images.ic_smartphone_300), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
}
@Composable
private fun CreateInitialProfile(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.create_chat_profile)
Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
}
@Composable
private fun SettingsPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
Icon(painterResource(MR.images.ic_settings), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
}
@Composable
private fun CancelPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.cancel_verb)
Icon(painterResource(MR.images.ic_close), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
Row(
Modifier.padding(horizontal = 6.dp, vertical = 4.dp)
) {
Icon(
icon,
text,
Modifier.size(16.dp * fontSizeSqrtMultiplier),
tint = MaterialTheme.colors.onSurface
)
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier))
Text(
text,
color = MaterialTheme.colors.onSurface,
fontSize = 12.sp,
)
if (onActionButtonClick != null && actionButtonVisible) {
val interactionSource = remember { MutableInteractionSource() }
val hovered = interactionSource.collectIsHoveredAsState().value
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON * fontSizeSqrtMultiplier))
IconButton(onActionButtonClick, Modifier.requiredSize(16.dp * fontSizeSqrtMultiplier)) {
Icon(
painterResource(if (hovered) MR.images.ic_wifi_off else MR.images.ic_wifi),
null,
Modifier.size(16.dp * fontSizeSqrtMultiplier).hoverable(interactionSource),
tint = if (hovered) WarningOrange else MaterialTheme.colors.onBackground
)
}
}
}
}
}
@@ -472,6 +599,29 @@ fun HostDisconnectButton(onClick: (() -> Unit)?) {
}
}
@Composable
private fun BoxScope.unreadBadge(unreadCount: Int, userMuted: Boolean) {
Text(
if (unreadCount > 0) unreadCountStr(unreadCount) else "",
color = Color.White,
fontSize = 10.sp,
style = TextStyle(textAlign = TextAlign.Center),
modifier = Modifier
.offset(y = 3.sp.toDp())
.background(if (userMuted) MaterialTheme.colors.primaryVariant else MaterialTheme.colors.secondary, shape = CircleShape)
.badgeLayout()
.padding(horizontal = 2.sp.toDp())
.padding(vertical = 2.sp.toDp())
.align(Alignment.TopEnd)
)
}
private suspend fun closePicker(userPickerState: MutableStateFlow<AnimatedViewState>) {
delay(500)
userPickerState.value = AnimatedViewState.HIDING
}
private fun switchToLocalDevice() {
withBGApi {
chatController.switchUIRemoteHost(null)
@@ -7,3 +7,5 @@ fun <T> chatListAnimationSpec() = tween<T>(durationMillis = 250, easing = FastOu
fun <T> newChatSheetAnimSpec() = tween<T>(256, 0, LinearEasing)
fun <T> audioProgressBarAnimationSpec() = tween<T>(durationMillis = 30, easing = LinearEasing)
fun <T> userPickerAnimSpec() = tween<T>(256, 0, FastOutSlowInEasing)
@@ -16,6 +16,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.*
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chatlist.DevicePill
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlin.math.absoluteValue
@@ -157,9 +158,13 @@ private fun bottomTitleAlpha(connection: CollapsingAppBarNestedScrollConnection?
@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.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)
DevicePill(
active = true,
onClick = {},
actionButtonVisible = false,
icon = painterResource(if (hostDevice.first == null) MR.images.ic_desktop else MR.images.ic_smartphone_300),
text = hostDevice.second
)
}
}
@@ -588,9 +588,11 @@ fun <T> KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1) {
if (anyChange || key1 != prevKey) {
block(prevKey)
val prev = prevKey
prevKey = key1
anyChange = true
// Call it as the last statement because the coroutine can be cancelled earlier
block(prev)
}
}
}
@@ -610,8 +612,8 @@ fun KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1, key2) {
if (anyChange || key1 != initialKey || key2 != initialKey2) {
block()
anyChange = true
block()
}
}
}
@@ -633,8 +635,8 @@ fun KeyChangeEffect(
var anyChange by remember { mutableStateOf(false) }
LaunchedEffect(key1, key2, key3) {
if (anyChange || key1 != initialKey || key2 != initialKey2 || key3 != initialKey3) {
block()
anyChange = true
block()
}
}
}
@@ -2,7 +2,6 @@ package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionItemView
import SectionSpacer
import SectionTextFooter
import SectionView
import TextIconSpaced
@@ -267,24 +266,13 @@ private fun ProfilePickerOption(
)
}
private fun filteredProfiles(users: List<User>, searchTextOrPassword: String): List<User> {
val s = searchTextOrPassword.trim()
val lower = s.lowercase()
return users.filter { u ->
if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) {
true
} else {
correctPassword(u, s)
}
}
}
@Composable
private fun ActiveProfilePicker(
fun ActiveProfilePicker(
search: MutableState<String>,
contactConnection: PendingContactConnection?,
close: () -> Unit,
rhId: Long?
rhId: Long?,
showIncognito: Boolean = true
) {
val switchingProfile = remember { mutableStateOf(false) }
val incognito = remember {
@@ -292,11 +280,9 @@ private fun ActiveProfilePicker(
}
val selectedProfile by remember { chatModel.currentUser }
val searchTextOrPassword = rememberSaveable { search }
val profiles = remember {
chatModel.users.map { it.user }.sortedBy { !it.activeUser }
}
val filteredProfiles by remember {
derivedStateOf { filteredProfiles(profiles, searchTextOrPassword.value) }
// Intentionally don't use derivedStateOf in order to NOT change an order after user was selected
val filteredProfiles = remember(searchTextOrPassword.value) {
filteredProfiles(chatModel.users.map { it.user }.sortedBy { !it.activeUser }, searchTextOrPassword.value)
}
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
@@ -322,32 +308,38 @@ private fun ActiveProfilePicker(
switchingProfile.value = true
withApi {
try {
var updatedConn: PendingContactConnection? = null;
if (contactConnection != null) {
val conn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId)
if (conn != null) {
updatedConn = controller.apiChangeConnectionUser(rhId, contactConnection.pccConnId, user.userId)
if (updatedConn != null) {
withChats {
updateContactConnection(rhId, conn)
updateShownConnection(conn)
updateContactConnection(rhId, updatedConn)
updateShownConnection(updatedConn)
}
controller.changeActiveUser_(
rhId = user.remoteHostId,
toUserId = user.userId,
viewPwd = if (user.hidden) searchTextOrPassword.value else null
)
if (chatModel.currentUser.value?.userId != user.userId) {
AlertManager.shared.showAlertMsg(generalGetString(
MR.strings.switching_profile_error_title),
String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName)
)
}
withChats {
updateContactConnection(user.remoteHostId, conn)
}
close.invoke()
}
}
controller.changeActiveUser_(
rhId = user.remoteHostId,
toUserId = user.userId,
viewPwd = if (user.hidden) searchTextOrPassword.value else null
)
if (chatModel.currentUser.value?.userId != user.userId) {
AlertManager.shared.showAlertMsg(generalGetString(
MR.strings.switching_profile_error_title),
String.format(generalGetString(MR.strings.switching_profile_error_message), user.chatViewName)
)
}
if (updatedConn != null) {
withChats {
updateContactConnection(user.remoteHostId, updatedConn)
}
}
close()
} finally {
switchingProfile.value = false
}
@@ -364,24 +356,21 @@ private fun ActiveProfilePicker(
title = stringResource(MR.strings.incognito),
selected = incognito,
onSelected = {
if (!incognito) {
switchingProfile.value = true
withApi {
try {
if (contactConnection != null) {
val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true)
if (incognito || switchingProfile.value || contactConnection == null) return@ProfilePickerOption
if (conn != null) {
withChats {
updateContactConnection(rhId, conn)
updateShownConnection(conn)
}
close.invoke()
}
switchingProfile.value = true
withApi {
try {
val conn = controller.apiSetConnectionIncognito(rhId, contactConnection.pccConnId, true)
if (conn != null) {
withChats {
updateContactConnection(rhId, conn)
updateShownConnection(conn)
}
} finally {
switchingProfile.value = false
close()
}
} finally {
switchingProfile.value = false
}
}
},
@@ -413,20 +402,18 @@ private fun ActiveProfilePicker(
if (activeProfile != null) {
val otherProfiles = filteredProfiles.filter { it.userId != activeProfile.userId }
if (incognito) {
item {
IncognitoUserOption()
}
item {
ProfilePickerUserOption(activeProfile)
}
} else {
item {
ProfilePickerUserOption(activeProfile)
}
item {
IncognitoUserOption()
item {
when {
!showIncognito ->
ProfilePickerUserOption(activeProfile)
incognito -> {
IncognitoUserOption()
ProfilePickerUserOption(activeProfile)
}
else -> {
ProfilePickerUserOption(activeProfile)
IncognitoUserOption()
}
}
}
@@ -434,8 +421,10 @@ private fun ActiveProfilePicker(
ProfilePickerUserOption(p)
}
} else {
item {
IncognitoUserOption()
if (showIncognito) {
item {
IncognitoUserOption()
}
}
itemsIndexed(filteredProfiles) { _, p ->
ProfilePickerUserOption(p)
@@ -641,6 +630,18 @@ fun LinkTextView(link: String, share: Boolean) {
}
}
private fun filteredProfiles(users: List<User>, searchTextOrPassword: String): List<User> {
val s = searchTextOrPassword.trim()
val lower = s.lowercase()
return users.filter { u ->
if ((u.activeUser || !u.hidden) && (s == "" || u.anyNameContains(lower))) {
true
} else {
correctPassword(u, s)
}
}
}
private suspend fun verify(rhId: Long?, text: String?, close: () -> Unit): Boolean {
if (text != null && strIsSimplexLink(text)) {
connect(rhId, text, close)
@@ -9,6 +9,7 @@ import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.MaterialTheme.colors
@@ -606,6 +607,39 @@ object AppearanceScope {
}
}
@Composable
fun ColorModeSwitcher() {
val currentTheme by CurrentColors.collectAsState()
val themeMode = if (remember { appPrefs.currentTheme.state }.value == DefaultTheme.SYSTEM_THEME_NAME) {
if (systemInDarkThemeCurrently) DefaultThemeMode.DARK else DefaultThemeMode.LIGHT
} else {
currentTheme.base.mode
}
val onLongClick = {
ThemeManager.applyTheme(DefaultTheme.SYSTEM_THEME_NAME)
showToast(generalGetString(MR.strings.system_mode_toast))
saveThemeToDatabase(null)
}
Box(
modifier = Modifier
.clip(CircleShape)
.combinedClickable(
onClick = {
ThemeManager.applyTheme(if (themeMode == DefaultThemeMode.LIGHT) appPrefs.systemDarkTheme.get()!! else DefaultTheme.LIGHT.themeName)
saveThemeToDatabase(null)
},
onLongClick = onLongClick
)
.onRightClick(onLongClick)
.size(44.dp),
contentAlignment = Alignment.Center
) {
Icon(painterResource(if (themeMode == DefaultThemeMode.LIGHT) MR.images.ic_light_mode else MR.images.ic_bedtime_moon), stringResource(MR.strings.color_mode_light), tint = MaterialTheme.colors.secondary)
}
}
private var updateBackendJob: Job = Job()
private fun saveThemeToDatabase(themeUserDestination: Pair<Long, ThemeModeOverrides?>?) {
val remoteHostId = chatModel.remoteHostId()
@@ -73,10 +73,7 @@ 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
Box(Modifier.padding(top = DEFAULT_PADDING, end = endPadding)) {
Box(Modifier.padding(top = DEFAULT_PADDING)) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -31,13 +31,11 @@ import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.migration.MigrateFromDeviceView
import chat.simplex.common.views.onboarding.SimpleXInfo
import chat.simplex.common.views.onboarding.WhatsNewView
import chat.simplex.common.views.remote.ConnectDesktopView
import chat.simplex.common.views.remote.ConnectMobileView
import chat.simplex.res.MR
import kotlinx.coroutines.*
@Composable
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerState: DrawerState) {
fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, close: () -> Unit) {
val user = chatModel.currentUser.value
val stopped = chatModel.chatRunning.value == false
SettingsLayout(
@@ -71,10 +69,9 @@ fun SettingsView(chatModel: ChatModel, setPerformLA: (Boolean) -> Unit, drawerSt
}
},
withAuth = ::doWithAuth,
drawerState = drawerState,
)
KeyChangeEffect(chatModel.updatingProgress.value != null) {
drawerState.close()
close()
}
}
@@ -96,18 +93,11 @@ fun SettingsLayout(
showCustomModal: (@Composable ModalData.(ChatModel, () -> Unit) -> Unit) -> (() -> Unit),
showVersion: () -> Unit,
withAuth: (title: String, desc: String, block: () -> Unit) -> Unit,
drawerState: DrawerState,
) {
val scope = rememberCoroutineScope()
val closeSettings: () -> Unit = { scope.launch { drawerState.close() } }
val view = LocalMultiplatformView()
if (drawerState.isOpen) {
BackHandler {
closeSettings()
}
LaunchedEffect(Unit) {
hideKeyboard(view)
}
LaunchedEffect(Unit) {
hideKeyboard(view)
}
val theme = CurrentColors.collectAsState()
val uriHandler = LocalUriHandler.current
@@ -118,46 +108,22 @@ fun SettingsLayout(
) {
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)
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)
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)
}
}
}, disabled = 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)
} else {
SettingsActionItem(painterResource(MR.images.ic_desktop), stringResource(MR.strings.settings_section_title_use_from_desktop), showCustomModal { it, close -> ConnectDesktopView(close) }, disabled = stopped)
}
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)
}
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)
SettingsActionItem(painterResource(MR.images.ic_wifi_tethering), stringResource(MR.strings.network_and_servers), showSettingsModal { NetworkAndServersView() }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_videocam), stringResource(MR.strings.settings_audio_video_calls), showSettingsModal { CallSettingsView(it, showModal) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_lock), stringResource(MR.strings.privacy_and_security), showSettingsModal { PrivacySettingsView(it, showSettingsModal, setPerformLA) }, disabled = stopped)
SettingsActionItem(painterResource(MR.images.ic_light_mode), stringResource(MR.strings.appearance_settings), showSettingsModal { AppearanceView(it) })
DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.settings_section_title_chat_database)) {
DatabaseItem(encrypted, passphraseSaved, showSettingsModal { DatabaseView(it, showSettingsModal) }, stopped)
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)
}
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)
SettingsActionItem(painterResource(MR.images.ic_add), stringResource(MR.strings.whats_new), showCustomModal { _, close -> WhatsNewView(viaSettings = true, close) }, disabled = stopped)
@@ -535,7 +501,6 @@ fun PreviewSettingsLayout() {
showCustomModal = { {} },
showVersion = {},
withAuth = { _, _, _ -> },
drawerState = DrawerState(DrawerValue.Closed),
)
}
}
@@ -174,7 +174,7 @@ private fun UserAddressLayout(
saveAas: (AutoAcceptState, MutableState<AutoAcceptState>) -> Unit,
) {
ColumnWithScrollBar {
AppBarTitle(stringResource(MR.strings.simplex_address), hostDevice(user?.remoteHostId))
AppBarTitle(stringResource(MR.strings.public_address), hostDevice(user?.remoteHostId))
Column(
Modifier.fillMaxWidth().padding(bottom = DEFAULT_PADDING_HALF),
horizontalAlignment = Alignment.CenterHorizontally,
@@ -230,7 +230,7 @@ private fun UserAddressLayout(
private fun CreateAddressButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_qr_code),
stringResource(MR.strings.create_simplex_address),
stringResource(MR.strings.create_public_address),
onClick,
iconColor = MaterialTheme.colors.primary,
textColor = MaterialTheme.colors.primary,
@@ -34,6 +34,7 @@ fun UserProfileView(chatModel: ChatModel, close: () -> Unit) {
KeyChangeEffect(u.value?.remoteHostId, u.value?.userId) {
close()
}
if (user != null) {
var profile by remember { mutableStateOf(user.profile.toProfile()) }
UserProfileLayout(
@@ -36,11 +36,10 @@ import dev.icerock.moko.resources.StringResource
import kotlinx.coroutines.*
@Composable
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>, drawerState: DrawerState) {
fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden: MutableState<Boolean>) {
val searchTextOrPassword = rememberSaveable { search }
val users by remember { derivedStateOf { m.users.map { it.user } } }
val filteredUsers by remember { derivedStateOf { filteredUsers(m, searchTextOrPassword.value) } }
val scope = rememberCoroutineScope()
UserProfilesLayout(
users = users,
filteredUsers = filteredUsers,
@@ -51,12 +50,6 @@ fun UserProfilesView(m: ChatModel, search: MutableState<String>, profileHidden:
addUser = {
ModalManager.center.showModalCloseable { close ->
CreateProfile(m, close)
if (appPlatform.isDesktop) {
// Hide settings to allow clicks to pass through to CreateProfile view
DisposableEffectOnGone(always = { scope.launch { drawerState.close() } }) {
// Show settings again to allow intercept clicks to close modals after profile creation finishes
scope.launch(NonCancellable) { drawerState.open() } }
}
}
},
activateUser = { user ->
@@ -701,6 +701,10 @@
<string name="is_verified">%s is verified</string>
<string name="is_not_verified">%s is not verified</string>
<!-- user picker - UserPicker.kt -->
<string name="create_public_contact_address">Create public address</string>
<string name="your_public_contact_address">Your public address</string>
<!-- settings - SettingsView.kt -->
<string name="your_settings">Your settings</string>
<string name="your_simplex_contact_address">Your SimpleX address</string>
@@ -849,6 +853,8 @@
<string name="shutdown_alert_desc">Notifications will stop working until you re-launch the app</string>
<!-- Address Items - UserAddressView.kt -->
<string name="public_address">Public address</string>
<string name="create_public_address">Create public address</string>
<string name="create_address">Create address</string>
<string name="delete_address__question">Delete address?</string>
<string name="your_contacts_will_remain_connected">Your contacts will remain connected.</string>
@@ -1150,6 +1156,7 @@
<!-- Settings sections -->
<string name="settings_section_title_you">YOU</string>
<string name="settings_section_title_settings">SETTINGS</string>
<string name="settings_section_title_chat_database">CHAT DATABASE</string>
<string name="settings_section_title_help">HELP</string>
<string name="settings_section_title_support">SUPPORT SIMPLEX CHAT</string>
<string name="settings_section_title_app">APP</string>
@@ -1714,6 +1721,7 @@
<string name="theme_remove_image">Remove image</string>
<string name="appearance_font_size">Font size</string>
<string name="appearance_zoom">Zoom</string>
<string name="system_mode_toast">System mode</string>
<!-- Wallpapers -->
<string name="wallpaper_preview_hello_alice">Good afternoon!</string>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M497-481q28-30.5 43.25-70.5T555.5-634q0-42.5-15-82.5T497-787q57.5 8 95.5 51.75t38 101.25q0 57.5-38 101.25T497-481Zm199 308q10-17 15.25-36t5.25-39.09v-38.27q0-34.45-15-65.54-15-31.1-40-55.1 48.23 17.4 89.36 44.95Q792-334.5 792-286.5v38.5q0 30.94-22.03 52.97Q747.94-173 717-173h-21Zm96-348.5h-41.5q-15.5 0-26.5-11T713-559q0-15.5 11-26.5t26.5-11H792V-638q0-15.5 11-26.5t26.5-11q15.5 0 26.5 11t11 26.5v41.5h41.5q15.5 0 26.5 11t11 26.5q0 15.5-11 26.5t-26.5 11H867v41.5q0 15.5-11 26.5t-26.5 11q-15.5 0-26.5-11T792-480v-41.5ZM325.5-479q-64.5 0-109.75-45.25T170.5-634q0-64.5 45.25-109.75T325.5-789q64.5 0 109.75 45.25T480.5-634q0 64.5-45.25 109.75T325.5-479Zm-311 231v-31.03q0-32.97 16.75-60.22t45.27-41.76Q137.5-411 199.75-426.25 262-441.5 325.5-441.5t125.75 15.25Q513.5-411 574.48-381.01q28.52 14.51 45.27 41.76Q636.5-312 636.5-279.03V-248q0 30.94-22.03 52.97Q592.44-173 561.5-173h-472q-30.94 0-52.97-22.03Q14.5-217.06 14.5-248Zm311-306q33 0 56.5-23.5t23.5-56.5q0-33-23.5-56.5T325.5-714q-33 0-56.5 23.5T245.5-634q0 33 23.5 56.5t56.5 23.5Zm-236 306h472v-31q0-11.19-5.5-20.34-5.5-9.16-15-14.16-53.5-26.5-107.17-39.75-53.68-13.25-108.33-13.25-55 0-108.5 13.25T110-313.5q-9.5 5-15 14.16-5.5 9.15-5.5 20.34v31Zm236-386Zm0 386Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="22" viewBox="0 -960 960 960" width="22"><path d="M483.82-81.5q-83.12 0-156.43-31.82-73.32-31.81-127.85-86.25Q145-254 113.25-327.25 81.5-400.5 81.5-484q0-131.24 77-236.62t202.35-146.46q16.05-4.92 29.85 4.83t11.8 25.75q-6.5 88.5 24 170.4t94 145.35Q583.5-458 665.75-427t170.75 24q15-1.5 24.75 12.25t5.25 29.25q-40 125.5-145.35 202.75Q615.81-81.5 483.82-81.5ZM484-139q100.95 0 183.22-57.5Q749.5-254 800-343q-90.29-7.95-173.55-41.22Q543.2-417.5 479.5-481q-63.96-62.86-96.73-145.68Q350-709.5 342.5-798.5q-89 48.5-146.25 131.03Q139-584.95 139-484q0 144.38 100.31 244.69T484-139Zm-5-342Z"/></svg>

After

Width:  |  Height:  |  Size: 636 B

@@ -0,0 +1,86 @@
package chat.simplex.common.views.chatlist
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.User
import chat.simplex.common.model.UserInfo
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.flow.MutableStateFlow
@Composable
actual fun UserPickerInactiveUsersSection(
users: List<UserInfo>,
stopped: Boolean,
onShowAllProfilesClicked: () -> Unit,
onUserClicked: (user: User) -> Unit,
) {
if (users.isNotEmpty()) {
val userRows = users.chunked(5)
val rowsToDisplay = if (userRows.size > 2) 2 else userRows.size
val horizontalPadding = DEFAULT_PADDING_HALF + 8.dp
Column(Modifier
.padding(horizontal = horizontalPadding, vertical = DEFAULT_PADDING_HALF)
.height(55.dp * rowsToDisplay + (if (rowsToDisplay > 1) DEFAULT_PADDING else 0.dp))
) {
ColumnWithScrollBar(
verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING)
) {
val spaceBetween = (((DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) - (horizontalPadding)) - (55.dp * 5)) / 5
userRows.forEach { row ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spaceBetween),
) {
row.forEach { u ->
UserPickerInactiveUserBadge(u, stopped, size = 55.dp) {
onUserClicked(u.user)
}
}
}
}
}
}
}
UserPickerOptionRow(
painterResource(MR.images.ic_manage_accounts),
stringResource(MR.strings.your_chat_profiles),
onShowAllProfilesClicked
)
}
@Composable
actual fun PlatformUserPicker(modifier: Modifier, pickerState: MutableStateFlow<AnimatedViewState>, content: @Composable () -> Unit) {
AnimatedVisibility(
visible = pickerState.value.isVisible(),
enter = fadeIn(),
exit = fadeOut()
) {
Box(
Modifier
.fillMaxSize()
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = { pickerState.value = AnimatedViewState.HIDING }),
contentAlignment = Alignment.TopStart
) {
ColumnWithScrollBar(modifier) {
content()
}
}
}
}