Refactor: Improve navigation and UI for channel and login screens

This commit introduces several improvements to the navigation and user interface, primarily focused on the channel screen and login/registration flows.

**Channel Screen & Navigation:**

- Implement swipe-to-go-back gesture on the ChannelScreen.
- Persist the ChannelSideDrawer as a background layer when a channel is open. This allows for a smoother transition when returning to the channel list.
- Allow swiping from the left edge of the screen (when not in a channel view) to reopen the last viewed channel.
- Ensure the correct channel is highlighted in the ChannelSideDrawer when a channel is active.
- The back button on the ChannelScreen now navigates to the previous non-channel screen or the server's channel list, providing a more intuitive backstack behavior.

**Login/Registration:**

- Disable HTTP request retries for login attempts to prevent unexpected behavior with MFA or incorrect credentials.
- Add loading indicators to the Login and RegisterDetails screens to provide visual feedback during network operations.
- Display error messages more prominently on the Login and RegisterDetails screens.
- Adjust the soft input mode on the LoginScreen to `SOFT_INPUT_ADJUST_PAN` to prevent UI elements from being obscured by the keyboard.
- Fix a bug where the default notification type was incorrectly mapped.

**Other Changes:**

- Update the monochrome notification icon.
- Modify the primary color in `colors.xml`.
- Minor cleanups and toast message improvements in ChannelScreen camera handling.
This commit is contained in:
AbronStudio 2025-09-06 17:01:12 +03:30
parent 5596d56525
commit da28d50b18
10 changed files with 468 additions and 233 deletions

View File

@ -153,8 +153,17 @@ val PeptideHttp by lazy {
install(WebSockets)
install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 5)
retryOnException(maxRetries = 5)
retryIf(maxRetries = 5) { _, response ->
// Do not retry for explicit no-retry requests (like login)
val noRetry = response.call.request.headers["x-no-retry"] == "true"
if (noRetry) return@retryIf false
// Retry only on server errors (5xx)
response.status.value in 500..599
}
retryOnExceptionIf(maxRetries = 5) { request, cause ->
// Allow network retries unless explicitly opted-out
request.headers["x-no-retry"] != "true"
}
modifyRequest { request ->
request.headers.append("x-retry-count", retryCount.toString())

View File

@ -112,6 +112,7 @@ suspend fun negotiateAuthentication(email: String, password: String): EmailPassw
val response: HttpResponse = PeptideHttp.post("/auth/session/login".api()) {
contentType(ContentType.Application.Json)
headers.append("x-no-retry", "true")
setBody(LoginNegotiation(email, password, sessionName, null))
}

View File

@ -1,7 +1,7 @@
package chat.peptide.api.settings
enum class NotificationType(val storageValue: String) {
DEFAULT("default"),
DEFAULT("all"),
MUTED("muted"),
ALL("all"),
MENTIONS_ONLY("mentions"),
@ -11,7 +11,6 @@ enum class NotificationType(val storageValue: String) {
fun fromStorage(value: String?): NotificationType {
return when (value) {
null -> DEFAULT
"default" -> DEFAULT
"muted" -> MUTED
"all" -> ALL
"mentions" -> MENTIONS_ONLY

View File

@ -106,6 +106,7 @@ import chat.peptide.sheets.UserInfoSheet
fun ChannelSideDrawer(
currentServer: String?,
currentDestination: ChatRouterDestination,
selectedChannelId: String?,
onDestinationChanged: (ChatRouterDestination) -> Unit,
navigateToServer: (String) -> Unit,
onShowServerContextSheet: (String) -> Unit,
@ -121,6 +122,11 @@ fun ChannelSideDrawer(
}
val channelListState = rememberLazyListState()
val effectiveDestination = when (currentDestination) {
is ChatRouterDestination.Channel -> currentDestination
else -> selectedChannelId?.let { ChatRouterDestination.Channel(it) } ?: currentDestination
}
LaunchedEffect(currentDestination) {
if ((currentDestination is ChatRouterDestination.ServersChannels) && currentServer != null) {
val channelIndex = categorisedChannels?.indexOfFirst {
@ -590,7 +596,7 @@ fun ChannelSideDrawer(
} else {
if (currentServer == null) {
DirectMessagesChannelListRenderer(
currentDestination,
effectiveDestination,
onDestinationChanged,
channelListState,
onOpenUserInfoSheet = { userId -> userContextSheetTarget = userId }
@ -598,7 +604,7 @@ fun ChannelSideDrawer(
} else {
ServerChannelListRenderer(
categorisedChannels,
currentDestination,
effectiveDestination,
onDestinationChanged,
channelListState,
onOpenChannelContextSheet = { channelContextSheetTarget = it },

View File

@ -10,10 +10,12 @@ import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only
@ -23,6 +25,8 @@ import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.DismissibleDrawerSheet
import androidx.compose.material3.DismissibleNavigationDrawer
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@ -50,6 +54,8 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
@ -156,6 +162,8 @@ class ChatRouterViewModel @Inject constructor(
@ApplicationContext val context: Context
) : ViewModel() {
var currentDestination by mutableStateOf<ChatRouterDestination>(ChatRouterDestination.default)
var previousDestination by mutableStateOf<ChatRouterDestination?>(null)
var lastNonChannelDestination by mutableStateOf<ChatRouterDestination?>(null)
var latestChangelogRead by mutableStateOf(true)
var latestChangelog by mutableStateOf("")
var latestChangelogBody by mutableStateOf("")
@ -180,6 +188,8 @@ class ChatRouterViewModel @Inject constructor(
setSaveDestination(ChatRouterDestination.fromString(current ?: ""))
}
setRegisterForNotifications()
latestChangelogRead = changelogs.hasSeenCurrent()
latestChangelog = changelogs.getLatestChangelogCode()
latestChangelogBody =
@ -211,7 +221,13 @@ class ChatRouterViewModel @Inject constructor(
}
fun setSaveDestination(destination: ChatRouterDestination) {
currentDestination = destination
if (destination != currentDestination) {
previousDestination = currentDestination
currentDestination = destination
if (destination !is ChatRouterDestination.Channel) {
lastNonChannelDestination = destination
}
}
}
fun setRegisterForNotifications() {
@ -913,169 +929,215 @@ fun ChannelNavigator(
isTouchExplorationEnabled: Boolean,
setDrawerGestureEnabled: (Boolean) -> Unit = {},
) {
when (dest) {
is ChatRouterDestination.Channel -> {
ChannelScreen(
channelId = dest.channelId,
messageId = dest.messageId,
backToChannelsScreen = {
currentServer?.let {
viewModel.setSaveDestination(
ChatRouterDestination.ServersChannels(
serverID = currentServer
)
)
} ?: viewModel.setSaveDestination(
ChatRouterDestination.Home
)
},
setDrawerGestureEnabled = setDrawerGestureEnabled,
)
}
// Keep track of the last opened channel to allow edge-swipe reopen
var lastChannel by remember { mutableStateOf<ChatRouterDestination.Channel?>(null) }
if (dest is ChatRouterDestination.Channel) {
lastChannel = dest
}
else -> {
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
BottomAppBar {
NavigationBarItem(
icon = {
Icon(
painter = painterResource(R.drawable.ic_bottom_navbar_home),
contentDescription = "Home",
modifier = Modifier.size(32.dp)
)
},
label = {
Text(text = "you")
},
selected = when (dest) {
is ChatRouterDestination.ServersChannels,
is ChatRouterDestination.NoCurrentChannel,
ChatRouterDestination.Home,
ChatRouterDestination.Discover -> true
var containerWidth by remember { mutableStateOf(0) }
var isReopenDragging by remember { mutableStateOf(false) }
var reopenDragDelta by remember { mutableStateOf(0f) }
else -> false
},
colors = NavigationBarItemDefaults.colors(
indicatorColor = Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.onBackground,
selectedTextColor = MaterialTheme.colorScheme.onBackground,
unselectedTextColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
unselectedIconColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
),
enabled = true,
onClick = {
viewModel.setSaveDestination(ChatRouterDestination.Home)
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(dest, lastChannel) {
if (dest !is ChatRouterDestination.Channel && lastChannel != null) {
detectHorizontalDragGestures(
onDragStart = { _ ->
// Allow reopen swipe to start anywhere on the screen
isReopenDragging = true
reopenDragDelta = 0f
},
onDragEnd = {
if (isReopenDragging) {
// If dragged sufficiently left, reopen the last channel
if (reopenDragDelta < -containerWidth / 6f) {
lastChannel?.let { viewModel.setSaveDestination(it) }
}
}
)
NavigationBarItem(
icon = {
Icon(
painter = painterResource(R.drawable.ic_bottom_navbar_friends),
contentDescription = "Friends",
modifier = Modifier.size(32.dp)
)
},
label = {
Text(text = "Friends")
},
selected = dest is ChatRouterDestination.Friends,
enabled = true,
colors = NavigationBarItemDefaults.colors(
indicatorColor = Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.onBackground,
selectedTextColor = MaterialTheme.colorScheme.onBackground,
unselectedTextColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
unselectedIconColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
),
onClick = {
viewModel.setSaveDestination(ChatRouterDestination.Friends)
}
)
NavigationBarItem(
icon = {
Icon(
painter = painterResource(R.drawable.ic_bottom_navbar_you),
contentDescription = "You",
modifier = Modifier.size(32.dp)
)
},
label = {
Text(text = "you")
},
selected = dest is ChatRouterDestination.Settings,
colors = NavigationBarItemDefaults.colors(
indicatorColor = Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.onBackground,
selectedTextColor = MaterialTheme.colorScheme.onBackground,
unselectedTextColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
unselectedIconColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
),
enabled = true,
onClick = {
viewModel.setSaveDestination(ChatRouterDestination.Settings)
}
)
}
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
when (dest) {
is ChatRouterDestination.Settings -> {
SettingsScreen(
navController = topNav,
)
isReopenDragging = false
reopenDragDelta = 0f
},
onDragCancel = {
isReopenDragging = false
reopenDragDelta = 0f
}
is ChatRouterDestination.Friends -> {
FriendsScreen(
topNav = topNav,
onDrawerClicked = toggleDrawer,
)
}
is ChatRouterDestination.Home,
is ChatRouterDestination.ServersChannels,
is ChatRouterDestination.Discover -> {
ChannelSideDrawer(
onDestinationChanged = viewModel::setSaveDestination,
currentDestination = viewModel.currentDestination,
currentServer = when (dest) {
is ChatRouterDestination.Home -> currentServer
is ChatRouterDestination.ServersChannels -> dest.serverID
else -> null
},
navigateToServer = viewModel::navigateToServer,
onShowServerContextSheet = onShowServerContextSheet,
showSettingsIcon = isTouchExplorationEnabled,
onOpenSettings = {
topNav.navigate("settings")
},
onShowAddServerSheet = onShowAddServerSheet
)
}
is ChatRouterDestination.Channel -> {}
is ChatRouterDestination.NoCurrentChannel -> {
NoCurrentChannelScreen(onDrawerClicked = toggleDrawer)
) { change, dragAmount ->
if (isReopenDragging) {
reopenDragDelta += dragAmount
}
}
}
}
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
containerWidth = placeable.width
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
}
) {
// Background scaffold is always rendered to act as the underlying layer
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
BottomAppBar {
NavigationBarItem(
icon = {
Icon(
painter = painterResource(R.drawable.ic_bottom_navbar_home),
contentDescription = "Home",
modifier = Modifier.size(32.dp)
)
},
label = {
Text(text = "you")
},
selected = when (dest) {
is ChatRouterDestination.ServersChannels,
is ChatRouterDestination.NoCurrentChannel,
ChatRouterDestination.Home,
ChatRouterDestination.Discover -> true
else -> false
},
colors = NavigationBarItemDefaults.colors(
indicatorColor = Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.onBackground,
selectedTextColor = MaterialTheme.colorScheme.onBackground,
unselectedTextColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
unselectedIconColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
),
enabled = true,
onClick = {
viewModel.setSaveDestination(ChatRouterDestination.Home)
}
)
NavigationBarItem(
icon = {
Icon(
painter = painterResource(R.drawable.ic_bottom_navbar_friends),
contentDescription = "Friends",
modifier = Modifier.size(32.dp)
)
},
label = {
Text(text = "Friends")
},
selected = dest is ChatRouterDestination.Friends,
enabled = true,
colors = NavigationBarItemDefaults.colors(
indicatorColor = Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.onBackground,
selectedTextColor = MaterialTheme.colorScheme.onBackground,
unselectedTextColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
unselectedIconColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
),
onClick = {
viewModel.setSaveDestination(ChatRouterDestination.Friends)
}
)
NavigationBarItem(
icon = {
Icon(
painter = painterResource(R.drawable.ic_bottom_navbar_you),
contentDescription = "You",
modifier = Modifier.size(32.dp)
)
},
label = {
Text(text = "you")
},
selected = dest is ChatRouterDestination.Settings,
colors = NavigationBarItemDefaults.colors(
indicatorColor = Color.Transparent,
selectedIconColor = MaterialTheme.colorScheme.onBackground,
selectedTextColor = MaterialTheme.colorScheme.onBackground,
unselectedTextColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
unselectedIconColor = MaterialTheme.colorScheme.onBackground.copy(
alpha = 0.4f
),
),
enabled = true,
onClick = {
viewModel.setSaveDestination(ChatRouterDestination.Settings)
}
)
}
}
) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
when (dest) {
is ChatRouterDestination.Settings -> {
SettingsScreen(
navController = topNav,
)
}
is ChatRouterDestination.Friends -> {
FriendsScreen(
topNav = topNav,
onDrawerClicked = toggleDrawer,
)
}
is ChatRouterDestination.Home,
is ChatRouterDestination.ServersChannels,
is ChatRouterDestination.Discover,
is ChatRouterDestination.Channel -> { // When Channel is active, show drawer as background
ChannelSideDrawer(
onDestinationChanged = viewModel::setSaveDestination,
currentDestination = viewModel.currentDestination,
currentServer = when (dest) {
is ChatRouterDestination.Home -> currentServer
is ChatRouterDestination.ServersChannels -> dest.serverID
is ChatRouterDestination.Channel -> currentServer
else -> null
},
selectedChannelId = (if (dest is ChatRouterDestination.Channel) dest.channelId else null),
navigateToServer = viewModel::navigateToServer,
onShowServerContextSheet = onShowServerContextSheet,
showSettingsIcon = isTouchExplorationEnabled,
onOpenSettings = {
topNav.navigate("settings")
},
onShowAddServerSheet = onShowAddServerSheet
)
}
is ChatRouterDestination.NoCurrentChannel -> {
NoCurrentChannelScreen(onDrawerClicked = toggleDrawer)
}
}
}
}
// Foreground overlay: Channel screen when destination is a Channel
if (dest is ChatRouterDestination.Channel) {
ChannelScreen(
channelId = dest.channelId,
messageId = dest.messageId,
backToChannelsScreen = {
// Prefer going back to the current server's channels screen explicitly
val target = currentServer?.let { ChatRouterDestination.ServersChannels(serverID = it) }
?: viewModel.lastNonChannelDestination
?: viewModel.previousDestination
?: ChatRouterDestination.Home
viewModel.setSaveDestination(target)
},
setDrawerGestureEnabled = setDrawerGestureEnabled,
)
}
}
}

View File

@ -25,6 +25,7 @@ import androidx.camera.view.PreviewView
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState
@ -36,6 +37,7 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -54,10 +56,12 @@ import androidx.compose.foundation.layout.imeAnimationTarget
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
@ -80,7 +84,6 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
@ -89,6 +92,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@ -99,6 +103,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@ -114,8 +120,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel
@ -170,6 +178,7 @@ import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import java.io.File
import kotlin.math.max
import kotlin.math.roundToInt
import androidx.camera.core.Preview as CameraPreview
sealed class ChannelScreenItem {
@ -383,13 +392,18 @@ fun ChannelScreen(
}
override fun onError(exc: ImageCaptureException) {
Toast.makeText(context, "Photo capture failed: ${exc.message}", Toast.LENGTH_SHORT).show()
Toast.makeText(
context,
"Photo capture failed: ${exc.message}",
Toast.LENGTH_SHORT
).show()
Log.e("ChannelScreen", "Photo capture failed: ", exc)
}
})
} catch (e: Exception) {
Log.e("ChannelScreen", "Error opening camera: ", e)
Toast.makeText(context, "Error opening camera: ${e.message}", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "Error opening camera: ${e.message}", Toast.LENGTH_SHORT)
.show()
}
}
}
@ -403,7 +417,10 @@ fun ChannelScreen(
}
}
val openCameraCallback = {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
val granted = ContextCompat.checkSelfPermission(
context,
Manifest.permission.CAMERA
) == PackageManager.PERMISSION_GRANTED
if (granted) {
showCameraSheet = true
} else {
@ -652,7 +669,53 @@ fun ChannelScreen(
}
// </editor-fold>
// <editor-fold desc="Begin UI composition">
// Swipe-back state
val offsetX = remember { Animatable(0f) }
var componentWidth by remember { mutableIntStateOf(0) }
var isDragging by remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier
.fillMaxSize()
.zIndex(1f)
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.pointerInput(Unit) {
detectHorizontalDragGestures(
onDragStart = { _ ->
// Allow swipe-back to start anywhere on the screen
isDragging = true
},
onDragEnd = {
if (isDragging) {
scope.launch {
if (offsetX.value > componentWidth * 0.25f) {
backToChannelsScreen?.invoke()
} else {
offsetX.animateTo(0f, animationSpec = tween(durationMillis = 300))
}
}
}
isDragging = false
},
onDragCancel = { isDragging = false }
) { change, dragAmount ->
if (isDragging) {
change.consume()
scope.launch {
offsetX.snapTo(
(offsetX.value + dragAmount).coerceIn(0f, componentWidth.toFloat())
)
}
}
}
}
.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
componentWidth = placeable.width
layout(placeable.width, placeable.height) {
placeable.place(0, 0)
}
},
contentWindowInsets = WindowInsets.zero,
topBar = {
Column {
@ -674,6 +737,16 @@ fun ChannelScreen(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
IconButton(
onClick = {
backToChannelsScreen?.invoke()
}
) {
Image(
painter = painterResource(R.drawable.icn_arrow_back_24dp),
contentDescription = "back",
)
}
viewModel.channel?.let {
when (it.channelType) {
ChannelType.DirectMessage -> {
@ -1139,7 +1212,8 @@ fun ChannelScreen(
// Full-screen camera preview sheet
if (showCameraSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val sheetState =
rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
sheetState = sheetState,
onDismissRequest = { showCameraSheet = false }
@ -1153,9 +1227,10 @@ fun ChannelScreen(
// Bind use cases when view is ready
val cameraProvider = cameraProviderFuture.get()
cameraProvider.unbindAll()
val preview = CameraPreview.Builder().build().also { pr ->
pr.surfaceProvider = pv.surfaceProvider
}
val preview =
CameraPreview.Builder().build().also { pr ->
pr.surfaceProvider = pv.surfaceProvider
}
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
@ -1454,7 +1529,7 @@ fun ChannelScreen(
ChannelScreenActivePane.None
}
MediaPickerGateway(
MediaPickerGateway(
onOpenPhotoPicker = {
openPhotoPickerCallback()
viewModel.activePane =

View File

@ -1,6 +1,8 @@
package chat.peptide.screens.login
import android.util.Log
import android.app.Activity
import android.view.WindowManager
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -13,6 +15,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.input.TextObfuscationMode
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api
@ -27,6 +31,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@ -34,6 +39,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
@ -68,6 +74,8 @@ import javax.inject.Inject
class LoginViewModel @Inject constructor(
private val kvStorage: KVStorage
) : ViewModel() {
var isLoading by mutableStateOf(false)
private set
private var _email by mutableStateOf("")
val email: String
get() = _email
@ -90,51 +98,59 @@ class LoginViewModel @Inject constructor(
fun doLogin() {
_error = null
isLoading = true
viewModelScope.launch {
val response = try {
negotiateAuthentication(_email, _password)
} catch (e: Exception) {
_error = if (e.message?.startsWith("Unexpected JSON token") == true) {
PeptideApplication.instance.getString(R.string.service_health_alert_body_default)
} else e.message ?: "Unknown error"
return@launch
}
if (response.error != null) {
_error = response.error.type
} else {
try {
val response = negotiateAuthentication(_email, _password)
if (response.error != null) {
_error = response.error.type
isLoading = false
return@launch
}
Log.d("Login", "Checking for MFA")
if (response.proceedMfa) {
Log.d("Login", "MFA required. Navigating to MFA screen")
_mfaResponse = response
_navigateTo = "mfa"
} else {
Log.d(
"Login",
"No MFA required. Login is complete! We should have a session token"
)
try {
val token = response.firstUserHints!!.token
val id = response.firstUserHints.id
kvStorage.set("sessionToken", token)
kvStorage.set("sessionId", id)
val onboard = needsOnboarding(token)
if (onboard) {
_navigateTo = "onboarding"
return@launch
}
PeptideAPI.loginAs(token)
PeptideAPI.setSessionId(response.firstUserHints.token)
_navigateTo = "home"
} catch (e: Error) {
_error = e.message ?: "Unknown error"
}
isLoading = false
return@launch
}
Log.d(
"Login",
"No MFA required. Login is complete! We should have a session token"
)
try {
val token = response.firstUserHints!!.token
val id = response.firstUserHints.id
kvStorage.set("sessionToken", token)
kvStorage.set("sessionId", id)
val onboard = needsOnboarding(token)
if (onboard) {
_navigateTo = "onboarding"
isLoading = false
return@launch
}
PeptideAPI.loginAs(token)
PeptideAPI.setSessionId(response.firstUserHints.token)
_navigateTo = "home"
} catch (e: Exception) {
_error = e.message ?: "Unknown error"
}
} catch (e: Exception) {
_error = if (e.message?.startsWith("Unexpected JSON token") == true) {
PeptideApplication.instance.getString(R.string.service_health_alert_body_default)
} else e.message ?: "Unknown error"
} finally {
isLoading = false
}
}
}
@ -155,6 +171,13 @@ class LoginViewModel @Inject constructor(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltViewModel()) {
val context = LocalContext.current
DisposableEffect(Unit) {
val window = (context as Activity).window
val previousMode = window.attributes.softInputMode
window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
onDispose { window.setSoftInputMode(previousMode) }
}
val passwordTextFieldState = rememberTextFieldState()
LaunchedEffect(passwordTextFieldState.text) {
viewModel.setPassword(passwordTextFieldState.text.toString())
@ -230,6 +253,7 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltVi
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 20.dp, horizontal = 16.dp)
.imePadding(),
verticalArrangement = Arrangement.Top,
@ -334,6 +358,16 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltVi
url = "${PeptideAPI.getCurrentAppUrl()}/login/reset",
modifier = Modifier.padding(vertical = 12.dp)
)
if (viewModel.error != null) {
Text(
text = viewModel.error ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
)
}
Spacer(modifier = Modifier.height(32.dp))
SquareButton(
@ -343,8 +377,17 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltVi
modifier = Modifier
.fillMaxWidth()
.testTag("confirm_platform_button"),
enabled = !viewModel.isLoading && viewModel.email.isNotBlank() && viewModel.password.isNotBlank()
) {
Text(text = stringResource(R.string.login))
if (viewModel.isLoading) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(text = stringResource(R.string.login))
}
}
}
}

View File

@ -74,20 +74,24 @@ class RegisterDetailsScreenViewModel : ViewModel() {
var password by mutableStateOf("")
var error by mutableStateOf<String?>(null)
private var captchaToken by mutableStateOf<String?>(null)
var isLoading by mutableStateOf(false)
fun initCaptcha(context: Context, onSuccess: () -> Unit) {
viewModelScope.launch {
isLoading = true
val root = try {
getRootRoute()
} catch (e: Exception) {
error = if (e.message?.startsWith("Expected response body of the type") == true) {
PeptideApplication.instance.getString(R.string.service_health_alert_body_default)
} else e.message
isLoading = false
return@launch
}
if (!root.features.captcha.enabled) {
onSuccess()
isLoading = false
return@launch
}
@ -97,18 +101,25 @@ class RegisterDetailsScreenViewModel : ViewModel() {
size(HCaptchaSize.INVISIBLE)
}.build()
HCaptcha.getClient(context).apply {
addOnSuccessListener {
captchaToken = it.tokenResult
onSuccess()
}
try {
HCaptcha.getClient(context).apply {
addOnSuccessListener {
captchaToken = it.tokenResult
onSuccess()
isLoading = false
}
addOnFailureListener {
error = it.message
}
addOnFailureListener {
error = it.message
isLoading = false
}
setup(config)
verifyWithHCaptcha()
setup(config)
verifyWithHCaptcha()
}
} catch (e: Exception) {
error = e.message
isLoading = false
}
}
}
@ -121,12 +132,18 @@ class RegisterDetailsScreenViewModel : ViewModel() {
)
viewModelScope.launch {
val result = register(body)
if (result.ok) {
navController.navigate("register/verify/$email")
} else {
error = result.unwrapError().type
isLoading = true
try {
val result = register(body)
if (result.ok) {
navController.navigate("register/verify/$email")
} else {
error = result.unwrapError().type
}
} catch (e: Exception) {
error = e.message
} finally {
isLoading = false
}
}
}
@ -293,6 +310,17 @@ fun RegisterDetailsScreen(
)
Spacer(modifier = Modifier.height(32.dp))
if (viewModel.error != null) {
Text(
text = viewModel.error ?: "",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 12.dp)
)
}
SquareButton(
onClick = {
viewModel.initCaptcha(context) {
@ -302,8 +330,17 @@ fun RegisterDetailsScreen(
modifier = Modifier
.fillMaxWidth()
.testTag("setup_continue_button"),
enabled = !viewModel.isLoading && viewModel.email.isNotBlank() && viewModel.password.isNotBlank(),
) {
Text(text = stringResource(R.string.continue_))
if (viewModel.isLoading) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(text = stringResource(R.string.continue_))
}
}
}
}

View File

@ -1,9 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp"
android:height="200dp"
android:viewportWidth="500"
android:viewportHeight="500">
android:width="73dp"
android:height="72dp"
android:viewportWidth="73"
android:viewportHeight="72">
<path
android:pathData="M122.59,447C122.59,326.35 122.59,247.26 122.59,144.56L46,53H293.71C323.14,53 348.89,58.32 370.95,68.97C393.02,79.61 410.19,94.94 422.45,114.95C434.71,134.96 440.84,158.94 440.84,186.9C440.84,215.12 434.51,238.91 421.87,258.27C409.35,277.64 391.73,292.26 369.02,302.14C346.43,312.01 320.04,316.95 289.84,316.95H187.63V233.84H268.16C280.81,233.84 291.59,232.3 300.49,229.22C309.52,226.02 316.43,220.95 321.2,214.02C326.11,207.1 328.56,198.06 328.56,186.9C328.56,175.61 326.11,166.44 321.2,159.39C316.43,152.21 309.52,146.95 300.49,143.61C291.59,140.15 280.81,138.42 268.16,138.42H230.22V447H122.59ZM354.89,266.16L454,447H337.08L240.29,266.16H354.89Z"
android:fillColor="@color/primary"/>
android:pathData="M32.783,19.341V30.328H25.235V19.341H32.783ZM55.191,19.341V30.328H47.644V19.341H55.191Z"
android:fillColor="#662D91"/>
<path
android:pathData="M55.191,44.908H47.69V52.278H25.235V44.908H47.644V37.538H55.191V44.908ZM25.235,37.538V44.908H17.688V37.538H25.235ZM25.235,30.328H17.688V19.341H25.235V30.328ZM47.667,30.328H40.12V19.341H47.667V30.328Z"
android:fillColor="#FFDE17"/>
</vector>

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#FFFF005C</color>
<color name="primary">#FF100518</color>
<color name="background">#FF131722</color>
<color name="splashscreen_background">#FFFFFFFF</color>
<color name="foreground">#FF000000</color>