Unify sheets swipe-to-dismiss gating
This commit is contained in:
@@ -1014,46 +1014,47 @@ class MainActivity :
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight(0.9f),
|
.fillMaxHeight(),
|
||||||
) {
|
) {
|
||||||
// Header
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 24.dp)
|
|
||||||
.padding(bottom = 16.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.title_groups),
|
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
)
|
|
||||||
if (groupsUiState.groups.isNotEmpty()) {
|
|
||||||
IconButton(onClick = { groupsViewModel.toggleAllGroups() }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (allCollapsed) {
|
|
||||||
Icons.Default.UnfoldMore
|
|
||||||
} else {
|
|
||||||
Icons.Default.UnfoldLess
|
|
||||||
},
|
|
||||||
contentDescription = if (allCollapsed) {
|
|
||||||
stringResource(R.string.expand_all)
|
|
||||||
} else {
|
|
||||||
stringResource(R.string.collapse_all)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Groups content
|
// Groups content
|
||||||
GroupsCard(
|
GroupsCard(
|
||||||
serviceStatus = currentServiceStatus,
|
serviceStatus = currentServiceStatus,
|
||||||
commandClient = dashboardViewModel.commandClient,
|
commandClient = dashboardViewModel.commandClient,
|
||||||
viewModel = groupsViewModel,
|
viewModel = groupsViewModel,
|
||||||
|
listHeaderContent = {
|
||||||
|
Row(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.title_groups),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
if (groupsUiState.groups.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { groupsViewModel.toggleAllGroups() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (allCollapsed) {
|
||||||
|
Icons.Default.UnfoldMore
|
||||||
|
} else {
|
||||||
|
Icons.Default.UnfoldLess
|
||||||
|
},
|
||||||
|
contentDescription = if (allCollapsed) {
|
||||||
|
stringResource(R.string.expand_all)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.collapse_all)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
asSheet = true,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1097,7 +1098,7 @@ class MainActivity :
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.fillMaxHeight(0.9f),
|
.fillMaxHeight(),
|
||||||
) {
|
) {
|
||||||
if (displayConnection != null) {
|
if (displayConnection != null) {
|
||||||
ConnectionDetailsScreen(
|
ConnectionDetailsScreen(
|
||||||
@@ -1106,11 +1107,13 @@ class MainActivity :
|
|||||||
onClose = {
|
onClose = {
|
||||||
selectedConnectionId?.let { connectionsViewModel.closeConnection(it) }
|
selectedConnectionId?.let { connectionsViewModel.closeConnection(it) }
|
||||||
},
|
},
|
||||||
|
asSheet = true,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
ConnectionsPage(
|
ConnectionsPage(
|
||||||
serviceStatus = currentServiceStatus,
|
serviceStatus = currentServiceStatus,
|
||||||
viewModel = connectionsViewModel,
|
viewModel = connectionsViewModel,
|
||||||
|
asSheet = true,
|
||||||
showTitle = true,
|
showTitle = true,
|
||||||
onConnectionClick = { selectedConnectionId = it },
|
onConnectionClick = { selectedConnectionId = it },
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.rememberOverscrollEffect
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -46,6 +47,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import io.nekohasekai.libbox.Libbox
|
import io.nekohasekai.libbox.Libbox
|
||||||
import io.nekohasekai.sfa.R
|
import io.nekohasekai.sfa.R
|
||||||
import io.nekohasekai.sfa.compose.model.Connection
|
import io.nekohasekai.sfa.compose.model.Connection
|
||||||
|
import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -57,17 +59,25 @@ fun ConnectionDetailsScreen(
|
|||||||
onClose: () -> Unit,
|
onClose: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showHeader: Boolean = true,
|
showHeader: Boolean = true,
|
||||||
|
asSheet: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val dateTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
val dateTimeFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
|
||||||
var showMenu by remember { mutableStateOf(false) }
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
val scrollState = rememberScrollState()
|
val scrollState = rememberScrollState()
|
||||||
val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(scrollState)
|
val scrollModifier =
|
||||||
|
if (asSheet) {
|
||||||
|
rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier {
|
||||||
|
scrollState.value == 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier.nestedScroll(rememberBounceBlockingNestedScrollConnection(scrollState))
|
||||||
|
}
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.nestedScroll(bounceBlockingConnection)
|
.then(scrollModifier)
|
||||||
.verticalScroll(scrollState),
|
.verticalScroll(scrollState, overscrollEffect = if (asSheet) null else rememberOverscrollEffect()),
|
||||||
) {
|
) {
|
||||||
if (showHeader) {
|
if (showHeader) {
|
||||||
Row(
|
Row(
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.rememberOverscrollEffect
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Check
|
import androidx.compose.material.icons.filled.Check
|
||||||
@@ -44,6 +44,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
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
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.runtime.setValue
|
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
|
||||||
@@ -62,6 +63,7 @@ import io.nekohasekai.sfa.compose.model.Connection
|
|||||||
import io.nekohasekai.sfa.compose.model.ConnectionSort
|
import io.nekohasekai.sfa.compose.model.ConnectionSort
|
||||||
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
|
import io.nekohasekai.sfa.compose.model.ConnectionStateFilter
|
||||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
|
import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -69,6 +71,7 @@ import io.nekohasekai.sfa.constant.Status
|
|||||||
fun ConnectionsPage(
|
fun ConnectionsPage(
|
||||||
serviceStatus: Status,
|
serviceStatus: Status,
|
||||||
viewModel: ConnectionsViewModel = viewModel(),
|
viewModel: ConnectionsViewModel = viewModel(),
|
||||||
|
asSheet: Boolean = false,
|
||||||
showTitle: Boolean = true,
|
showTitle: Boolean = true,
|
||||||
showTopBar: Boolean = false,
|
showTopBar: Boolean = false,
|
||||||
onConnectionClick: (String) -> Unit = {},
|
onConnectionClick: (String) -> Unit = {},
|
||||||
@@ -87,14 +90,21 @@ fun ConnectionsPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(
|
val headerRowModifier =
|
||||||
modifier = modifier.fillMaxSize(),
|
if (asSheet) {
|
||||||
) {
|
Modifier
|
||||||
Row(
|
.fillMaxWidth()
|
||||||
modifier = Modifier
|
.padding(bottom = 16.dp)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(bottom = 16.dp),
|
.padding(bottom = 16.dp)
|
||||||
|
}
|
||||||
|
|
||||||
|
val headerContent: @Composable () -> Unit = {
|
||||||
|
Row(
|
||||||
|
modifier = headerRowModifier,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
@@ -258,13 +268,29 @@ fun ConnectionsPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asSheet) {
|
||||||
ConnectionsScreen(
|
ConnectionsScreen(
|
||||||
serviceStatus = serviceStatus,
|
serviceStatus = serviceStatus,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onConnectionClick = { connection -> onConnectionClick(connection.id) },
|
onConnectionClick = { connection -> onConnectionClick(connection.id) },
|
||||||
modifier = Modifier.fillMaxSize(),
|
listHeaderContent = headerContent,
|
||||||
|
asSheet = true,
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
|
Column(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
) {
|
||||||
|
headerContent()
|
||||||
|
ConnectionsScreen(
|
||||||
|
serviceStatus = serviceStatus,
|
||||||
|
viewModel = viewModel,
|
||||||
|
onConnectionClick = { connection -> onConnectionClick(connection.id) },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,6 +373,8 @@ fun ConnectionsScreen(
|
|||||||
serviceStatus: Status,
|
serviceStatus: Status,
|
||||||
viewModel: ConnectionsViewModel = viewModel(),
|
viewModel: ConnectionsViewModel = viewModel(),
|
||||||
onConnectionClick: (Connection) -> Unit = {},
|
onConnectionClick: (Connection) -> Unit = {},
|
||||||
|
listHeaderContent: (@Composable () -> Unit)? = null,
|
||||||
|
asSheet: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
@@ -365,73 +393,96 @@ fun ConnectionsScreen(
|
|||||||
viewModel.updateServiceStatus(serviceStatus)
|
viewModel.updateServiceStatus(serviceStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = modifier.fillMaxSize()) {
|
val lazyListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
|
||||||
AnimatedVisibility(
|
|
||||||
visible = uiState.isSearchActive,
|
|
||||||
enter = expandVertically() + fadeIn(),
|
|
||||||
exit = shrinkVertically() + fadeOut(),
|
|
||||||
) {
|
|
||||||
val focusRequester = remember { FocusRequester() }
|
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
if (asSheet) {
|
||||||
focusRequester.requestFocus()
|
val sheetSwipeToDismissModifier =
|
||||||
|
rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier {
|
||||||
|
lazyListState.firstVisibleItemIndex == 0 &&
|
||||||
|
lazyListState.firstVisibleItemScrollOffset == 0
|
||||||
}
|
}
|
||||||
|
LazyColumn(
|
||||||
OutlinedTextField(
|
modifier =
|
||||||
value = uiState.searchText,
|
modifier
|
||||||
onValueChange = { viewModel.setSearchText(it) },
|
.fillMaxSize()
|
||||||
modifier = Modifier
|
.then(sheetSwipeToDismissModifier),
|
||||||
.fillMaxWidth()
|
state = lazyListState,
|
||||||
.padding(horizontal = 16.dp)
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 16.dp),
|
||||||
.padding(bottom = 8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
.focusRequester(focusRequester),
|
overscrollEffect = null,
|
||||||
placeholder = { Text(stringResource(R.string.search_connections)) },
|
) {
|
||||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
if (listHeaderContent != null) {
|
||||||
trailingIcon = {
|
item(key = "connections_list_header") {
|
||||||
if (uiState.searchText.isNotEmpty()) {
|
listHeaderContent()
|
||||||
IconButton(onClick = { viewModel.setSearchText("") }) {
|
|
||||||
Icon(Icons.Default.Clear, contentDescription = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
when {
|
|
||||||
uiState.isLoading -> {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
uiState.connections.isEmpty() -> {
|
item(key = "connections_search") {
|
||||||
Box(
|
AnimatedVisibility(
|
||||||
modifier = Modifier.fillMaxSize(),
|
visible = uiState.isSearchActive,
|
||||||
contentAlignment = Alignment.Center,
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
) {
|
) {
|
||||||
Text(
|
val focusRequester = remember { FocusRequester() }
|
||||||
text = stringResource(R.string.empty_connections),
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
LaunchedEffect(Unit) {
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.searchText,
|
||||||
|
onValueChange = { viewModel.setSearchText(it) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
placeholder = { Text(stringResource(R.string.search_connections)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (uiState.searchText.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { viewModel.setSearchText("") }) {
|
||||||
|
Icon(Icons.Default.Clear, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
when {
|
||||||
val lazyListState = rememberLazyListState()
|
uiState.isLoading -> {
|
||||||
val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState)
|
item(key = "connections_loading") {
|
||||||
LazyColumn(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxWidth()
|
||||||
.nestedScroll(bounceBlockingConnection),
|
.padding(vertical = 48.dp),
|
||||||
state = lazyListState,
|
contentAlignment = Alignment.Center,
|
||||||
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
) {
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
CircularProgressIndicator()
|
||||||
) {
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.connections.isEmpty() -> {
|
||||||
|
item(key = "connections_empty") {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 48.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.empty_connections),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
items(
|
items(
|
||||||
items = uiState.connections,
|
items = uiState.connections,
|
||||||
key = { it.id },
|
key = { it.id },
|
||||||
@@ -445,6 +496,90 @@ fun ConnectionsScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Column(modifier = modifier.fillMaxSize()) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = uiState.isSearchActive,
|
||||||
|
enter = expandVertically() + fadeIn(),
|
||||||
|
exit = shrinkVertically() + fadeOut(),
|
||||||
|
) {
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = uiState.searchText,
|
||||||
|
onValueChange = { viewModel.setSearchText(it) },
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 8.dp)
|
||||||
|
.focusRequester(focusRequester),
|
||||||
|
placeholder = { Text(stringResource(R.string.search_connections)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
trailingIcon = {
|
||||||
|
if (uiState.searchText.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { viewModel.setSearchText("") }) {
|
||||||
|
Icon(Icons.Default.Clear, contentDescription = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
uiState.isLoading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.connections.isEmpty() -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.empty_connections),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState)
|
||||||
|
LazyColumn(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.nestedScroll(bounceBlockingConnection),
|
||||||
|
state = lazyListState,
|
||||||
|
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
overscrollEffect = rememberOverscrollEffect(),
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = uiState.connections,
|
||||||
|
key = { it.id },
|
||||||
|
) { connection ->
|
||||||
|
ConnectionItem(
|
||||||
|
connection = connection,
|
||||||
|
onClick = { onConnectionClick(connection) },
|
||||||
|
onClose = { viewModel.closeConnection(connection.id) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.rememberOverscrollEffect
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ExpandMore
|
import androidx.compose.material.icons.filled.ExpandMore
|
||||||
@@ -49,6 +49,7 @@ import androidx.compose.runtime.collectAsState
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
@@ -70,6 +71,7 @@ import io.nekohasekai.sfa.compose.model.Group
|
|||||||
import io.nekohasekai.sfa.compose.model.GroupItem
|
import io.nekohasekai.sfa.compose.model.GroupItem
|
||||||
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
|
import io.nekohasekai.sfa.compose.screen.dashboard.groups.GroupsViewModel
|
||||||
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
import io.nekohasekai.sfa.compose.topbar.OverrideTopBar
|
||||||
|
import io.nekohasekai.sfa.compose.util.rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier
|
||||||
import io.nekohasekai.sfa.constant.Status
|
import io.nekohasekai.sfa.constant.Status
|
||||||
import io.nekohasekai.sfa.utils.CommandClient
|
import io.nekohasekai.sfa.utils.CommandClient
|
||||||
|
|
||||||
@@ -80,6 +82,8 @@ fun GroupsCard(
|
|||||||
commandClient: CommandClient? = null,
|
commandClient: CommandClient? = null,
|
||||||
viewModel: GroupsViewModel? = null,
|
viewModel: GroupsViewModel? = null,
|
||||||
showTopBar: Boolean = false,
|
showTopBar: Boolean = false,
|
||||||
|
listHeaderContent: (@Composable () -> Unit)? = null,
|
||||||
|
asSheet: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val actualViewModel: GroupsViewModel = viewModel ?: viewModel(
|
val actualViewModel: GroupsViewModel = viewModel ?: viewModel(
|
||||||
@@ -169,6 +173,8 @@ fun GroupsCard(
|
|||||||
onToggleExpanded = onToggleExpanded,
|
onToggleExpanded = onToggleExpanded,
|
||||||
onItemSelected = onItemSelected,
|
onItemSelected = onItemSelected,
|
||||||
onUrlTest = onUrlTest,
|
onUrlTest = onUrlTest,
|
||||||
|
listHeaderContent = listHeaderContent,
|
||||||
|
asSheet = asSheet,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -179,51 +185,78 @@ private fun GroupsCardContent(
|
|||||||
onToggleExpanded: (String) -> Unit,
|
onToggleExpanded: (String) -> Unit,
|
||||||
onItemSelected: (String, String) -> Unit,
|
onItemSelected: (String, String) -> Unit,
|
||||||
onUrlTest: (String) -> Unit,
|
onUrlTest: (String) -> Unit,
|
||||||
|
listHeaderContent: (@Composable () -> Unit)? = null,
|
||||||
|
asSheet: Boolean = false,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(modifier = modifier.fillMaxWidth()) {
|
val lazyListState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() }
|
||||||
// Groups content
|
val scrollModifier =
|
||||||
if (uiState.isLoading) {
|
if (asSheet) {
|
||||||
Box(
|
rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier {
|
||||||
modifier =
|
lazyListState.firstVisibleItemIndex == 0 &&
|
||||||
Modifier
|
lazyListState.firstVisibleItemScrollOffset == 0
|
||||||
.fillMaxWidth()
|
|
||||||
.height(200.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
} else if (uiState.groups.isEmpty()) {
|
|
||||||
Box(
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(100.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "No groups available",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val lazyListState = rememberLazyListState()
|
Modifier.nestedScroll(rememberBounceBlockingNestedScrollConnection(lazyListState))
|
||||||
val bounceBlockingConnection = rememberBounceBlockingNestedScrollConnection(lazyListState)
|
}
|
||||||
LazyColumn(
|
val overscrollEffect = if (asSheet) null else rememberOverscrollEffect()
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
LazyColumn(
|
||||||
.nestedScroll(bounceBlockingConnection),
|
modifier =
|
||||||
state = lazyListState,
|
modifier
|
||||||
contentPadding =
|
.fillMaxSize()
|
||||||
PaddingValues(
|
.then(scrollModifier),
|
||||||
start = 16.dp,
|
state = lazyListState,
|
||||||
end = 16.dp,
|
contentPadding =
|
||||||
top = 8.dp,
|
PaddingValues(
|
||||||
bottom = 16.dp,
|
start = 16.dp,
|
||||||
),
|
end = 16.dp,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
top = 8.dp,
|
||||||
) {
|
bottom = 16.dp,
|
||||||
|
),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
overscrollEffect = overscrollEffect,
|
||||||
|
) {
|
||||||
|
if (listHeaderContent != null) {
|
||||||
|
item(key = "groups_list_header") {
|
||||||
|
listHeaderContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
uiState.isLoading -> {
|
||||||
|
item(key = "groups_loading") {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uiState.groups.isEmpty() -> {
|
||||||
|
item(key = "groups_empty") {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(100.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No groups available",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
items(
|
items(
|
||||||
items = uiState.groups,
|
items = uiState.groups,
|
||||||
key = { it.tag },
|
key = { it.tag },
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package io.nekohasekai.sfa.compose.util
|
||||||
|
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
|
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||||
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.Velocity
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun rememberSheetDismissFromContentOnlyIfGestureStartedAtTopModifier(isAtTop: () -> Boolean): Modifier {
|
||||||
|
val isAtTopState = rememberUpdatedState(isAtTop)
|
||||||
|
val gestureStartedAtTop = remember { mutableStateOf(true) }
|
||||||
|
|
||||||
|
val nestedScrollConnection =
|
||||||
|
remember {
|
||||||
|
object : NestedScrollConnection {
|
||||||
|
override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
|
||||||
|
if (source != NestedScrollSource.UserInput) return Offset.Zero
|
||||||
|
val startedAtTop = gestureStartedAtTop.value
|
||||||
|
return when {
|
||||||
|
available.y < 0 -> available
|
||||||
|
available.y > 0 && !startedAtTop -> available
|
||||||
|
else -> Offset.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||||
|
val startedAtTop = gestureStartedAtTop.value
|
||||||
|
return when {
|
||||||
|
available.y < 0 -> available
|
||||||
|
available.y > 0 && !startedAtTop -> available
|
||||||
|
else -> Velocity.Zero
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val gestureGateModifier =
|
||||||
|
Modifier.pointerInput(Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
awaitFirstDown(requireUnconsumed = false)
|
||||||
|
gestureStartedAtTop.value = isAtTopState.value.invoke()
|
||||||
|
do {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
} while (event.changes.any { it.pressed })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return gestureGateModifier.nestedScroll(nestedScrollConnection)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user