From 073908060772f6f8a399476b54b6470c91adc466 Mon Sep 17 00:00:00 2001 From: TimPushkin Date: Fri, 27 Oct 2023 17:06:41 +0300 Subject: [PATCH 1/2] Replace SearchScreen with SearchBar --- app/build.gradle | 3 +- .../depnav/data/db/SearchHistoryDaoTest.kt | 7 +- app/src/main/AndroidManifest.xml | 2 +- .../spbu/depnav/data/db/SearchHistoryDao.kt | 3 +- .../data/repository/SearchHistoryRepo.kt | 2 +- .../ru/spbu/depnav/{ => ui}/MainActivity.kt | 43 +- .../java/ru/spbu/depnav/ui/NavDestination.kt | 22 - .../ui/{map => component}/FloorSwitch.kt | 9 +- .../spbu/depnav/ui/component/MapSearchBar.kt | 205 +++++++++ .../ui/{map => component}/MarkerInfoLines.kt | 2 +- .../ui/{map => component}/MarkerView.kt | 2 +- .../spbu/depnav/ui/{map => component}/Pin.kt | 2 +- .../ui/{search => component}/SearchResults.kt | 7 +- .../ui/{map => component}/ZoomInHint.kt | 2 +- .../ui/{map => dialog}/MapLegendDialog.kt | 3 +- .../ui/{map => dialog}/SettingsDialog.kt | 2 +- .../java/ru/spbu/depnav/ui/map/MapScreen.kt | 242 ----------- .../spbu/depnav/ui/map/MapScreenViewModel.kt | 305 ------------- .../java/ru/spbu/depnav/ui/map/TopButton.kt | 116 ----- .../ru/spbu/depnav/ui/screen/MapScreen.kt | 280 ++++++++++++ .../ru/spbu/depnav/ui/search/SearchField.kt | 122 ------ .../ru/spbu/depnav/ui/search/SearchScreen.kt | 98 ----- .../depnav/ui/search/SearchScreenViewModel.kt | 114 ----- .../spbu/depnav/ui/viewmodel/MapViewModel.kt | 411 ++++++++++++++++++ .../depnav/utils/misc/StateFlowMutations.kt | 23 + .../java/ru/spbu/depnav/utils/tiles/Floor.kt | 5 +- app/src/main/res/values-ru/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - gradle/libs.versions.toml | 5 +- 29 files changed, 947 insertions(+), 1092 deletions(-) rename app/src/main/java/ru/spbu/depnav/{ => ui}/MainActivity.kt (55%) delete mode 100644 app/src/main/java/ru/spbu/depnav/ui/NavDestination.kt rename app/src/main/java/ru/spbu/depnav/ui/{map => component}/FloorSwitch.kt (95%) create mode 100644 app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt rename app/src/main/java/ru/spbu/depnav/ui/{map => component}/MarkerInfoLines.kt (99%) rename app/src/main/java/ru/spbu/depnav/ui/{map => component}/MarkerView.kt (99%) rename app/src/main/java/ru/spbu/depnav/ui/{map => component}/Pin.kt (97%) rename app/src/main/java/ru/spbu/depnav/ui/{search => component}/SearchResults.kt (97%) rename app/src/main/java/ru/spbu/depnav/ui/{map => component}/ZoomInHint.kt (98%) rename app/src/main/java/ru/spbu/depnav/ui/{map => dialog}/MapLegendDialog.kt (97%) rename app/src/main/java/ru/spbu/depnav/ui/{map => dialog}/SettingsDialog.kt (99%) delete mode 100644 app/src/main/java/ru/spbu/depnav/ui/map/MapScreen.kt delete mode 100644 app/src/main/java/ru/spbu/depnav/ui/map/MapScreenViewModel.kt delete mode 100644 app/src/main/java/ru/spbu/depnav/ui/map/TopButton.kt create mode 100644 app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt delete mode 100644 app/src/main/java/ru/spbu/depnav/ui/search/SearchField.kt delete mode 100644 app/src/main/java/ru/spbu/depnav/ui/search/SearchScreen.kt delete mode 100644 app/src/main/java/ru/spbu/depnav/ui/search/SearchScreenViewModel.kt create mode 100644 app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt create mode 100644 app/src/main/java/ru/spbu/depnav/utils/misc/StateFlowMutations.kt diff --git a/app/build.gradle b/app/build.gradle index e4412624..7addb102 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -70,6 +70,7 @@ android { dependencies { implementation libs.androidx.lifecycle.runtimeKtx + implementation libs.androidx.lifecycle.runtimeCompose implementation libs.androidx.lifecycle.viewmodelCompose implementation libs.androidx.room.runtime @@ -88,8 +89,6 @@ dependencies { implementation libs.androidx.core.ktx implementation libs.androidx.activity.compose - implementation libs.androidx.navigation.compose - implementation libs.androidx.hilt.navigationCompose implementation libs.plrapps.mapcompose testImplementation libs.junit diff --git a/app/src/androidTest/java/ru/spbu/depnav/data/db/SearchHistoryDaoTest.kt b/app/src/androidTest/java/ru/spbu/depnav/data/db/SearchHistoryDaoTest.kt index bd3fcbf8..33f6067d 100644 --- a/app/src/androidTest/java/ru/spbu/depnav/data/db/SearchHistoryDaoTest.kt +++ b/app/src/androidTest/java/ru/spbu/depnav/data/db/SearchHistoryDaoTest.kt @@ -18,7 +18,6 @@ package ru.spbu.depnav.data.db -import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -61,7 +60,7 @@ class SearchHistoryDaoTest : AppDatabaseDaoTest() { searchHistoryDao.insertNotExceeding(SearchHistoryEntry(expectedId, 1L), 10) } - val actual = runBlocking { searchHistoryDao.loadByMap(expectedMap.name).first() } + val actual = runBlocking { searchHistoryDao.loadByMap(expectedMap.name) } assertTrue("Loaded entry list is empty", actual.isNotEmpty()) actual.forEach { assertEquals(expectedId, it.markerId) } @@ -78,7 +77,7 @@ class SearchHistoryDaoTest : AppDatabaseDaoTest() { expected.sortBy { it.timestamp } while (expected.size > maxEntriesNum) expected.removeFirst() - val actual = runBlocking { searchHistoryDao.loadByMap(INSERTED_MAP.name).first() } + val actual = runBlocking { searchHistoryDao.loadByMap(INSERTED_MAP.name) } assertEquals(expected.size, actual.size) for (entry in actual) { @@ -151,7 +150,7 @@ class SearchHistoryDaoTest : AppDatabaseDaoTest() { } } - val actual = runBlocking { searchHistoryDao.loadByMap(INSERTED_MAP.name).first() } + val actual = runBlocking { searchHistoryDao.loadByMap(INSERTED_MAP.name) } assertEquals(maxEntriesNum, actual.size) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c16ee0e3..3718c613 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,7 +10,7 @@ android:supportsRtl="true" android:theme="@style/Theme.DepNav"> diff --git a/app/src/main/java/ru/spbu/depnav/data/db/SearchHistoryDao.kt b/app/src/main/java/ru/spbu/depnav/data/db/SearchHistoryDao.kt index 02488a79..c1e67862 100644 --- a/app/src/main/java/ru/spbu/depnav/data/db/SearchHistoryDao.kt +++ b/app/src/main/java/ru/spbu/depnav/data/db/SearchHistoryDao.kt @@ -23,7 +23,6 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Transaction -import kotlinx.coroutines.flow.Flow import ru.spbu.depnav.data.model.SearchHistoryEntry /** DAO fot the table containing [marker search history entries][SearchHistoryEntry]. */ @@ -86,5 +85,5 @@ abstract class SearchHistoryDao { ORDER BY timestamp ASC """ ) - abstract fun loadByMap(mapName: String): Flow> + abstract suspend fun loadByMap(mapName: String): List } diff --git a/app/src/main/java/ru/spbu/depnav/data/repository/SearchHistoryRepo.kt b/app/src/main/java/ru/spbu/depnav/data/repository/SearchHistoryRepo.kt index 1ba34931..1a4ff55a 100644 --- a/app/src/main/java/ru/spbu/depnav/data/repository/SearchHistoryRepo.kt +++ b/app/src/main/java/ru/spbu/depnav/data/repository/SearchHistoryRepo.kt @@ -36,5 +36,5 @@ class SearchHistoryRepo @Inject constructor(private val dao: SearchHistoryDao) { dao.insertNotExceeding(entry, maxEntriesNum) /** Loads the current entries for the specified map sorted by timestamps (older first). */ - fun loadByMap(mapName: String) = dao.loadByMap(mapName) + suspend fun loadByMap(mapName: String) = dao.loadByMap(mapName) } diff --git a/app/src/main/java/ru/spbu/depnav/MainActivity.kt b/app/src/main/java/ru/spbu/depnav/ui/MainActivity.kt similarity index 55% rename from app/src/main/java/ru/spbu/depnav/MainActivity.kt rename to app/src/main/java/ru/spbu/depnav/ui/MainActivity.kt index 55e4200c..7e4334a9 100644 --- a/app/src/main/java/ru/spbu/depnav/MainActivity.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/MainActivity.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package ru.spbu.depnav +package ru.spbu.depnav.ui import android.graphics.Color import android.os.Bundle @@ -24,19 +24,10 @@ import androidx.activity.ComponentActivity import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.viewModelScope -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable -import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.launch -import ru.spbu.depnav.ui.NavDestination -import ru.spbu.depnav.ui.map.MapScreen -import ru.spbu.depnav.ui.map.MapScreenViewModel -import ru.spbu.depnav.ui.search.SearchScreen +import ru.spbu.depnav.ui.screen.MapScreen import ru.spbu.depnav.ui.theme.DepNavTheme import ru.spbu.depnav.utils.preferences.PreferencesManager import javax.inject.Inject @@ -44,8 +35,6 @@ import javax.inject.Inject @AndroidEntryPoint @Suppress("UndocumentedPublicClass") // Class name is self-explanatory class MainActivity : ComponentActivity() { - private val mapScreenVm: MapScreenViewModel by viewModels() - /** User preferences. */ @Inject lateinit var prefs: PreferencesManager @@ -66,33 +55,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge(systemBarStyle, systemBarStyle) } - DepNavTheme(darkTheme = darkTheme) { - val navController = rememberNavController() - - NavHost(navController = navController, startDestination = NavDestination.MAP.name) { - composable(NavDestination.MAP.name) { - MapScreen(vm = mapScreenVm) { - navController.navigate(NavDestination.SEARCH.name) - } - } - composable(NavDestination.SEARCH.name) { - fun navigateToMap() { - navController.popBackStack( - route = NavDestination.MAP.name, - inclusive = false - ) - } - - SearchScreen( - onResultClick = { - with(mapScreenVm) { viewModelScope.launch { focusOnMarker(it) } } - navigateToMap() - }, - onNavigateBack = ::navigateToMap - ) - } - } - } + DepNavTheme(darkTheme = darkTheme) { MapScreen() } } } } diff --git a/app/src/main/java/ru/spbu/depnav/ui/NavDestination.kt b/app/src/main/java/ru/spbu/depnav/ui/NavDestination.kt deleted file mode 100644 index 63088e08..00000000 --- a/app/src/main/java/ru/spbu/depnav/ui/NavDestination.kt +++ /dev/null @@ -1,22 +0,0 @@ -/** - * DepNav -- department navigator. - * Copyright (C) 2022 Timofei Pushkin - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package ru.spbu.depnav.ui - -/** Application's GUI screens as navigation destinations. */ -enum class NavDestination { MAP, SEARCH } diff --git a/app/src/main/java/ru/spbu/depnav/ui/map/FloorSwitch.kt b/app/src/main/java/ru/spbu/depnav/ui/component/FloorSwitch.kt similarity index 95% rename from app/src/main/java/ru/spbu/depnav/ui/map/FloorSwitch.kt rename to app/src/main/java/ru/spbu/depnav/ui/component/FloorSwitch.kt index 01331509..0fe87e1d 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/map/FloorSwitch.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/component/FloorSwitch.kt @@ -16,10 +16,9 @@ * along with this program. If not, see . */ -package ru.spbu.depnav.ui.map +package ru.spbu.depnav.ui.component import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.SizeTransform import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -43,11 +42,12 @@ import androidx.compose.ui.tooling.preview.Preview import ru.spbu.depnav.R import ru.spbu.depnav.ui.theme.DepNavTheme +private const val MIN_FLOOR = 1 + /** Two buttons to switch the current map one floor up or down. */ @Composable fun FloorSwitch( floor: Int, - minFloor: Int, maxFloor: Int, modifier: Modifier = Modifier, onClick: (new: Int) -> Unit @@ -85,7 +85,7 @@ fun FloorSwitch( IconButton( onClick = { onClick(floor - 1) }, - enabled = floor > minFloor + enabled = floor > MIN_FLOOR ) { Icon( Icons.Rounded.KeyboardArrowDown, @@ -103,7 +103,6 @@ private fun FloorSwitchPreview() { DepNavTheme { FloorSwitch( floor = 1, - minFloor = 1, maxFloor = 2, onClick = {} ) diff --git a/app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt b/app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt new file mode 100644 index 00000000..af8d1e6f --- /dev/null +++ b/app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt @@ -0,0 +1,205 @@ +/** + * DepNav -- department navigator. + * Copyright (C) 2022 Timofei Pushkin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package ru.spbu.depnav.ui.component + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.SearchBar +import androidx.compose.material3.Text +import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import ru.spbu.depnav.R +import ru.spbu.depnav.data.model.Marker +import ru.spbu.depnav.data.model.MarkerText +import ru.spbu.depnav.ui.theme.DEFAULT_PADDING + +// These are basically copied from SearchBar implementation +private val ACTIVATION_ENTER_SPEC = tween( + durationMillis = 600, + delayMillis = 100, + easing = CubicBezierEasing(0.05f, 0.7f, 0.1f, 1.0f) +) +private val ACTIVATION_EXIT_SPEC = tween( + durationMillis = 350, + delayMillis = 100, + easing = CubicBezierEasing(0.0f, 1.0f, 0.0f, 1.0f) +) + +@Composable +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +fun MapSearchBar( + query: String, + onQueryChange: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + results: Map, + onResultClick: (Int) -> Unit, + onInfoClick: () -> Unit, + onSettingsClick: () -> Unit, + modifier: Modifier = Modifier +) { + val activationAnimationProgress by animateFloatAsState( + targetValue = if (active) 1f else 0f, + animationSpec = if (active) ACTIVATION_ENTER_SPEC else ACTIVATION_EXIT_SPEC, + label = "Map search bar activation animation progress" + ) + + val (insetsStartPadding, insetsEndPadding) = with(WindowInsets.systemBars.asPaddingValues()) { + val layoutDirection = LocalLayoutDirection.current + calculateStartPadding(layoutDirection) to calculateEndPadding(layoutDirection) + } + + val outerStartPadding = insetsStartPadding * (1 - activationAnimationProgress) + val outerEndPadding = insetsEndPadding * (1 - activationAnimationProgress) + val innerStartPadding = insetsStartPadding * activationAnimationProgress + val innerEndPadding = insetsEndPadding * activationAnimationProgress + + SearchBar( + query = query, + onQueryChange = onQueryChange, + onSearch = { onActiveChange(false) }, + active = active, + onActiveChange = onActiveChange, + modifier = Modifier + .apply { + if (active) padding(start = outerStartPadding, end = outerEndPadding) + else windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + } + .then(modifier), + placeholder = { Text(stringResource(R.string.search_markers), maxLines = 1) }, + leadingIcon = { + AnimatedContent( + active, + modifier = Modifier.padding(start = innerStartPadding), + label = "Map search bar leading icon change" + ) { active -> + if (active) { + IconButton(onClick = { onActiveChange(false) }) { + Icon( + Icons.Rounded.ArrowBack, + contentDescription = stringResource(R.string.label_navigate_back) + ) + } + } else { + Icon( + Icons.Rounded.Search, + contentDescription = null, + // To have the same size as the back button above + modifier = Modifier.minimumInteractiveComponentSize() + ) + } + } + }, + trailingIcon = { + AnimatedContent( + active to query.isEmpty(), + modifier = Modifier.padding(end = innerEndPadding), + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "Map search bar trailing icon change" + ) { (active, emptyQuery) -> + if (active) { + if (emptyQuery) { + Spacer(modifier = Modifier.minimumInteractiveComponentSize()) + } else { + IconButton( + onClick = { onQueryChange("") } + ) { + Icon( + Icons.Rounded.Clear, + contentDescription = stringResource(R.string.label_clear_text_field) + ) + } + } + } else { + Row { + IconButton(onClick = onInfoClick) { + Icon( + Icons.Rounded.Info, + contentDescription = stringResource(R.string.label_open_map_info) + ) + } + + IconButton(onClick = onSettingsClick) { + Icon( + Icons.Rounded.Settings, + contentDescription = stringResource(R.string.label_open_settings) + ) + } + } + } + } + } + ) { + val keyboard = LocalSoftwareKeyboardController.current + // Without remember this may change quicker than the history itself arrives because VM + // debounces the queries + val isHistory = remember(results) { query.isEmpty() } + + SearchResults( + markersWithTexts = results, + isHistory = isHistory, + onScroll = { onTop -> keyboard?.apply { if (onTop) show() else hide() } }, + onResultClick = { + onActiveChange(false) + onResultClick(it) + }, + modifier = Modifier + .padding(horizontal = DEFAULT_PADDING * 1.5f) + .padding( + start = innerStartPadding, + end = innerEndPadding, + bottom = WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding() + ) + ) + } +} diff --git a/app/src/main/java/ru/spbu/depnav/ui/map/MarkerInfoLines.kt b/app/src/main/java/ru/spbu/depnav/ui/component/MarkerInfoLines.kt similarity index 99% rename from app/src/main/java/ru/spbu/depnav/ui/map/MarkerInfoLines.kt rename to app/src/main/java/ru/spbu/depnav/ui/component/MarkerInfoLines.kt index 34d04be9..22468ebb 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/map/MarkerInfoLines.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/component/MarkerInfoLines.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package ru.spbu.depnav.ui.map +package ru.spbu.depnav.ui.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column diff --git a/app/src/main/java/ru/spbu/depnav/ui/map/MarkerView.kt b/app/src/main/java/ru/spbu/depnav/ui/component/MarkerView.kt similarity index 99% rename from app/src/main/java/ru/spbu/depnav/ui/map/MarkerView.kt rename to app/src/main/java/ru/spbu/depnav/ui/component/MarkerView.kt index e2731090..4dafcb19 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/map/MarkerView.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/component/MarkerView.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package ru.spbu.depnav.ui.map +package ru.spbu.depnav.ui.component import androidx.compose.foundation.layout.size import androidx.compose.material3.Icon diff --git a/app/src/main/java/ru/spbu/depnav/ui/map/Pin.kt b/app/src/main/java/ru/spbu/depnav/ui/component/Pin.kt similarity index 97% rename from app/src/main/java/ru/spbu/depnav/ui/map/Pin.kt rename to app/src/main/java/ru/spbu/depnav/ui/component/Pin.kt index 74bddb02..ce10c087 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/map/Pin.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/component/Pin.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package ru.spbu.depnav.ui.map +package ru.spbu.depnav.ui.component import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size diff --git a/app/src/main/java/ru/spbu/depnav/ui/search/SearchResults.kt b/app/src/main/java/ru/spbu/depnav/ui/component/SearchResults.kt similarity index 97% rename from app/src/main/java/ru/spbu/depnav/ui/search/SearchResults.kt rename to app/src/main/java/ru/spbu/depnav/ui/component/SearchResults.kt index 7f0099d5..4670956b 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/search/SearchResults.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/component/SearchResults.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package ru.spbu.depnav.ui.search +package ru.spbu.depnav.ui.component import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -50,7 +50,6 @@ import kotlinx.coroutines.flow.onEach import ru.spbu.depnav.R import ru.spbu.depnav.data.model.Marker import ru.spbu.depnav.data.model.MarkerText -import ru.spbu.depnav.ui.map.MarkerView import ru.spbu.depnav.ui.theme.DEFAULT_PADDING import ru.spbu.depnav.ui.theme.DepNavTheme @@ -59,7 +58,7 @@ import ru.spbu.depnav.ui.theme.DepNavTheme fun SearchResults( markersWithTexts: Map, isHistory: Boolean, - onStateChange: (onTop: Boolean) -> Unit, + onScroll: (onTop: Boolean) -> Unit, onResultClick: (Int) -> Unit, modifier: Modifier = Modifier ) { @@ -101,7 +100,7 @@ fun SearchResults( snapshotFlow { state.run { firstVisibleItemIndex == 0 && firstVisibleItemScrollOffset == 0 } } - .onEach { onStateChange(it) } + .onEach { onScroll(it) } .launchIn(scope) } } diff --git a/app/src/main/java/ru/spbu/depnav/ui/map/ZoomInHint.kt b/app/src/main/java/ru/spbu/depnav/ui/component/ZoomInHint.kt similarity index 98% rename from app/src/main/java/ru/spbu/depnav/ui/map/ZoomInHint.kt rename to app/src/main/java/ru/spbu/depnav/ui/component/ZoomInHint.kt index 93b1a4bc..ec5ede20 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/map/ZoomInHint.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/component/ZoomInHint.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package ru.spbu.depnav.ui.map +package ru.spbu.depnav.ui.component import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Row diff --git a/app/src/main/java/ru/spbu/depnav/ui/map/MapLegendDialog.kt b/app/src/main/java/ru/spbu/depnav/ui/dialog/MapLegendDialog.kt similarity index 97% rename from app/src/main/java/ru/spbu/depnav/ui/map/MapLegendDialog.kt rename to app/src/main/java/ru/spbu/depnav/ui/dialog/MapLegendDialog.kt index 2e2b839c..5751b35b 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/map/MapLegendDialog.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/dialog/MapLegendDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package ru.spbu.depnav.ui.map +package ru.spbu.depnav.ui.dialog import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement @@ -34,6 +34,7 @@ import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.buildAnnotatedString import ru.spbu.depnav.R import ru.spbu.depnav.data.model.Marker +import ru.spbu.depnav.ui.component.MarkerView import ru.spbu.depnav.ui.theme.DEFAULT_PADDING /** Dialog with the map legend. **/ diff --git a/app/src/main/java/ru/spbu/depnav/ui/map/SettingsDialog.kt b/app/src/main/java/ru/spbu/depnav/ui/dialog/SettingsDialog.kt similarity index 99% rename from app/src/main/java/ru/spbu/depnav/ui/map/SettingsDialog.kt rename to app/src/main/java/ru/spbu/depnav/ui/dialog/SettingsDialog.kt index 1600d33b..ad4abcb6 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/map/SettingsDialog.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/dialog/SettingsDialog.kt @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -package ru.spbu.depnav.ui.map +package ru.spbu.depnav.ui.dialog import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement diff --git a/app/src/main/java/ru/spbu/depnav/ui/map/MapScreen.kt b/app/src/main/java/ru/spbu/depnav/ui/map/MapScreen.kt deleted file mode 100644 index 777424dc..00000000 --- a/app/src/main/java/ru/spbu/depnav/ui/map/MapScreen.kt +++ /dev/null @@ -1,242 +0,0 @@ -/** - * DepNav -- department navigator. - * Copyright (C) 2022 Timofei Pushkin - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package ru.spbu.depnav.ui.map - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutHorizontally -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.exclude -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.material3.LocalAbsoluteTonalElevation -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.launch -import ovh.plrapps.mapcompose.api.fullSize -import ovh.plrapps.mapcompose.ui.MapUI -import ru.spbu.depnav.R -import ru.spbu.depnav.data.model.Marker -import ru.spbu.depnav.data.model.MarkerText -import ru.spbu.depnav.ui.theme.DEFAULT_PADDING - -private const val MIN_FLOOR = 1 - -/** Screen containing a navigable map. */ -@Composable -fun MapScreen(vm: MapScreenViewModel = hiltViewModel(), onStartSearch: () -> Unit) { - val tileColor = MaterialTheme.colorScheme.outline - LaunchedEffect(tileColor) { vm.tileColor = tileColor } - - if (vm.mapState.fullSize == IntSize.Zero) { // Compose crashes when trying to display empty map - StubScreen() - return - } - - var openMapLegend by rememberSaveable { mutableStateOf(false) } - if (openMapLegend) { - MapLegendDialog(onDismiss = { openMapLegend = false }) - } - - var openSettings by rememberSaveable { mutableStateOf(false) } - if (openSettings) { - SettingsDialog( - prefs = vm.prefs, - onDismiss = { openSettings = false } - ) - } - - Surface(color = MaterialTheme.colorScheme.background) { - MapUI(state = vm.mapState) - - Box(modifier = Modifier.fillMaxSize()) { - CompositionLocalProvider(LocalAbsoluteTonalElevation provides 4.dp) { - TopUi( - visible = vm.showUI, - currentFloor = vm.currentFloor, - maxFloor = vm.floorsNum, - onOpenMapLegendClick = { openMapLegend = true }, - onOpenSettingsClick = { openSettings = true }, - onStartSearchClick = onStartSearch, - onSwitchFloorClick = { vm.viewModelScope.launch { vm.setFloor(it) } } - ) - - BottomUi( - markerInfoVisible = vm.showUI && vm.isMarkerPinned, - zoomInHintVisible = vm.showUI && !vm.areMarkersVisible && !vm.isMarkerPinned, - pinnedMarkerWithText = vm.pinnedMarkerWithText - ) - } - } - } -} - -@Composable -private fun StubScreen() { - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) -} - -@Composable -@Suppress("LongParameterList") // Considered ok for composables -private fun BoxScope.TopUi( - visible: Boolean, - currentFloor: Int, - maxFloor: Int, - onOpenMapLegendClick: () -> Unit, - onOpenSettingsClick: () -> Unit, - onStartSearchClick: () -> Unit, - onSwitchFloorClick: (Int) -> Unit -) { - val insetsNoBottom = WindowInsets.systemBars.run { exclude(only(WindowInsetsSides.Bottom)) } - - Column( - modifier = Modifier - .fillMaxWidth() - .align(Alignment.TopCenter) - .padding(top = DEFAULT_PADDING) - .windowInsetsPadding(insetsNoBottom), - verticalArrangement = Arrangement.spacedBy(DEFAULT_PADDING), - horizontalAlignment = Alignment.CenterHorizontally - ) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() - ) { - TopButton( - text = stringResource(R.string.search_markers), - onInfoClick = onOpenMapLegendClick, - onSettingsClick = onOpenSettingsClick, - onSurfaceClick = onStartSearchClick, - modifier = Modifier.fillMaxWidth(0.9f) - ) - } - - val horizontalOffset: (Int) -> Int = - if (LocalLayoutDirection.current == LayoutDirection.Ltr) { - { it } - } else { - { -it } - } - - AnimatedVisibility( - visible = visible, - modifier = Modifier.align(Alignment.End), - enter = slideInHorizontally(initialOffsetX = horizontalOffset) + fadeIn(), - exit = slideOutHorizontally(targetOffsetX = horizontalOffset) + fadeOut() - ) { - FloorSwitch( - floor = currentFloor, - minFloor = MIN_FLOOR, - maxFloor = maxFloor, - modifier = Modifier.padding(horizontal = DEFAULT_PADDING), - onClick = onSwitchFloorClick - ) - } - } -} - -@Composable -private fun BoxScope.BottomUi( - markerInfoVisible: Boolean, - zoomInHintVisible: Boolean, - pinnedMarkerWithText: Pair? -) { - val insetsNoTop = WindowInsets.systemBars.run { exclude(only(WindowInsetsSides.Top)) } - - // Not using insets here to let the Surface reside under bottom bar - AnimatedVisibility( - visible = markerInfoVisible, - modifier = Modifier.align(Alignment.BottomCenter), - enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), - exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large.copy( - bottomStart = CornerSize(0), - bottomEnd = CornerSize(0) - ) - ) { - pinnedMarkerWithText?.let { (marker, markerText) -> - MarkerInfoLines( - title = markerText.title ?: stringResource(R.string.no_title), - description = markerText.description, - isClosed = marker.isClosed, - modifier = Modifier - .padding(DEFAULT_PADDING) - .windowInsetsPadding(insetsNoTop) - ) { - MarkerView( - title = markerText.title ?: stringResource(R.string.no_title), - type = marker.type, - isClosed = marker.isClosed, - simplified = true - ) - } - } - } - } - - AnimatedVisibility( - visible = zoomInHintVisible && !markerInfoVisible, - modifier = Modifier - .align(Alignment.BottomCenter) - .windowInsetsPadding(insetsNoTop), - enter = fadeIn(), - exit = fadeOut() - ) { - ZoomInHint() - } -} diff --git a/app/src/main/java/ru/spbu/depnav/ui/map/MapScreenViewModel.kt b/app/src/main/java/ru/spbu/depnav/ui/map/MapScreenViewModel.kt deleted file mode 100644 index efbc580e..00000000 --- a/app/src/main/java/ru/spbu/depnav/ui/map/MapScreenViewModel.kt +++ /dev/null @@ -1,305 +0,0 @@ -/** - * DepNav -- department navigator. - * Copyright (C) 2022 Timofei Pushkin - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package ru.spbu.depnav.ui.map - -import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.graphicsLayer -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import ovh.plrapps.mapcompose.api.ExperimentalClusteringApi -import ovh.plrapps.mapcompose.api.addLayer -import ovh.plrapps.mapcompose.api.addLazyLoader -import ovh.plrapps.mapcompose.api.addMarker -import ovh.plrapps.mapcompose.api.centerOnMarker -import ovh.plrapps.mapcompose.api.disableRotation -import ovh.plrapps.mapcompose.api.enableRotation -import ovh.plrapps.mapcompose.api.maxScale -import ovh.plrapps.mapcompose.api.minScaleSnapshotFlow -import ovh.plrapps.mapcompose.api.onMarkerClick -import ovh.plrapps.mapcompose.api.onTap -import ovh.plrapps.mapcompose.api.removeAllLayers -import ovh.plrapps.mapcompose.api.removeAllMarkers -import ovh.plrapps.mapcompose.api.removeMarker -import ovh.plrapps.mapcompose.api.rotateTo -import ovh.plrapps.mapcompose.api.scale -import ovh.plrapps.mapcompose.api.setColorFilterProvider -import ovh.plrapps.mapcompose.api.setScrollOffsetRatio -import ovh.plrapps.mapcompose.api.shouldLoopScale -import ovh.plrapps.mapcompose.core.TileStreamProvider -import ovh.plrapps.mapcompose.ui.state.MapState -import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy -import ru.spbu.depnav.data.model.MapInfo -import ru.spbu.depnav.data.model.Marker -import ru.spbu.depnav.data.model.MarkerText -import ru.spbu.depnav.data.repository.MapInfoRepo -import ru.spbu.depnav.data.repository.MarkerWithTextRepo -import ru.spbu.depnav.ui.theme.DEFAULT_PADDING -import ru.spbu.depnav.utils.preferences.PreferencesManager -import ru.spbu.depnav.utils.tiles.Floor -import ru.spbu.depnav.utils.tiles.TileStreamProviderFactory -import javax.inject.Inject - -private const val TAG = "MapScreenViewModel" - -private const val LAZY_LOADER_ID = "main" - -private const val MIN_MARKER_VISIBILITY_SCALE = 0.2f -private const val MAX_MARKER_VISIBILITY_SCALE = 0.5f - -private const val PIN_ID = "Pin" // Real IDs are integers - -/** ViewModel for [MapScreen]. */ -@OptIn(ExperimentalClusteringApi::class) -@HiltViewModel -class MapScreenViewModel @Inject constructor( - private val tileStreamProviderFactory: TileStreamProviderFactory, - private val mapInfoRepo: MapInfoRepo, - private val markerWithTextRepo: MarkerWithTextRepo, - /** User preferences. */ - val prefs: PreferencesManager -) : ViewModel() { - /** State of the map currently displayed. */ - var mapState by mutableStateOf(MapState(0, 0, 0)) - private set - - private var floors = emptyMap() - - /** Number of floors the current map has. */ - var floorsNum by mutableIntStateOf(floors.size) - private set - - /** The floor currently displayed. */ - var currentFloor by mutableIntStateOf(0) - - /** Controls the color of map tiles. */ - var tileColor: Color = Color.Black - set(value) { - mapState.setColorFilterProvider { _, _, _ -> ColorFilter.tint(tileColor) } - field = value - } - - /** Whether any UI is displayed on top of the map. */ - var showUI by mutableStateOf(true) - private set - - /** Whether a marker is pinned. */ - // It is separate from pinnedMarker so that marker info stays visible during hiding animation - var isMarkerPinned by mutableStateOf(false) - - /** The marker currently pinned. */ - var pinnedMarkerWithText by mutableStateOf?>(null) - private set - - private val clickableMarkers = mutableMapOf>() - - /** Whether markers are visible. */ - val areMarkersVisible // Acts like MutableState because backed by markerAlpha - get() = markerAlpha > 0 - - private var minScaleCollectionJob: Job? = null - private var minMarkerVisScale = 0f - private val maxMarkerVisScale = mapState.maxScale.coerceAtMost(MAX_MARKER_VISIBILITY_SCALE) - private val markerAlpha // Acts like MutableState because backed by mapState.scale - get() = (mapState.scale - minMarkerVisScale) / (maxMarkerVisScale - minMarkerVisScale) - - init { - snapshotFlow { prefs.enableRotation } - .onEach { shouldEnable -> - mapState.apply { - if (shouldEnable) { - enableRotation() - } else { - disableRotation() - rotateTo(0f) - } - } - } - .launchIn(viewModelScope) - snapshotFlow { prefs.selectedMap } - .onEach { initMap(it.persistedName) } - .launchIn(viewModelScope) - } - - private suspend fun initMap(mapName: String) { - Log.i(TAG, "Initializing map $mapName") - - minScaleCollectionJob?.cancel("State changed") - mapState.shutdown() - - val mapInfo = withContext(Dispatchers.IO) { mapInfoRepo.loadByName(mapName) } - setMapParamsFrom(mapInfo) - - floors = with(tileStreamProviderFactory) { - List(mapInfo.floorsNum) { - val floorNum = it + 1 - val layers = listOf(makeTileStreamProvider(mapName, floorNum)) - val markers = withContext(Dispatchers.IO) { - async { markerWithTextRepo.loadByFloor(mapName, floorNum) } - } - floorNum to Floor(layers, markers) - }.toMap() - } - floorsNum = floors.size - val firstFloor = floors.keys.firstOrNull() ?: 0.also { Log.e(TAG, "No floors provided") } - setFloor(firstFloor) - } - - private fun setMapParamsFrom(mapInfo: MapInfo) { - mapState = MapState( - mapInfo.levelsNum, - mapInfo.floorWidth, - mapInfo.floorHeight, - mapInfo.tileSize - ) { scale(0f) }.apply { - setScrollOffsetRatio(0.5f, 0.5f) - setColorFilterProvider { _, _, _ -> ColorFilter.tint(tileColor) } - addLazyLoader(LAZY_LOADER_ID, padding = (DEFAULT_PADDING * 2)) - shouldLoopScale = true - - if (prefs.enableRotation) enableRotation() - - onTap { _, _ -> - if (isMarkerPinned) mapState.removeMarker(PIN_ID) else showUI = !showUI - isMarkerPinned = false - } - - onMarkerClick { id, _, _ -> - Log.d(TAG, "Received a click on marker $id") - clickableMarkers[id]?.let { (marker, markerText) -> pinMarker(marker, markerText) } - ?: Log.e(TAG, "Marker $id is not clickable") - } - } - - minScaleCollectionJob = mapState.minScaleSnapshotFlow() - .onEach { minMarkerVisScale = it.coerceAtLeast(MIN_MARKER_VISIBILITY_SCALE) } - .launchIn(viewModelScope) - } - - private fun pinMarker(marker: Marker, markerText: MarkerText) { - pinnedMarkerWithText = marker to markerText - showUI = true - isMarkerPinned = true - - mapState.removeMarker(PIN_ID) - mapState.addMarker( - id = PIN_ID, - x = marker.x, - y = marker.y, - zIndex = 1f, - clickable = false, - relativeOffset = Offset(-0.5f, -0.5f), - clipShape = null - ) { Pin() } - } - - /** Changes the current map floor. */ - suspend fun setFloor(floorNum: Int) { - val floor = floors[floorNum] - if (floor == null) { - Log.e(TAG, "Cannot switch to floor $floorNum which does not exist") - return - } - - Log.i(TAG, "Switching to floor $floorNum") - - currentFloor = floorNum - isMarkerPinned = false - - replaceLayersWith(floor.layers) - replaceMarkersWith(floor.markers.await()) - Log.d(TAG, "Switched to floor $floorNum") - } - - private fun replaceLayersWith(tileProviders: Iterable) { - Log.d(TAG, "Replacing layers...") - - mapState.removeAllLayers() - for (tileProvider in tileProviders) mapState.addLayer(tileProvider) - } - - private fun replaceMarkersWith(markersWithText: Map) { - Log.d(TAG, "Replacing markers...") - - clickableMarkers.clear() - mapState.removeAllMarkers() - - for ((marker, markerText) in markersWithText) placeMarker(marker, markerText) - } - - private fun placeMarker(marker: Marker, markerText: MarkerText) { - val isClickable = - !markerText.title.isNullOrBlank() || !markerText.description.isNullOrBlank() - - if (isClickable) { - if (clickableMarkers.containsKey(marker.idStr)) { - Log.e(TAG, "Adding a clickable marker ${marker.idStr} which is already added") - } - clickableMarkers[marker.idStr] = marker to markerText - } - - mapState.addMarker( - id = marker.idStr, - x = marker.x, - y = marker.y, - clickable = isClickable, - relativeOffset = Offset(-0.5f, -0.5f), - clipShape = null, - renderingStrategy = RenderingStrategy.LazyLoading(LAZY_LOADER_ID) - ) { - if (!areMarkersVisible) return@addMarker // Not to consume clicks - - MarkerView( - title = markerText.title ?: "", - type = marker.type, - isClosed = marker.isClosed, - modifier = Modifier.graphicsLayer(alpha = markerAlpha) - ) - } - } - - /** Centers on the specified marker and highlights it. */ - suspend fun focusOnMarker(markerId: Int) { - Log.d(TAG, "Focusing on marker with ID $markerId") - - val (marker, markerText) = markerWithTextRepo.loadById(markerId) - Log.d(TAG, "Focusing on marker $marker") - - setFloor(marker.floor) - pinMarker(marker, markerText) - viewModelScope.launch { mapState.centerOnMarker(marker.idStr, 1f) } - } -} diff --git a/app/src/main/java/ru/spbu/depnav/ui/map/TopButton.kt b/app/src/main/java/ru/spbu/depnav/ui/map/TopButton.kt deleted file mode 100644 index c85458b3..00000000 --- a/app/src/main/java/ru/spbu/depnav/ui/map/TopButton.kt +++ /dev/null @@ -1,116 +0,0 @@ -/** - * DepNav -- department navigator. - * Copyright (C) 2022 Timofei Pushkin - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package ru.spbu.depnav.ui.map - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.basicMarquee -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.Settings -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalViewConfiguration -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import ru.spbu.depnav.R -import ru.spbu.depnav.ui.theme.DepNavTheme - -/** Button with a search icon, text, and additional nested buttons. */ -@OptIn(ExperimentalFoundationApi::class) -@Composable -fun TopButton( - text: String, - onInfoClick: () -> Unit, - onSettingsClick: () -> Unit, - onSurfaceClick: () -> Unit, - modifier: Modifier = Modifier -) { - Surface( - onClick = onSurfaceClick, - modifier = modifier, - shape = CircleShape - ) { - Row(verticalAlignment = Alignment.CenterVertically) { - Box( // Aligns this Icon with the trailing IconButton - modifier = Modifier.size(LocalViewConfiguration.current.minimumTouchTargetSize), - contentAlignment = Alignment.Center - ) { - Icon( - Icons.Rounded.Search, - contentDescription = stringResource(R.string.label_search) - ) - } - - CompositionLocalProvider( - LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant - ) { - Text( - text = text, - modifier = Modifier - .weight(1f) - // TODO: fix this being applied in landscape mode even when there is enough - // space - .basicMarquee(), - maxLines = 1, - ) - } - - IconButton(onClick = onInfoClick) { - Icon( - Icons.Rounded.Info, - contentDescription = stringResource(R.string.label_open_map_info) - ) - } - - IconButton(onClick = onSettingsClick) { - Icon( - Icons.Rounded.Settings, - contentDescription = stringResource(R.string.label_open_settings) - ) - } - } - } -} - -@Preview -@Composable -@Suppress("UnusedPrivateMember") -private fun TopButtonPreview() { - DepNavTheme { - TopButton( - text = "Search markers", - onInfoClick = {}, - onSettingsClick = {}, - onSurfaceClick = {} - ) - } -} diff --git a/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt b/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt new file mode 100644 index 00000000..98e19b1f --- /dev/null +++ b/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt @@ -0,0 +1,280 @@ +/** + * DepNav -- department navigator. + * Copyright (C) 2022 Timofei Pushkin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package ru.spbu.depnav.ui.screen + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContentPadding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.material3.LocalAbsoluteTonalElevation +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import ovh.plrapps.mapcompose.ui.MapUI +import ru.spbu.depnav.R +import ru.spbu.depnav.data.model.Marker +import ru.spbu.depnav.data.model.MarkerText +import ru.spbu.depnav.ui.component.FloorSwitch +import ru.spbu.depnav.ui.component.MapSearchBar +import ru.spbu.depnav.ui.component.MarkerInfoLines +import ru.spbu.depnav.ui.component.MarkerView +import ru.spbu.depnav.ui.component.ZoomInHint +import ru.spbu.depnav.ui.dialog.MapLegendDialog +import ru.spbu.depnav.ui.dialog.SettingsDialog +import ru.spbu.depnav.ui.theme.DEFAULT_PADDING +import ru.spbu.depnav.ui.viewmodel.MapUiState +import ru.spbu.depnav.ui.viewmodel.MapViewModel +import ru.spbu.depnav.ui.viewmodel.SearchUiState + +/** Screen containing a navigable map. */ +@Composable +fun MapScreen(vm: MapViewModel = viewModel()) { + val uncheckedMapUiState by vm.mapUiState.collectAsStateWithLifecycle(MapUiState.Loading) + val mapUiState = uncheckedMapUiState // Direct usage won't allow auto-cast + if (mapUiState !is MapUiState.Ready) { + return + } + + val searchUiState by vm.searchUiState.collectAsStateWithLifecycle(SearchUiState()) + + val mapColor = MaterialTheme.colorScheme.outline + LaunchedEffect(mapColor) { vm.setMapColor(mapColor) } + + var openMapLegend by rememberSaveable { mutableStateOf(false) } + if (openMapLegend) { + MapLegendDialog(onDismiss = { openMapLegend = false }) + } + + var openSettings by rememberSaveable { mutableStateOf(false) } + if (openSettings) { + SettingsDialog(prefs = vm.prefs, onDismiss = { openSettings = false }) + } + + Surface(color = MaterialTheme.colorScheme.background) { + MapUI(state = mapUiState.mapState) + + Box(modifier = Modifier.fillMaxSize()) { + CompositionLocalProvider(LocalAbsoluteTonalElevation provides 4.dp) { + AnimatedSearchBar( + visible = mapUiState.showOnMapUi, + query = searchUiState.query, + onQueryChange = vm::searchForMarker, + searchResults = searchUiState.results, + onResultClick = { markerId -> + vm.focusOnMarker(markerId) + vm.addToMarkerSearchHistory(markerId) + }, + onInfoCLick = { openMapLegend = true }, + onSettingsClick = { openSettings = true } + ) + + AnimatedFloorSwitch( + visible = mapUiState.showOnMapUi, + currentFloor = mapUiState.currentFloor, + maxFloor = mapUiState.floorsNum, + onFloorSwitch = vm::setFloor + ) + + AnimatedBottom( + pinnedMarker = mapUiState.pinnedMarker, + showZoomInHint = !vm.markersVisible + ) + } + } + } +} + +@Composable +private fun BoxScope.AnimatedSearchBar( + visible: Boolean, + query: String, + onQueryChange: (String) -> Unit, + searchResults: Map, + onResultClick: (Int) -> Unit, + onInfoCLick: () -> Unit, + onSettingsClick: () -> Unit +) { + var searchBarActive by rememberSaveable { mutableStateOf(false) } + if (!visible) { + searchBarActive = false + } + + if (!searchBarActive && query.isNotEmpty()) { + onQueryChange("") + } + + AnimatedVisibility( + visible = visible, + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(1f), + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut() + ) { + val horizontalPadding by animateDpAsState( + if (searchBarActive) 0.dp else DEFAULT_PADDING, + label = "Map search bar horizontal padding" + ) + + MapSearchBar( + query = query, + onQueryChange = onQueryChange, + active = searchBarActive, + onActiveChange = { searchBarActive = it }, + results = searchResults, + onResultClick = onResultClick, + onInfoClick = onInfoCLick, + onSettingsClick = onSettingsClick, + modifier = Modifier.padding(horizontal = horizontalPadding) + ) + } +} + +@Composable +private fun BoxScope.AnimatedFloorSwitch( + visible: Boolean, + currentFloor: Int, + maxFloor: Int, + onFloorSwitch: (Int) -> Unit +) { + val horizontalOffset: (Int) -> Int = + if (LocalLayoutDirection.current == LayoutDirection.Ltr) { + { it } + } else { + { -it } + } + + AnimatedVisibility( + visible = visible, + modifier = Modifier + .align(Alignment.TopEnd) + .safeContentPadding() + .padding(top = 64.dp) // Estimated search bar height + .padding(DEFAULT_PADDING), + enter = slideInHorizontally(initialOffsetX = horizontalOffset) + fadeIn(), + exit = slideOutHorizontally(targetOffsetX = horizontalOffset) + fadeOut() + ) { + FloorSwitch( + floor = currentFloor, + maxFloor = maxFloor, + onClick = onFloorSwitch + ) + } +} + +@Composable +private fun BoxScope.AnimatedBottom( + pinnedMarker: Pair?, + showZoomInHint: Boolean +) { + val insetsNoTop = WindowInsets.systemBars.run { exclude(only(WindowInsetsSides.Top)) } + + // Not using insets here to let the Surface reside under bottom bar + AnimatedVisibility( + visible = pinnedMarker != null, + modifier = Modifier.align(Alignment.BottomCenter), + enter = slideInVertically(initialOffsetY = { it }) + fadeIn(), + exit = slideOutVertically(targetOffsetY = { it }) + fadeOut() + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large.copy( + bottomStart = CornerSize(0), + bottomEnd = CornerSize(0) + ) + ) { + // Have to remember the latest pinned marker to continue showing it while the exit + // animation is still in progress + var latestPinnedMarker by remember { mutableStateOf(pinnedMarker) } + if (pinnedMarker != null) { + latestPinnedMarker = pinnedMarker + } + + AnimatedContent( + targetState = latestPinnedMarker, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "Marker info lines change" + ) { markerWithText -> + val (marker, markerText) = markerWithText ?: return@AnimatedContent + + MarkerInfoLines( + title = markerText.title ?: stringResource(R.string.no_title), + description = markerText.description, + isClosed = marker.isClosed, + modifier = Modifier + .padding(DEFAULT_PADDING) + .windowInsetsPadding(insetsNoTop) + ) { + MarkerView( + title = markerText.title ?: stringResource(R.string.no_title), + type = marker.type, + isClosed = marker.isClosed, + simplified = true + ) + } + } + } + } + + AnimatedVisibility( + visible = showZoomInHint && pinnedMarker == null, + modifier = Modifier + .align(Alignment.BottomCenter) + .windowInsetsPadding(insetsNoTop), + enter = fadeIn(), + exit = fadeOut() + ) { + ZoomInHint() + } +} diff --git a/app/src/main/java/ru/spbu/depnav/ui/search/SearchField.kt b/app/src/main/java/ru/spbu/depnav/ui/search/SearchField.kt deleted file mode 100644 index 2973259a..00000000 --- a/app/src/main/java/ru/spbu/depnav/ui/search/SearchField.kt +++ /dev/null @@ -1,122 +0,0 @@ -/** - * DepNav -- department navigator. - * Copyright (C) 2022 Timofei Pushkin - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package ru.spbu.depnav.ui.search - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import ru.spbu.depnav.R - -/** Text field with a search icon. */ -@Composable -@OptIn(ExperimentalComposeUiApi::class) -fun SearchField( - onTextChange: (String) -> Unit, - onClear: () -> Unit, - onBackClick: () -> Unit, - modifier: Modifier = Modifier, - placeholder: String = "" -) { - val focusRequester = remember { FocusRequester() } - - Row(verticalAlignment = Alignment.CenterVertically) { - var text by rememberSaveable { mutableStateOf("") } - val keyboard = LocalSoftwareKeyboardController.current - - IconButton(onClick = onBackClick) { - Icon( - imageVector = Icons.Rounded.ArrowBack, - contentDescription = stringResource(R.string.label_navigate_back) - ) - } - - TextField( - value = text, - onValueChange = { - text = it - onTextChange(it) - }, - modifier = Modifier - .focusRequester(focusRequester) - .then(modifier), - placeholder = { Text(placeholder) }, - trailingIcon = { - AnimatedVisibility( - visible = text.isNotEmpty(), - enter = fadeIn(), - exit = fadeOut() - ) { - IconButton( - onClick = { - text = "" - onClear() - } - ) { - Icon( - Icons.Rounded.Close, - contentDescription = stringResource(R.string.label_clear_text_field) - ) - } - } - }, - singleLine = true, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), - keyboardActions = KeyboardActions { keyboard?.hide() }, - shape = RectangleShape, - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.background, - unfocusedContainerColor = MaterialTheme.colorScheme.background, - disabledContainerColor = MaterialTheme.colorScheme.background, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent - ) - ) - } - - LaunchedEffect(Unit) { focusRequester.requestFocus() } -} diff --git a/app/src/main/java/ru/spbu/depnav/ui/search/SearchScreen.kt b/app/src/main/java/ru/spbu/depnav/ui/search/SearchScreen.kt deleted file mode 100644 index a42abee1..00000000 --- a/app/src/main/java/ru/spbu/depnav/ui/search/SearchScreen.kt +++ /dev/null @@ -1,98 +0,0 @@ -/** - * DepNav -- department navigator. - * Copyright (C) 2022 Timofei Pushkin - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package ru.spbu.depnav.ui.search - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.material3.Divider -import androidx.compose.material3.LocalContentColor -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.res.stringResource -import androidx.hilt.navigation.compose.hiltViewModel -import ru.spbu.depnav.R -import ru.spbu.depnav.ui.theme.DEFAULT_PADDING - -/** Screen containing a marker search and the results found. */ -@Composable -@OptIn(ExperimentalComposeUiApi::class) -fun SearchScreen( - vm: SearchScreenViewModel = hiltViewModel(), - onResultClick: (Int) -> Unit, - onNavigateBack: () -> Unit -) { - Surface(modifier = Modifier.fillMaxSize()) { - Column( - modifier = Modifier.windowInsetsPadding(WindowInsets.systemBars), - horizontalAlignment = Alignment.CenterHorizontally - ) { - val fillMaxWidthModifier = Modifier.fillMaxWidth(0.9f) - - SearchField( - onTextChange = vm.queryTextFlow::tryEmit, - onClear = vm::clearMatches, - onBackClick = onNavigateBack, - modifier = fillMaxWidthModifier, - placeholder = stringResource(R.string.search_markers) - ) - - Divider( - modifier = fillMaxWidthModifier, - color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.2f) - ) - - if (vm.searchMatches.let { it == null || it.isNotEmpty() }) { - val keyboard = LocalSoftwareKeyboardController.current - - SearchResults( - markersWithTexts = vm.searchMatches ?: vm.searchHistory, - isHistory = vm.searchMatches == null, - onStateChange = { onTop -> keyboard?.apply { if (onTop) show() else hide() } }, - onResultClick = { markerId -> - vm.addToSearchHistory(markerId) - onResultClick(markerId) - }, - modifier = fillMaxWidthModifier - ) - } else { - CompositionLocalProvider( - LocalContentColor provides - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - ) { - Text( - text = stringResource(R.string.nothing_found), - modifier = Modifier.padding(DEFAULT_PADDING * 2) - ) - } - } - } - } -} diff --git a/app/src/main/java/ru/spbu/depnav/ui/search/SearchScreenViewModel.kt b/app/src/main/java/ru/spbu/depnav/ui/search/SearchScreenViewModel.kt deleted file mode 100644 index 0b0d8097..00000000 --- a/app/src/main/java/ru/spbu/depnav/ui/search/SearchScreenViewModel.kt +++ /dev/null @@ -1,114 +0,0 @@ -/** - * DepNav -- department navigator. - * Copyright (C) 2022 Timofei Pushkin - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package ru.spbu.depnav.ui.search - -import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.BufferOverflow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import ru.spbu.depnav.data.model.Marker -import ru.spbu.depnav.data.model.MarkerText -import ru.spbu.depnav.data.model.SearchHistoryEntry -import ru.spbu.depnav.data.repository.MarkerWithTextRepo -import ru.spbu.depnav.data.repository.SearchHistoryRepo -import ru.spbu.depnav.utils.preferences.PreferencesManager -import javax.inject.Inject - -private const val TAG = "MarkerSearchViewModel" - -private const val MIN_QUERY_PERIOD_MS = 300L -private const val SEARCH_HISTORY_SIZE = 10 - -/** View model for [SearchScreen]. */ -@HiltViewModel -@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) -class SearchScreenViewModel @Inject constructor( - private val markerWithTextRepo: MarkerWithTextRepo, - private val searchHistoryRepo: SearchHistoryRepo, - private val prefs: PreferencesManager -) : ViewModel() { - /** Current search query flow. Values emitted into it will be used in search. */ - val queryTextFlow = MutableSharedFlow( - extraBufferCapacity = 1, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - - /** - * Markers with their texts that were found by the search sorted by relevance. Null if no - * search was attempted. - */ - var searchMatches by mutableStateOf?>(null) - private set - - /** Markers with their texts that were searched in the past. */ - var searchHistory by mutableStateOf>(emptyMap()) - private set - - init { - queryTextFlow - .debounce(MIN_QUERY_PERIOD_MS) // Filter out queries that change too frequently - .distinctUntilChanged() // Filter out identical queries - .filterNotNull() - .mapLatest { if (it.isNotEmpty()) search(it) else clearMatches() } // Cancel unfinished - .launchIn(viewModelScope) - - searchHistoryRepo.loadByMap(prefs.selectedMap.persistedName) - .distinctUntilChanged() // Filter out identical search entity lists - .mapLatest { entries -> // Cancel previous if unfinished - searchHistory = entries.associate { markerWithTextRepo.loadById(it.markerId) } - } - .launchIn(viewModelScope) - } - - private suspend fun search(query: String) { - val matches = withContext(Dispatchers.IO) { - markerWithTextRepo.loadByQuery(prefs.selectedMap.persistedName, query) - } - Log.v(TAG, "Query '$query' has ${matches.size} matches") - searchMatches = matches - } - - /** Clear the search results by setting them to null. */ - fun clearMatches() { - queryTextFlow.tryEmit(null) // Update distinctUntilChanged state - searchMatches = null - } - - /** Add the provided marker ID to the search history. */ - fun addToSearchHistory(markerId: Int) { - viewModelScope.launch(Dispatchers.IO) { - searchHistoryRepo.insertNotExceeding(SearchHistoryEntry(markerId), SEARCH_HISTORY_SIZE) - } - } -} diff --git a/app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt b/app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt new file mode 100644 index 00000000..e308cd5f --- /dev/null +++ b/app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt @@ -0,0 +1,411 @@ +/** + * DepNav -- department navigator. + * Copyright (C) 2022 Timofei Pushkin + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package ru.spbu.depnav.ui.viewmodel + +import android.util.Log +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import ovh.plrapps.mapcompose.api.ExperimentalClusteringApi +import ovh.plrapps.mapcompose.api.addLayer +import ovh.plrapps.mapcompose.api.addLazyLoader +import ovh.plrapps.mapcompose.api.addMarker +import ovh.plrapps.mapcompose.api.centerOnMarker +import ovh.plrapps.mapcompose.api.disableRotation +import ovh.plrapps.mapcompose.api.enableRotation +import ovh.plrapps.mapcompose.api.maxScale +import ovh.plrapps.mapcompose.api.minScale +import ovh.plrapps.mapcompose.api.onMarkerClick +import ovh.plrapps.mapcompose.api.onTap +import ovh.plrapps.mapcompose.api.removeAllLayers +import ovh.plrapps.mapcompose.api.removeAllMarkers +import ovh.plrapps.mapcompose.api.removeMarker +import ovh.plrapps.mapcompose.api.rotateTo +import ovh.plrapps.mapcompose.api.scale +import ovh.plrapps.mapcompose.api.setColorFilterProvider +import ovh.plrapps.mapcompose.api.setScrollOffsetRatio +import ovh.plrapps.mapcompose.api.shouldLoopScale +import ovh.plrapps.mapcompose.ui.state.MapState +import ovh.plrapps.mapcompose.ui.state.markers.model.RenderingStrategy +import ru.spbu.depnav.data.model.Marker +import ru.spbu.depnav.data.model.MarkerText +import ru.spbu.depnav.data.model.SearchHistoryEntry +import ru.spbu.depnav.data.repository.MapInfoRepo +import ru.spbu.depnav.data.repository.MarkerWithTextRepo +import ru.spbu.depnav.data.repository.SearchHistoryRepo +import ru.spbu.depnav.ui.component.MarkerView +import ru.spbu.depnav.ui.component.Pin +import ru.spbu.depnav.ui.theme.DEFAULT_PADDING +import ru.spbu.depnav.utils.misc.updateIf +import ru.spbu.depnav.utils.preferences.PreferencesManager +import ru.spbu.depnav.utils.tiles.Floor +import ru.spbu.depnav.utils.tiles.TileStreamProviderFactory +import javax.inject.Inject + +private const val TAG = "MapScreenViewModel" + +private const val LAZY_LOADER_ID = "main" + +private const val MIN_MARKER_VISIBILITY_SCALE = 0.2f +private const val MAX_MARKER_VISIBILITY_SCALE = 0.5f + +private const val PIN_ID = "Pin" // Real IDs are integers + +private const val MIN_QUERY_PERIOD_MS = 300L +private const val SEARCH_HISTORY_SIZE = 10 + +/** ViewModel for [MapScreen][ru.spbu.depnav.ui.screen.MapScreen]. */ +@HiltViewModel +@OptIn(ExperimentalClusteringApi::class, ExperimentalCoroutinesApi::class, FlowPreview::class) +class MapViewModel @Inject constructor( + private val tileStreamProviderFactory: TileStreamProviderFactory, + private val mapInfoRepo: MapInfoRepo, + private val markerRepo: MarkerWithTextRepo, + private val searchHistoryRepo: SearchHistoryRepo, + /** User preferences. */ + val prefs: PreferencesManager +) : ViewModel() { + private val state = MutableStateFlow(MapViewModelState()) + + /** State of the main map UI. */ + val mapUiState = state.map { it.toMapUiState() } + + /** State of marker search UI. */ + val searchUiState = state.map { it.toSearchUiState() } + + private data class MapViewModelState( + val mapName: String? = null, + val mapState: MapState? = null, + val mapColor: Color = Color.Black, + val floors: Map = emptyMap(), + val currentFloorId: Int = 0, + val pinnedMarker: Pair? = null, + val showOnMapUi: Boolean = true, + val searchQuery: String = "", + val searchResults: Map = emptyMap() + ) { + fun toMapUiState(): MapUiState = when (mapState) { + null -> MapUiState.Loading + else -> MapUiState.Ready( + mapState = mapState, + floorsNum = floors.size, + currentFloor = currentFloorId, + pinnedMarker = pinnedMarker, + showOnMapUi = showOnMapUi + ) + } + + fun toSearchUiState() = SearchUiState(query = searchQuery, results = searchResults) + } + + // Not a part of state/mapUiState because can update very frequently + private val markerAlpha + get() = state.value.mapState?.run { + val minScale = minScale.coerceAtLeast(MIN_MARKER_VISIBILITY_SCALE) + val maxScale = maxScale.coerceAtMost(MAX_MARKER_VISIBILITY_SCALE) + (scale - minScale) / (maxScale - minScale) + } ?: 0f + + /** Whether markers are visible on the map. */ + val markersVisible + get() = markerAlpha > 0 + + init { + snapshotFlow { prefs.selectedMap } + .onEach { initMap(it.persistedName) } + .launchIn(viewModelScope) + + snapshotFlow { prefs.enableRotation } + .mapLatest { enableRotation -> + state.value.mapState?.apply { + if (enableRotation) { + enableRotation() + } else { + disableRotation() + rotateTo(0f) + } + } + } + .launchIn(viewModelScope) + + state + .distinctUntilChangedBy { it.mapColor } + .onEach { + state.value.mapState?.setColorFilterProvider { _, _, _ -> + ColorFilter.tint(it.mapColor) + } + } + .launchIn(viewModelScope) + + state + .debounce(MIN_QUERY_PERIOD_MS) + .filter { it.mapName != null } + .distinctUntilChangedBy { it.searchQuery } + .mapLatest { state -> + val mapName = checkNotNull(state.mapName) + val query = state.searchQuery + val results = if (query.isNotEmpty()) { + markerRepo.loadByQuery(mapName, query) + } else { + searchHistoryRepo.loadByMap(mapName).associate { + markerRepo.loadById(it.markerId) + } + } + Log.d(TAG, "Searched '$query' on map $mapName, got ${results.size} results") + mapName to results + } + .flowOn(Dispatchers.IO) + .onEach { (searchedMapName, results) -> + state.updateIf(condition = { it.mapName == searchedMapName }) { + it.copy(searchResults = results) + } + } + .launchIn(viewModelScope) + } + + private suspend fun initMap(mapName: String) { + Log.i(TAG, "Initializing map $mapName") + + val mapInfo = withContext(Dispatchers.IO) { mapInfoRepo.loadByName(mapName) } + + val mapState = with(mapInfo) { + MapState(levelsNum, floorWidth, floorHeight, tileSize) { scale(0f) } + }.apply { + setScrollOffsetRatio(0.5f, 0.5f) + setColorFilterProvider { _, _, _ -> ColorFilter.tint(state.value.mapColor) } + addLazyLoader(LAZY_LOADER_ID, padding = (DEFAULT_PADDING * 2)) + shouldLoopScale = true + + if (prefs.enableRotation) enableRotation() + + onTap { _, _ -> + state.updateIf(condition = { it.mapState == this }) { state -> + state.copy( + showOnMapUi = if (state.pinnedMarker != null) { + removeMarker(PIN_ID) + state.showOnMapUi + } else { + !state.showOnMapUi + }, + pinnedMarker = null + ) + } + } + + onMarkerClick { id, _, _ -> + val currentState = state.value + if (currentState.mapState != this) { + return@onMarkerClick + } + + val floor = checkNotNull(currentState.run { floors[currentFloorId] }) { + "Illegal current floor" + } + val (marker, markerText) = checkNotNull(floor.markers[id]) { + "Unknown marker $id clicked" + } + pinMarker(marker) + + state.compareAndSet( + currentState, + currentState.copy(pinnedMarker = marker to markerText, showOnMapUi = true) + ) + } + } + + val floors = with(tileStreamProviderFactory) { + List(mapInfo.floorsNum) { + val floorNum = it + 1 + val layers = listOf(makeTileStreamProvider(mapName, floorNum)) + val markers = withContext(Dispatchers.IO) { + markerRepo.loadByFloor(mapName, floorNum).entries + }.associate { (marker, markerText) -> marker.idStr to (marker to markerText) } + floorNum to Floor(layers, markers) + }.toMap() + } + + state.value = MapViewModelState( + mapName = mapName, + mapState = mapState, + floors = floors + ) + + setFloor(checkNotNull(floors.keys.firstOrNull()) { "Map has no floors" }) + } + + private fun MapState.pinMarker(marker: Marker) { + removeMarker(PIN_ID) + addMarker( + id = PIN_ID, + x = marker.x, + y = marker.y, + zIndex = 1f, + clickable = false, + relativeOffset = Offset(-0.5f, -0.5f), + clipShape = null + ) { Pin() } + } + + /** Sets the color of map tiles. */ + fun setMapColor(color: Color) { + state.update { it.copy(mapColor = color) } + } + + /** Sets the map floor to display. */ + fun setFloor(floorId: Int) { + val mapState = checkNotNull(state.value.mapState) { "No map to switch floors on" } + viewModelScope.launch { mapState.setFloor(floorId) } + } + + private val floorSwitchLock = Mutex() + + private suspend fun MapState.setFloor(floorId: Int) { + // No need to worry about floors changing concurrently since then the map state would also + // change and we check for that + val floor = state.value.floors[floorId] + if (floor == null) { + Log.w(TAG, "Tried switch to floor $floorId which does not exist") + return + } + + // Using a lock because otherwise if this method is called twice concurrently with the same + // parameter state.update {} will allow the same layers and markers to be added twice: CAS + // will succeed for the second update since the state will have the value it expects from + // the first update + floorSwitchLock.withLock(floorId) { + if (floorId == state.value.currentFloorId) { + return + } + Log.i(TAG, "Switching to floor $floorId") + + removeAllLayers() + removeAllMarkers() + + for (layer in floor.layers) { + addLayer(layer) + } + for ((marker, markerText) in floor.markers.values) { + addMarker(marker, markerText) + } + + state.updateIf(condition = { it.mapState == this@setFloor }) { + it.copy(currentFloorId = floorId, pinnedMarker = null) + } + + Log.d(TAG, "Switched to floor $floorId") + } + } + + private fun MapState.addMarker(marker: Marker, markerText: MarkerText) { + addMarker( + id = marker.idStr, + x = marker.x, + y = marker.y, + clickable = markerText.run { !title.isNullOrBlank() || !description.isNullOrBlank() }, + relativeOffset = Offset(-0.5f, -0.5f), + clipShape = null, + renderingStrategy = RenderingStrategy.LazyLoading(LAZY_LOADER_ID) + ) { + if (markerAlpha > 0f) { // Not to consume clicks + MarkerView( + title = markerText.title ?: "", + type = marker.type, + isClosed = marker.isClosed, + modifier = Modifier.alpha(markerAlpha) + ) + } + } + } + + /** Centers on the specified marker and pins it. */ + fun focusOnMarker(markerId: Int) { + Log.d(TAG, "Focusing on marker $markerId") + val mapState = checkNotNull(state.value.mapState) { "No map to do focusing on" } + viewModelScope.launch { + val (marker, markerText) = withContext(Dispatchers.IO) { markerRepo.loadById(markerId) } + mapState.setFloor(marker.floor) // Updates the state internally + state.updateIf( + condition = { it.mapState == mapState && it.currentFloorId == marker.floor } + ) { state -> + with(mapState) { + pinMarker(marker) + launch { centerOnMarker(marker.idStr, 1f) } + } + state.copy(pinnedMarker = marker to markerText, showOnMapUi = true) + } + } + } + + /** Runs marker search with the specified query. */ + fun searchForMarker(query: String) { + state.update { it.copy(searchQuery = query) } + } + + /** Adds the provided marker ID to the marker search history. */ + fun addToMarkerSearchHistory(markerId: Int) { + viewModelScope.launch(Dispatchers.IO) { + searchHistoryRepo.insertNotExceeding(SearchHistoryEntry(markerId), SEARCH_HISTORY_SIZE) + } + } +} + +sealed interface MapUiState { + data object Loading : MapUiState + + data class Ready( + /** State of the map. */ + val mapState: MapState, + /** Number of floors the current map has. */ + val floorsNum: Int, + /** The floor currently displayed. */ + val currentFloor: Int, + /** Latest pinned marker. */ + val pinnedMarker: Pair?, + /** Whether any UI is displayed above the map. */ + val showOnMapUi: Boolean + ) : MapUiState +} + +data class SearchUiState( + val query: String = "", + val results: Map = emptyMap() +) diff --git a/app/src/main/java/ru/spbu/depnav/utils/misc/StateFlowMutations.kt b/app/src/main/java/ru/spbu/depnav/utils/misc/StateFlowMutations.kt new file mode 100644 index 00000000..cf0b63af --- /dev/null +++ b/app/src/main/java/ru/spbu/depnav/utils/misc/StateFlowMutations.kt @@ -0,0 +1,23 @@ +package ru.spbu.depnav.utils.misc + +import kotlinx.coroutines.flow.MutableStateFlow + +/** + * Updates the [MutableStateFlow.value] atomically with [update] of the current value if [condition] + * evaluates to true for it. + * + * [update] and [condition] may be evaluated multiple times, if the value is being concurrently + * updated. + */ +inline fun MutableStateFlow.updateIf(condition: (T) -> Boolean, update: (T) -> T) { + while (true) { + val prevValue = value + if (!condition(prevValue)) { + return + } + val nextValue = update(prevValue) + if (compareAndSet(prevValue, nextValue)) { + return + } + } +} diff --git a/app/src/main/java/ru/spbu/depnav/utils/tiles/Floor.kt b/app/src/main/java/ru/spbu/depnav/utils/tiles/Floor.kt index 9852f072..7eb1e779 100644 --- a/app/src/main/java/ru/spbu/depnav/utils/tiles/Floor.kt +++ b/app/src/main/java/ru/spbu/depnav/utils/tiles/Floor.kt @@ -18,7 +18,6 @@ package ru.spbu.depnav.utils.tiles -import kotlinx.coroutines.Deferred import ovh.plrapps.mapcompose.core.TileStreamProvider import ru.spbu.depnav.data.model.Marker import ru.spbu.depnav.data.model.MarkerText @@ -27,6 +26,6 @@ import ru.spbu.depnav.data.model.MarkerText data class Floor( /** Layers of tiles that this floor consist of. */ val layers: Iterable, - /** Markers placed on this floor. */ - val markers: Deferred> + /** Markers placed on this floor, mapped by their IDs. */ + val markers: Map> ) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index cacb7263..63ea65c2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -46,7 +46,6 @@ Общий туалет Иное место Выбранное место - Искать Открыть информацию о карте Открыть настройки Вернуться назад diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0558b104..fc83a337 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -50,7 +50,6 @@ Unisex restroom Miscellaneous place Selected place - Search Open map info Open settings Navigate back diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2c299ca..cf7b3d82 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,6 +12,7 @@ room = "2.5.2" [libraries] androidx-lifecycle-runtimeKtx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } +androidx-lifecycle-runtimeCompose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" } androidx-lifecycle-viewmodelCompose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } @@ -21,12 +22,10 @@ androidx-room-compiler = { group = "androidx.room", name = "room-compiler", vers google-dagger-hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } google-dagger-hiltCompiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" } -androidx-compose-bom = "androidx.compose:compose-bom:2023.10.00" +androidx-compose-bom = "androidx.compose:compose-bom:2023.10.01" androidx-core-ktx = "androidx.core:core-ktx:1.12.0" androidx-activity-compose = "androidx.activity:activity-compose:1.8.0" -androidx-navigation-compose = "androidx.navigation:navigation-compose:2.7.4" -androidx-hilt-navigationCompose = "androidx.hilt:hilt-navigation-compose:1.0.0" plrapps-mapcompose = "ovh.plrapps:mapcompose:2.9.4" junit = "junit:junit:4.13.2" From 7bf5c4707cccd88c7737548ce2c8a066e7c35d58 Mon Sep 17 00:00:00 2001 From: TimPushkin Date: Fri, 27 Oct 2023 17:45:13 +0300 Subject: [PATCH 2/2] Fix code style issues --- .../spbu/depnav/ui/component/MapSearchBar.kt | 150 +++++++++++------- .../ru/spbu/depnav/ui/screen/MapScreen.kt | 1 + .../spbu/depnav/ui/viewmodel/MapViewModel.kt | 34 ++-- 3 files changed, 118 insertions(+), 67 deletions(-) diff --git a/app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt b/app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt index af8d1e6f..7f7b8ef1 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/component/MapSearchBar.kt @@ -73,7 +73,14 @@ private val ACTIVATION_EXIT_SPEC = tween( easing = CubicBezierEasing(0.0f, 1.0f, 0.0f, 1.0f) ) +/** + * Search bar for querying map markers on [ru.spbu.depnav.ui.screen.MapScreen]. + */ @Composable +@Suppress( + "LongMethod", // No point in further shrinking + "LongParameterList" // Considered OK for composables +) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) fun MapSearchBar( query: String, @@ -116,66 +123,19 @@ fun MapSearchBar( .then(modifier), placeholder = { Text(stringResource(R.string.search_markers), maxLines = 1) }, leadingIcon = { - AnimatedContent( - active, - modifier = Modifier.padding(start = innerStartPadding), - label = "Map search bar leading icon change" - ) { active -> - if (active) { - IconButton(onClick = { onActiveChange(false) }) { - Icon( - Icons.Rounded.ArrowBack, - contentDescription = stringResource(R.string.label_navigate_back) - ) - } - } else { - Icon( - Icons.Rounded.Search, - contentDescription = null, - // To have the same size as the back button above - modifier = Modifier.minimumInteractiveComponentSize() - ) - } + AnimatedLeadingIcon(active, modifier = Modifier.padding(start = innerStartPadding)) { + onActiveChange(false) } }, trailingIcon = { - AnimatedContent( - active to query.isEmpty(), - modifier = Modifier.padding(end = innerEndPadding), - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "Map search bar trailing icon change" - ) { (active, emptyQuery) -> - if (active) { - if (emptyQuery) { - Spacer(modifier = Modifier.minimumInteractiveComponentSize()) - } else { - IconButton( - onClick = { onQueryChange("") } - ) { - Icon( - Icons.Rounded.Clear, - contentDescription = stringResource(R.string.label_clear_text_field) - ) - } - } - } else { - Row { - IconButton(onClick = onInfoClick) { - Icon( - Icons.Rounded.Info, - contentDescription = stringResource(R.string.label_open_map_info) - ) - } - - IconButton(onClick = onSettingsClick) { - Icon( - Icons.Rounded.Settings, - contentDescription = stringResource(R.string.label_open_settings) - ) - } - } - } - } + AnimatedTrailingIcons( + active, + query.isEmpty(), + onClearClick = { onQueryChange("") }, + onInfoClick = onInfoClick, + onSettingsClick = onSettingsClick, + modifier = Modifier.padding(end = innerEndPadding) + ) } ) { val keyboard = LocalSoftwareKeyboardController.current @@ -203,3 +163,79 @@ fun MapSearchBar( ) } } + +@Composable +private fun AnimatedLeadingIcon( + searchBarActive: Boolean, + modifier: Modifier = Modifier, + onNavigateBackClick: () -> Unit +) { + AnimatedContent( + searchBarActive, + modifier = modifier, + label = "Map search bar leading icon change" + ) { showBackButton -> + if (showBackButton) { + IconButton(onClick = onNavigateBackClick) { + Icon( + Icons.Rounded.ArrowBack, + contentDescription = stringResource(R.string.label_navigate_back) + ) + } + } else { + Icon( + Icons.Rounded.Search, + contentDescription = null, + // To have the same size as the back button above + modifier = Modifier.minimumInteractiveComponentSize() + ) + } + } +} + +@Composable +@Suppress("LongParameterList") // Considered OK for composables +private fun AnimatedTrailingIcons( + searchBarActive: Boolean, + queryEmpty: Boolean, + onClearClick: () -> Unit, + onInfoClick: () -> Unit, + onSettingsClick: () -> Unit, + modifier: Modifier = Modifier +) { + AnimatedContent( + searchBarActive to queryEmpty, + modifier = modifier, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "Map search bar trailing icon change" + ) { (active, emptyQuery) -> + if (active) { + if (emptyQuery) { + Spacer(modifier = Modifier.minimumInteractiveComponentSize()) + } else { + IconButton(onClick = onClearClick) { + Icon( + Icons.Rounded.Clear, + contentDescription = stringResource(R.string.label_clear_text_field) + ) + } + } + } else { + Row { + IconButton(onClick = onInfoClick) { + Icon( + Icons.Rounded.Info, + contentDescription = stringResource(R.string.label_open_map_info) + ) + } + + IconButton(onClick = onSettingsClick) { + Icon( + Icons.Rounded.Settings, + contentDescription = stringResource(R.string.label_open_settings) + ) + } + } + } + } +} diff --git a/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt b/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt index 98e19b1f..a0b599ad 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/screen/MapScreen.kt @@ -136,6 +136,7 @@ fun MapScreen(vm: MapViewModel = viewModel()) { } @Composable +@Suppress("LongParameterList") // Considered OK for composables private fun BoxScope.AnimatedSearchBar( visible: Boolean, query: String, diff --git a/app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt b/app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt index e308cd5f..4ae5ee53 100644 --- a/app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt +++ b/app/src/main/java/ru/spbu/depnav/ui/viewmodel/MapViewModel.kt @@ -123,16 +123,18 @@ class MapViewModel @Inject constructor( val searchQuery: String = "", val searchResults: Map = emptyMap() ) { - fun toMapUiState(): MapUiState = when (mapState) { - null -> MapUiState.Loading - else -> MapUiState.Ready( - mapState = mapState, - floorsNum = floors.size, - currentFloor = currentFloorId, - pinnedMarker = pinnedMarker, - showOnMapUi = showOnMapUi - ) - } + fun toMapUiState() = + if (mapState == null) { + MapUiState.Loading + } else { + MapUiState.Ready( + mapState = mapState, + floorsNum = floors.size, + currentFloor = currentFloorId, + pinnedMarker = pinnedMarker, + showOnMapUi = showOnMapUi + ) + } fun toSearchUiState() = SearchUiState(query = searchQuery, results = searchResults) } @@ -388,9 +390,12 @@ class MapViewModel @Inject constructor( } } +/** Describes states of map UI. */ sealed interface MapUiState { + /** Map has not yet been loaded. */ data object Loading : MapUiState + /** Map has been loaded. */ data class Ready( /** State of the map. */ val mapState: MapState, @@ -405,7 +410,16 @@ sealed interface MapUiState { ) : MapUiState } +/** Map markers search UI state. */ data class SearchUiState( + /** Marker search query entered by the user. */ val query: String = "", + /** + * Either the actual results of the query or a history of previous searches if the query was + * empty. + * + * Note that these result mey correspond not to the current query, but to some query in the + * past. + * */ val results: Map = emptyMap() )