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(WebSockets)
install(HttpRequestRetry) { install(HttpRequestRetry) {
retryOnServerErrors(maxRetries = 5) retryIf(maxRetries = 5) { _, response ->
retryOnException(maxRetries = 5) // 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 -> modifyRequest { request ->
request.headers.append("x-retry-count", retryCount.toString()) 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()) { val response: HttpResponse = PeptideHttp.post("/auth/session/login".api()) {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
headers.append("x-no-retry", "true")
setBody(LoginNegotiation(email, password, sessionName, null)) setBody(LoginNegotiation(email, password, sessionName, null))
} }

View File

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

View File

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

View File

@ -10,10 +10,12 @@ import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.only 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.foundation.layout.windowInsetsPadding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.BottomAppBar import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.DismissibleDrawerSheet
import androidx.compose.material3.DismissibleNavigationDrawer
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@ -50,6 +54,8 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.structuralEqualityPolicy import androidx.compose.runtime.structuralEqualityPolicy
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
@ -156,6 +162,8 @@ class ChatRouterViewModel @Inject constructor(
@ApplicationContext val context: Context @ApplicationContext val context: Context
) : ViewModel() { ) : ViewModel() {
var currentDestination by mutableStateOf<ChatRouterDestination>(ChatRouterDestination.default) 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 latestChangelogRead by mutableStateOf(true)
var latestChangelog by mutableStateOf("") var latestChangelog by mutableStateOf("")
var latestChangelogBody by mutableStateOf("") var latestChangelogBody by mutableStateOf("")
@ -180,6 +188,8 @@ class ChatRouterViewModel @Inject constructor(
setSaveDestination(ChatRouterDestination.fromString(current ?: "")) setSaveDestination(ChatRouterDestination.fromString(current ?: ""))
} }
setRegisterForNotifications()
latestChangelogRead = changelogs.hasSeenCurrent() latestChangelogRead = changelogs.hasSeenCurrent()
latestChangelog = changelogs.getLatestChangelogCode() latestChangelog = changelogs.getLatestChangelogCode()
latestChangelogBody = latestChangelogBody =
@ -211,7 +221,13 @@ class ChatRouterViewModel @Inject constructor(
} }
fun setSaveDestination(destination: ChatRouterDestination) { fun setSaveDestination(destination: ChatRouterDestination) {
currentDestination = destination if (destination != currentDestination) {
previousDestination = currentDestination
currentDestination = destination
if (destination !is ChatRouterDestination.Channel) {
lastNonChannelDestination = destination
}
}
} }
fun setRegisterForNotifications() { fun setRegisterForNotifications() {
@ -913,169 +929,215 @@ fun ChannelNavigator(
isTouchExplorationEnabled: Boolean, isTouchExplorationEnabled: Boolean,
setDrawerGestureEnabled: (Boolean) -> Unit = {}, setDrawerGestureEnabled: (Boolean) -> Unit = {},
) { ) {
when (dest) { // Keep track of the last opened channel to allow edge-swipe reopen
is ChatRouterDestination.Channel -> { var lastChannel by remember { mutableStateOf<ChatRouterDestination.Channel?>(null) }
ChannelScreen( if (dest is ChatRouterDestination.Channel) {
channelId = dest.channelId, lastChannel = dest
messageId = dest.messageId, }
backToChannelsScreen = {
currentServer?.let {
viewModel.setSaveDestination(
ChatRouterDestination.ServersChannels(
serverID = currentServer
)
)
} ?: viewModel.setSaveDestination(
ChatRouterDestination.Home
)
},
setDrawerGestureEnabled = setDrawerGestureEnabled,
)
}
else -> { var containerWidth by remember { mutableStateOf(0) }
Scaffold( var isReopenDragging by remember { mutableStateOf(false) }
modifier = Modifier.fillMaxSize(), var reopenDragDelta by remember { mutableStateOf(0f) }
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 Box(
}, modifier = Modifier
colors = NavigationBarItemDefaults.colors( .fillMaxSize()
indicatorColor = Color.Transparent, .pointerInput(dest, lastChannel) {
selectedIconColor = MaterialTheme.colorScheme.onBackground, if (dest !is ChatRouterDestination.Channel && lastChannel != null) {
selectedTextColor = MaterialTheme.colorScheme.onBackground, detectHorizontalDragGestures(
unselectedTextColor = MaterialTheme.colorScheme.onBackground.copy( onDragStart = { _ ->
alpha = 0.4f // Allow reopen swipe to start anywhere on the screen
), isReopenDragging = true
unselectedIconColor = MaterialTheme.colorScheme.onBackground.copy( reopenDragDelta = 0f
alpha = 0.4f },
), onDragEnd = {
), if (isReopenDragging) {
enabled = true, // If dragged sufficiently left, reopen the last channel
onClick = { if (reopenDragDelta < -containerWidth / 6f) {
viewModel.setSaveDestination(ChatRouterDestination.Home) lastChannel?.let { viewModel.setSaveDestination(it) }
}
} }
) isReopenDragging = false
NavigationBarItem( reopenDragDelta = 0f
icon = { },
Icon( onDragCancel = {
painter = painterResource(R.drawable.ic_bottom_navbar_friends), isReopenDragging = false
contentDescription = "Friends", reopenDragDelta = 0f
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,
)
} }
) { change, dragAmount ->
is ChatRouterDestination.Friends -> { if (isReopenDragging) {
FriendsScreen( reopenDragDelta += dragAmount
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)
} }
} }
} }
} }
.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.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.animateIntAsState import androidx.compose.animation.core.animateIntAsState
@ -36,6 +37,7 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.imePadding
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredHeight import androidx.compose.foundation.layout.requiredHeight
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -80,7 +84,6 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -89,6 +92,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@ -99,6 +103,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.ColorFilter 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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity 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.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.documentfile.provider.DocumentFile import androidx.documentfile.provider.DocumentFile
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -170,6 +178,7 @@ import kotlinx.coroutines.launch
import kotlinx.datetime.Instant import kotlinx.datetime.Instant
import java.io.File import java.io.File
import kotlin.math.max import kotlin.math.max
import kotlin.math.roundToInt
import androidx.camera.core.Preview as CameraPreview import androidx.camera.core.Preview as CameraPreview
sealed class ChannelScreenItem { sealed class ChannelScreenItem {
@ -383,13 +392,18 @@ fun ChannelScreen(
} }
override fun onError(exc: ImageCaptureException) { 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) Log.e("ChannelScreen", "Photo capture failed: ", exc)
} }
}) })
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChannelScreen", "Error opening camera: ", e) 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 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) { if (granted) {
showCameraSheet = true showCameraSheet = true
} else { } else {
@ -652,7 +669,53 @@ fun ChannelScreen(
} }
// </editor-fold> // </editor-fold>
// <editor-fold desc="Begin UI composition"> // <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( 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, contentWindowInsets = WindowInsets.zero,
topBar = { topBar = {
Column { Column {
@ -674,6 +737,16 @@ fun ChannelScreen(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
IconButton(
onClick = {
backToChannelsScreen?.invoke()
}
) {
Image(
painter = painterResource(R.drawable.icn_arrow_back_24dp),
contentDescription = "back",
)
}
viewModel.channel?.let { viewModel.channel?.let {
when (it.channelType) { when (it.channelType) {
ChannelType.DirectMessage -> { ChannelType.DirectMessage -> {
@ -1139,7 +1212,8 @@ fun ChannelScreen(
// Full-screen camera preview sheet // Full-screen camera preview sheet
if (showCameraSheet) { if (showCameraSheet) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val sheetState =
rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet( ModalBottomSheet(
sheetState = sheetState, sheetState = sheetState,
onDismissRequest = { showCameraSheet = false } onDismissRequest = { showCameraSheet = false }
@ -1153,9 +1227,10 @@ fun ChannelScreen(
// Bind use cases when view is ready // Bind use cases when view is ready
val cameraProvider = cameraProviderFuture.get() val cameraProvider = cameraProviderFuture.get()
cameraProvider.unbindAll() cameraProvider.unbindAll()
val preview = CameraPreview.Builder().build().also { pr -> val preview =
pr.surfaceProvider = pv.surfaceProvider CameraPreview.Builder().build().also { pr ->
} pr.surfaceProvider = pv.surfaceProvider
}
cameraProvider.bindToLifecycle( cameraProvider.bindToLifecycle(
lifecycleOwner, lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA, CameraSelector.DEFAULT_BACK_CAMERA,
@ -1454,7 +1529,7 @@ fun ChannelScreen(
ChannelScreenActivePane.None ChannelScreenActivePane.None
} }
MediaPickerGateway( MediaPickerGateway(
onOpenPhotoPicker = { onOpenPhotoPicker = {
openPhotoPickerCallback() openPhotoPickerCallback()
viewModel.activePane = viewModel.activePane =

View File

@ -1,6 +1,8 @@
package chat.peptide.screens.login package chat.peptide.screens.login
import android.util.Log import android.util.Log
import android.app.Activity
import android.view.WindowManager
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement 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.imePadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size 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.TextObfuscationMode
import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -27,6 +31,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -34,6 +39,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -68,6 +74,8 @@ import javax.inject.Inject
class LoginViewModel @Inject constructor( class LoginViewModel @Inject constructor(
private val kvStorage: KVStorage private val kvStorage: KVStorage
) : ViewModel() { ) : ViewModel() {
var isLoading by mutableStateOf(false)
private set
private var _email by mutableStateOf("") private var _email by mutableStateOf("")
val email: String val email: String
get() = _email get() = _email
@ -90,51 +98,59 @@ class LoginViewModel @Inject constructor(
fun doLogin() { fun doLogin() {
_error = null _error = null
isLoading = true
viewModelScope.launch { viewModelScope.launch {
val response = try { try {
negotiateAuthentication(_email, _password) val response = negotiateAuthentication(_email, _password)
} catch (e: Exception) {
_error = if (e.message?.startsWith("Unexpected JSON token") == true) { if (response.error != null) {
PeptideApplication.instance.getString(R.string.service_health_alert_body_default) _error = response.error.type
} else e.message ?: "Unknown error" isLoading = false
return@launch return@launch
} }
if (response.error != null) {
_error = response.error.type
} else {
Log.d("Login", "Checking for MFA") Log.d("Login", "Checking for MFA")
if (response.proceedMfa) { if (response.proceedMfa) {
Log.d("Login", "MFA required. Navigating to MFA screen") Log.d("Login", "MFA required. Navigating to MFA screen")
_mfaResponse = response _mfaResponse = response
_navigateTo = "mfa" _navigateTo = "mfa"
} else { isLoading = false
Log.d( return@launch
"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"
}
} }
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) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltViewModel()) { 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() val passwordTextFieldState = rememberTextFieldState()
LaunchedEffect(passwordTextFieldState.text) { LaunchedEffect(passwordTextFieldState.text) {
viewModel.setPassword(passwordTextFieldState.text.toString()) viewModel.setPassword(passwordTextFieldState.text.toString())
@ -230,6 +253,7 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltVi
modifier = Modifier modifier = Modifier
.padding(innerPadding) .padding(innerPadding)
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 20.dp, horizontal = 16.dp) .padding(vertical = 20.dp, horizontal = 16.dp)
.imePadding(), .imePadding(),
verticalArrangement = Arrangement.Top, verticalArrangement = Arrangement.Top,
@ -334,6 +358,16 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltVi
url = "${PeptideAPI.getCurrentAppUrl()}/login/reset", url = "${PeptideAPI.getCurrentAppUrl()}/login/reset",
modifier = Modifier.padding(vertical = 12.dp) 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)) Spacer(modifier = Modifier.height(32.dp))
SquareButton( SquareButton(
@ -343,8 +377,17 @@ fun LoginScreen(navController: NavController, viewModel: LoginViewModel = hiltVi
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag("confirm_platform_button"), .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 password by mutableStateOf("")
var error by mutableStateOf<String?>(null) var error by mutableStateOf<String?>(null)
private var captchaToken by mutableStateOf<String?>(null) private var captchaToken by mutableStateOf<String?>(null)
var isLoading by mutableStateOf(false)
fun initCaptcha(context: Context, onSuccess: () -> Unit) { fun initCaptcha(context: Context, onSuccess: () -> Unit) {
viewModelScope.launch { viewModelScope.launch {
isLoading = true
val root = try { val root = try {
getRootRoute() getRootRoute()
} catch (e: Exception) { } catch (e: Exception) {
error = if (e.message?.startsWith("Expected response body of the type") == true) { error = if (e.message?.startsWith("Expected response body of the type") == true) {
PeptideApplication.instance.getString(R.string.service_health_alert_body_default) PeptideApplication.instance.getString(R.string.service_health_alert_body_default)
} else e.message } else e.message
isLoading = false
return@launch return@launch
} }
if (!root.features.captcha.enabled) { if (!root.features.captcha.enabled) {
onSuccess() onSuccess()
isLoading = false
return@launch return@launch
} }
@ -97,18 +101,25 @@ class RegisterDetailsScreenViewModel : ViewModel() {
size(HCaptchaSize.INVISIBLE) size(HCaptchaSize.INVISIBLE)
}.build() }.build()
HCaptcha.getClient(context).apply { try {
addOnSuccessListener { HCaptcha.getClient(context).apply {
captchaToken = it.tokenResult addOnSuccessListener {
onSuccess() captchaToken = it.tokenResult
} onSuccess()
isLoading = false
}
addOnFailureListener { addOnFailureListener {
error = it.message error = it.message
} isLoading = false
}
setup(config) setup(config)
verifyWithHCaptcha() verifyWithHCaptcha()
}
} catch (e: Exception) {
error = e.message
isLoading = false
} }
} }
} }
@ -121,12 +132,18 @@ class RegisterDetailsScreenViewModel : ViewModel() {
) )
viewModelScope.launch { viewModelScope.launch {
val result = register(body) isLoading = true
try {
if (result.ok) { val result = register(body)
navController.navigate("register/verify/$email") if (result.ok) {
} else { navController.navigate("register/verify/$email")
error = result.unwrapError().type } 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)) 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( SquareButton(
onClick = { onClick = {
viewModel.initCaptcha(context) { viewModel.initCaptcha(context) {
@ -302,8 +330,17 @@ fun RegisterDetailsScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.testTag("setup_continue_button"), .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" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="200dp" android:width="73dp"
android:height="200dp" android:height="72dp"
android:viewportWidth="500" android:viewportWidth="73"
android:viewportHeight="500"> android:viewportHeight="72">
<path <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:pathData="M32.783,19.341V30.328H25.235V19.341H32.783ZM55.191,19.341V30.328H47.644V19.341H55.191Z"
android:fillColor="@color/primary"/> 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> </vector>

View File

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