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:
parent
5596d56525
commit
da28d50b18
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue