From da28d50b18cfdb622e92342eaef1ec9b70e55618 Mon Sep 17 00:00:00 2001 From: AbronStudio Date: Sat, 6 Sep 2025 17:01:12 +0330 Subject: [PATCH] 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. --- app/src/main/java/chat/peptide/api/AppAPI.kt | 13 +- .../chat/peptide/api/routes/account/Login.kt | 1 + .../peptide/api/settings/NotificationType.kt | 3 +- .../screens/chat/drawer/ChannelSideDrawer.kt | 10 +- .../peptide/screens/chat/ChatRouterScreen.kt | 374 ++++++++++-------- .../chat/views/channel/ChannelScreen.kt | 93 ++++- .../chat/peptide/screens/login/LoginScreen.kt | 119 ++++-- .../screens/register/RegisterDetailsScreen.kt | 71 +++- .../drawable/ic_notification_monochrome.xml | 15 +- app/src/main/res/values/colors.xml | 2 +- 10 files changed, 468 insertions(+), 233 deletions(-) diff --git a/app/src/main/java/chat/peptide/api/AppAPI.kt b/app/src/main/java/chat/peptide/api/AppAPI.kt index 9d56b9d3..f9ca6c21 100644 --- a/app/src/main/java/chat/peptide/api/AppAPI.kt +++ b/app/src/main/java/chat/peptide/api/AppAPI.kt @@ -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()) diff --git a/app/src/main/java/chat/peptide/api/routes/account/Login.kt b/app/src/main/java/chat/peptide/api/routes/account/Login.kt index 3c56c7ae..4423b9dc 100644 --- a/app/src/main/java/chat/peptide/api/routes/account/Login.kt +++ b/app/src/main/java/chat/peptide/api/routes/account/Login.kt @@ -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)) } diff --git a/app/src/main/java/chat/peptide/api/settings/NotificationType.kt b/app/src/main/java/chat/peptide/api/settings/NotificationType.kt index d203af6a..4d55fc23 100644 --- a/app/src/main/java/chat/peptide/api/settings/NotificationType.kt +++ b/app/src/main/java/chat/peptide/api/settings/NotificationType.kt @@ -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 diff --git a/app/src/main/java/chat/peptide/composables/screens/chat/drawer/ChannelSideDrawer.kt b/app/src/main/java/chat/peptide/composables/screens/chat/drawer/ChannelSideDrawer.kt index 5ec8f6ed..29ca4590 100644 --- a/app/src/main/java/chat/peptide/composables/screens/chat/drawer/ChannelSideDrawer.kt +++ b/app/src/main/java/chat/peptide/composables/screens/chat/drawer/ChannelSideDrawer.kt @@ -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 }, diff --git a/app/src/main/java/chat/peptide/screens/chat/ChatRouterScreen.kt b/app/src/main/java/chat/peptide/screens/chat/ChatRouterScreen.kt index afac9d47..3965de66 100644 --- a/app/src/main/java/chat/peptide/screens/chat/ChatRouterScreen.kt +++ b/app/src/main/java/chat/peptide/screens/chat/ChatRouterScreen.kt @@ -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.default) + var previousDestination by mutableStateOf(null) + var lastNonChannelDestination by mutableStateOf(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(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, + ) } } } diff --git a/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt b/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt index 815481dc..1bb2ac13 100644 --- a/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt +++ b/app/src/main/java/chat/peptide/screens/chat/views/channel/ChannelScreen.kt @@ -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( } // // + // 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 = diff --git a/app/src/main/java/chat/peptide/screens/login/LoginScreen.kt b/app/src/main/java/chat/peptide/screens/login/LoginScreen.kt index c612b609..674319e0 100644 --- a/app/src/main/java/chat/peptide/screens/login/LoginScreen.kt +++ b/app/src/main/java/chat/peptide/screens/login/LoginScreen.kt @@ -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)) + } } } } diff --git a/app/src/main/java/chat/peptide/screens/register/RegisterDetailsScreen.kt b/app/src/main/java/chat/peptide/screens/register/RegisterDetailsScreen.kt index d0e7c3f7..28c9fe8f 100644 --- a/app/src/main/java/chat/peptide/screens/register/RegisterDetailsScreen.kt +++ b/app/src/main/java/chat/peptide/screens/register/RegisterDetailsScreen.kt @@ -74,20 +74,24 @@ class RegisterDetailsScreenViewModel : ViewModel() { var password by mutableStateOf("") var error by mutableStateOf(null) private var captchaToken by mutableStateOf(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_)) + } } } } diff --git a/app/src/main/res/drawable/ic_notification_monochrome.xml b/app/src/main/res/drawable/ic_notification_monochrome.xml index 4eb5c682..f4883094 100644 --- a/app/src/main/res/drawable/ic_notification_monochrome.xml +++ b/app/src/main/res/drawable/ic_notification_monochrome.xml @@ -1,9 +1,12 @@ + android:width="73dp" + android:height="72dp" + android:viewportWidth="73" + android:viewportHeight="72"> + android:pathData="M32.783,19.341V30.328H25.235V19.341H32.783ZM55.191,19.341V30.328H47.644V19.341H55.191Z" + android:fillColor="#662D91"/> + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index aa74e5c9..c32cb31a 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -1,6 +1,6 @@ - #FFFF005C + #FF100518 #FF131722 #FFFFFFFF #FF000000