diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5b07b068..47e0af21 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -170,6 +170,7 @@ android:name=".ui.MainActivity" android:configChanges="keyboardHidden|orientation|screenSize" android:theme="@style/NoActionBarAppTheme" + android:windowSoftInputMode="adjustResize" android:exported="true"> diff --git a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt index 54a298fb..88705e79 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/MainActivity.kt @@ -25,6 +25,7 @@ import androidx.activity.viewModels import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.compose.runtime.getValue +import androidx.core.view.WindowCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.google.accompanist.adaptive.calculateDisplayFeatures import xyz.quaver.pupil.ui.composable.MainApp @@ -39,6 +40,8 @@ class MainActivity : BaseActivity() { val viewModel: MainViewModel by viewModels() + WindowCompat.setDecorFitsSystemWindows(window, false) + setContent { AppTheme { val windowSize = calculateWindowSizeClass(this) diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt index 2d0b24e1..6f9793a9 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/MainApp.kt @@ -5,7 +5,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars import androidx.compose.material3.DrawerValue import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalNavigationDrawer @@ -105,13 +109,15 @@ private fun MainNavigationWrapper( } if (navigationType == NavigationType.PERMANENT_NAVIGATION_DRAWER) { - PermanentNavigationDrawer(drawerContent = { - PermanentNavigationDrawerContent( - selectedDestination = uiState.currentDestination, - navigationContentPosition = navigationContentPosition, - navigateToDestination = navigateToDestination - ) - }) { + PermanentNavigationDrawer( + drawerContent = { + PermanentNavigationDrawerContent( + selectedDestination = uiState.currentDestination, + navigationContentPosition = navigationContentPosition, + navigateToDestination = navigateToDestination + ) + } + ) { MainContent( navigationType = navigationType, contentType = contentType, @@ -180,6 +186,12 @@ fun MainContent( ) { Box( modifier = Modifier.weight(1f) + .run { + if (navigationType == NavigationType.BOTTOM_NAVIGATION) { + this.consumeWindowInsets(WindowInsets.ime) + .consumeWindowInsets(WindowInsets.navigationBars) + } else this + } ) { MainScreen( contentType = contentType, diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationDrawerContent.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationDrawerContent.kt index 751375e3..8aa9fa15 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationDrawerContent.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/NavigationDrawerContent.kt @@ -4,17 +4,20 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.union import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.MenuOpen import androidx.compose.material.icons.filled.Menu -import androidx.compose.material.icons.filled.MenuOpen import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -34,7 +37,6 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.Layout import androidx.compose.ui.layout.Measurable import androidx.compose.ui.layout.MeasurePolicy -import androidx.compose.ui.layout.layout import androidx.compose.ui.layout.layoutId import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -237,7 +239,7 @@ fun BottomNavigationBar( selectedDestination: MainDestination, navigateToDestination: (MainDestination) -> Unit ) { - NavigationBar(modifier = Modifier.fillMaxWidth()) { + NavigationBar(modifier = Modifier.fillMaxWidth(), windowInsets = WindowInsets.ime.union(WindowInsets.navigationBars)) { mainDestinations.forEach { destination -> NavigationBarItem( selected = selectedDestination.route == destination.route, diff --git a/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt b/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt index 23e50af5..998ec4b7 100644 --- a/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt +++ b/app/src/main/java/xyz/quaver/pupil/ui/composable/QueryEditor.kt @@ -1,10 +1,11 @@ package xyz.quaver.pupil.ui.composable -import android.util.Log import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -19,6 +20,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -39,24 +41,33 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.toLowerCase import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import xyz.quaver.pupil.R import xyz.quaver.pupil.networking.SearchQuery import xyz.quaver.pupil.networking.validNamespace @@ -66,7 +77,6 @@ import xyz.quaver.pupil.ui.theme.Gray300 import xyz.quaver.pupil.ui.theme.Pink600 import xyz.quaver.pupil.ui.theme.Red300 import xyz.quaver.pupil.ui.theme.Yellow400 -import kotlin.math.exp private fun SearchQuery.toEditableStateInternal(): EditableSearchQueryState = when (this) { is SearchQuery.Tag -> EditableSearchQueryState.Tag(namespace, tag) @@ -102,10 +112,12 @@ fun coalesceTags(oldTag: EditableSearchQueryState.Tag?, newTag: EditableSearchQu sealed interface EditableSearchQueryState { class Tag( namespace: String? = null, - tag: String = "" + tag: String = "", + expanded: Boolean = false ): EditableSearchQueryState { val namespace = mutableStateOf(namespace) val tag = mutableStateOf(tag) + val expanded = mutableStateOf(expanded) } class And( @@ -138,6 +150,8 @@ sealed interface EditableSearchQueryState { fun EditableTagChip( state: EditableSearchQueryState.Tag, isFavorite: Boolean = false, + autoFocus: Boolean = true, + requestScrollTo: (Float) -> Unit, leftIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag -> TagChipIcon(tag) }, rightIcon: @Composable RowScope.(SearchQuery.Tag) -> Unit = { _ -> Spacer(Modifier.width(16.dp)) }, content: @Composable RowScope.(SearchQuery.Tag) -> Unit = { tag -> @@ -149,10 +163,20 @@ fun EditableTagChip( ) } ) { + val coroutineScope = rememberCoroutineScope() + var namespace by state.namespace var tag by state.tag + var expanded by state.expanded + var wasFocused by remember { mutableStateOf(false) } - var expanded by remember { mutableStateOf(false) } + var positionY by remember { mutableFloatStateOf(0f) } + + LaunchedEffect(expanded) { + if (!expanded) { + wasFocused = false + } + } val surfaceColor by animateColorAsState( when { @@ -175,6 +199,9 @@ fun EditableTagChip( ) Surface( + modifier = Modifier.onGloballyPositioned { + positionY = it.positionInRoot().y + }, shape = RoundedCornerShape(16.dp), color = surfaceColor ) { @@ -217,20 +244,34 @@ fun EditableTagChip( ) } - var selection by remember { mutableStateOf(TextRange.Zero) } + var selection by remember { mutableStateOf(TextRange(tag.length)) } var composition by remember { mutableStateOf(null) } + val focusRequester = remember { FocusRequester() } + val textFieldValue = remember(tag, selection, composition) { TextFieldValue(tag, selection, composition) } + LaunchedEffect(expanded) { + if (autoFocus && expanded) { + focusRequester.requestFocus() + } + } + OutlinedTextField( value = textFieldValue, singleLine = true, + keyboardOptions = KeyboardOptions( + autoCorrect = false, + capitalization = KeyboardCapitalization.None, + imeAction = ImeAction.Done + ), leadingIcon = { TagChipIcon(SearchQuery.Tag(namespace, tag)) }, modifier = Modifier + .fillMaxWidth() .onKeyEvent { event -> if (event.key == Key.Backspace && tag.isEmpty()) { val newTag = namespace?.dropLast(1) ?: "" @@ -240,6 +281,18 @@ fun EditableTagChip( composition = null true } else false + } + .focusRequester(focusRequester) + .onFocusChanged { event -> + if (event.isFocused) { + wasFocused = true + coroutineScope.launch { + delay(300) + requestScrollTo(positionY) + } + } else if (wasFocused) { + expanded = false + } }, keyboardActions = KeyboardActions( onDone = { @@ -247,9 +300,10 @@ fun EditableTagChip( } ), onValueChange = { newTextValue -> - val newTag = newTextValue.text.lowercase() - tag = if (namespace == null && newTag.trim() in validNamespace) { - namespace = newTag.trim() + val newTag = newTextValue.text + val possibleNamespace = newTag.dropLast(1).lowercase().trim() + tag = if (namespace == null && newTag.endsWith(':') && possibleNamespace in validNamespace) { + namespace = possibleNamespace "" } else newTag selection = newTextValue.selection @@ -310,10 +364,10 @@ fun NewQueryChip( opened = false } HorizontalDivider() - if (currentQuery !is EditableSearchQueryState.Tag) { + if (currentQuery !is EditableSearchQueryState.Tag && currentQuery !is EditableSearchQueryState.And) { NewQueryRow(modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.search_add_query_item_tag)) { opened = false - onNewQuery(EditableSearchQueryState.Tag()) + onNewQuery(EditableSearchQueryState.Tag(expanded = true)) } } if (currentQuery !is EditableSearchQueryState.And) { @@ -351,11 +405,14 @@ fun NewQueryChip( fun QueryEditorQueryView( state: EditableSearchQueryState, onQueryRemove: (EditableSearchQueryState) -> Unit, + requestScrollTo: (Float) -> Unit, + requestScrollBy: (Float) -> Unit, ) { when (state) { is EditableSearchQueryState.Tag -> { EditableTagChip( state, + requestScrollTo = requestScrollTo, rightIcon = { Icon( modifier = Modifier @@ -404,7 +461,9 @@ fun QueryEditorQueryView( if (index != 0) { Text("+", modifier = Modifier.padding(horizontal = 8.dp)) } QueryEditorQueryView( subQueryState, - onQueryRemove = { state.queries.remove(it) } + onQueryRemove = { state.queries.remove(it) }, + requestScrollTo = requestScrollTo, + requestScrollBy = requestScrollBy ) } NewQueryChip(state) { newQueryState -> @@ -429,6 +488,24 @@ fun QueryEditorQueryView( verticalArrangement = Arrangement.spacedBy(8.dp), horizontalAlignment = Alignment.Start, ) { + val newSearchQuery = remember { EditableSearchQueryState.Tag() } + + var newQueryNamespace by newSearchQuery.namespace + var newQueryTag by newSearchQuery.tag + var newQueryExpanded by newSearchQuery.expanded + + val offset = with(LocalDensity.current) { 40.dp.toPx() } + + LaunchedEffect(newQueryExpanded) { + if (!newQueryExpanded && (newQueryNamespace != null || newQueryTag.isNotBlank())) { + state.queries.add(EditableSearchQueryState.Tag(newQueryNamespace, newQueryTag)) + newQueryNamespace = null + newQueryTag = "" + newQueryExpanded = true + requestScrollBy(offset) + } + } + Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -446,9 +523,27 @@ fun QueryEditorQueryView( state.queries.forEach { subQuery -> QueryEditorQueryView( subQuery, - onQueryRemove = { state.queries.remove(it) } + onQueryRemove = { state.queries.remove(it) }, + requestScrollTo = requestScrollTo, + requestScrollBy = requestScrollBy ) } + EditableTagChip( + newSearchQuery, + requestScrollTo = requestScrollTo, + rightIcon = { + Icon( + modifier = Modifier + .padding(8.dp) + .size(16.dp) + .clickable { + onQueryRemove(state) + }, + imageVector = Icons.Default.RemoveCircleOutline, + contentDescription = stringResource(R.string.search_remove_query_item_description) + ) + } + ) NewQueryChip(state) { newQueryState -> state.queries.add(newQueryState) } @@ -491,7 +586,9 @@ fun QueryEditorQueryView( if (subQueryStateSnapshot != null) { QueryEditorQueryView( subQueryStateSnapshot, - onQueryRemove = { subQueryState = null } + onQueryRemove = { subQueryState = null }, + requestScrollTo = requestScrollTo, + requestScrollBy = requestScrollBy, ) } @@ -518,9 +615,19 @@ fun QueryEditor( ) { var rootQuery by state.query + val scrollState = rememberScrollState() + var topY by remember { mutableFloatStateOf(0f) } + + val scrollOffset = with (LocalDensity.current) { 16.dp.toPx() } + + val coroutineScope = rememberCoroutineScope() + Column( modifier = Modifier - .verticalScroll(rememberScrollState()) + .onGloballyPositioned { + topY = it.positionInRoot().y + } + .verticalScroll(scrollState) .padding(8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { @@ -528,7 +635,19 @@ fun QueryEditor( if (rootQuerySnapshot != null) { QueryEditorQueryView( state = rootQuerySnapshot, - onQueryRemove = { rootQuery = null } + onQueryRemove = { rootQuery = null }, + requestScrollTo = { target -> + val topYSnapshot = topY + + coroutineScope.launch { + scrollState.animateScrollBy(target - topYSnapshot - scrollOffset, spring(stiffness = Spring.StiffnessLow)) + } + }, + requestScrollBy = { value -> + coroutineScope.launch { + scrollState.animateScrollBy(value) + } + } ) }