diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9cb9252 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 RouteMood + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5bff13f --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# RouteMood - Генератор маршрутов для Android + +[![License](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) + +## Содержание +- [Описание](#описание) +- [Возможности](#возможности) +- [Скриншоты](#скриншоты) +- [Технологии](#технологии) +- [Установка и запуск](#установка-и-запуск) +- [Лицензия](#лицензия) + +## Описание +Android-приложение для создания и оценки пеших маршрутов. Позволяет генерировать маршруты с помощью YandexGPT или создавать их вручную на карте. + +## Возможности +✔ Генерация маршрутов через YandexGPT +✔ Ручное создание маршрутов на карте +✔ Сохранение маршрутов локально (Room Database) +✔ Обмен маршрутами с другими пользователями +✔ Рейтинговая система (1-5 звёзд) +✔ Пользовательские аватары (Datastore + сервер) +✔ Воспроизведение видео при генерации маршрута + +## Скриншоты +| Экран входа | Настройки маршрута | Профиль | +|-------------|-------------------|---------| +| | | | + +| Создание маршрута | Просмотр маршрута | Рейтинг | +|-------------------|------------------|---------| +| | | | + +## Технологии +- Язык: Kotlin, Jetpack Compose +- Карты: Google Maps SDK +- Локальное хранилище: Room Database, DataStore +- Сетевое взаимодействие: Retrofit +- Генерация маршрутов: YandexGPT API +- Архитектура: MVVM + +## Установка и запуск +1. Установите [Android Studio](https://developer.android.com/studio) +2. Клонируйте репозиторий: + ```bash + git clone https://github.com/RouteMood/RouteMoodClient.git +3. Откройте проект в Android Studio + +4. Добавьте API ключи: + +Создайте файл local.default.properties в корне проекта +```properties +# Ключ для Google Maps SDK +MAPS_API_KEY=your_google_maps_key + +# Ключ для Google Directions API +MAPS_SERVER_API_KEY=your_directions_api_key +``` +# Лицензия +MIT License. Подробнее см. в файле LICENSE. + \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4608035..aa1343a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,7 +26,7 @@ android { defaultConfig { applicationId = "ru.hse.routemoodclient" - minSdk = 24 + minSdk = 26 targetSdk = 35 versionCode = 1 versionName = "1.0" @@ -61,6 +61,19 @@ android { } dependencies { + // Test + testImplementation("junit:junit:4.13.2") + androidTestImplementation("androidx.test.ext:junit:1.1.5") + androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") + androidTestImplementation("androidx.room:room-testing:2.6.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.1") + testImplementation("app.cash.turbine:turbine:1.0.0") + // Coil + implementation("io.coil-kt:coil-compose:2.4.0") + // DataStore + implementation("androidx.datastore:datastore-preferences:1.1.7") + // Glide + implementation ("com.github.bumptech.glide:compose:1.0.0-beta01") // Permissions implementation ("com.google.accompanist:accompanist-permissions:0.37.0") // Gson diff --git a/app/src/androidTest/java/ru/hse/routemoodclient/imageManagerTest/ImageManagerTest.kt b/app/src/androidTest/java/ru/hse/routemoodclient/imageManagerTest/ImageManagerTest.kt new file mode 100644 index 0000000..4983fd7 --- /dev/null +++ b/app/src/androidTest/java/ru/hse/routemoodclient/imageManagerTest/ImageManagerTest.kt @@ -0,0 +1,177 @@ +package ru.hse.routemoodclient.imageManagerTest + +import android.content.Context +import android.net.Uri +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStoreFile +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import ru.hse.routemoodclient.ui.ImageManager +import java.io.File +import java.util.UUID + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class ImageManagerTest { + private lateinit var imageManager: ImageManager + private lateinit var context: Context + private lateinit var dataStore: DataStore + private val testDataStoreName = "test_image_prefs_${System.currentTimeMillis()}" + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + + // Создаем уникальное имя для DataStore в каждом тесте + dataStore = PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile(testDataStoreName) } + ) + + // Очищаем данные перед каждым тестом + runBlocking { + dataStore.edit { it.clear() } + File(context.filesDir, ImageManager.IMAGE_DIR).deleteRecursively() + } + + imageManager = ImageManager(dataStore, context) + } + + @After + fun cleanup() { + runBlocking { + // Удаляем тестовый DataStore + File(context.filesDir, "datastore/$testDataStoreName.preferences_pb").delete() + File(context.filesDir, ImageManager.IMAGE_DIR).deleteRecursively() + } + } + + + @Test + fun addAndGetUuidUriPair() = runTest { + // Создаем тестовый файл изображения + val testFile = File(context.filesDir, "test_image.jpg") + testFile.createNewFile() + testFile.writeText("test image content") + val testUri = Uri.fromFile(testFile) + + val uuid = UUID.randomUUID() + val savedFile = imageManager.addUuidUriPair(uuid, testUri) + + // Проверяем блокирующую версию + val retrievedFile = imageManager.getFileForUuidBlocking(uuid) + assertEquals(savedFile.absolutePath, retrievedFile?.absolutePath) + assertTrue(retrievedFile?.exists() ?: false) + + // Проверяем Flow версию + val flowFile = imageManager.getFileForUuid(uuid).first() + assertEquals(savedFile.absolutePath, flowFile?.absolutePath) + } + + @Test + fun addAndGetUuidByteArrayPair() = runTest { + val uuid = UUID.randomUUID() + val testData = "test byte array content".toByteArray() + + val savedFile = imageManager.addUuidByteArrayPair(uuid, testData) + + // Проверяем содержимое файла + val fileContent = savedFile.readBytes() + assertTrue(fileContent.contentEquals(testData)) + + // Проверяем Uri + val uri = imageManager.getUriForUuidBlocking(uuid) + assertNotNull(uri) + } + + @Test + fun removeUuidUriPair() = runTest { + val uuid = UUID.randomUUID() + val testData = "test content".toByteArray() + val file = imageManager.addUuidByteArrayPair(uuid, testData) + + // Убеждаемся, что файл существует + assertTrue(file.exists()) + + // Удаляем + imageManager.removeUuidUriPair(uuid) + + // Проверяем, что файл удален + assertFalse(file.exists()) + + // Проверяем, что запись удалена из DataStore + val path = dataStore.data.map { it[imageManager.uuidKey(uuid)] }.first() + assertNull(path) + } + + @Test + fun getAllPairs() = runTest { + val uuid1 = UUID.randomUUID() + val uuid2 = UUID.randomUUID() + + imageManager.addUuidByteArrayPair(uuid1, "image1".toByteArray()) + imageManager.addUuidByteArrayPair(uuid2, "image2".toByteArray()) + + val allPairs = imageManager.getAllPairs().first() + + assertEquals(2, allPairs.size) + assertTrue(allPairs.containsKey(uuid1)) + assertTrue(allPairs.containsKey(uuid2)) + } + + @Test + fun getUriForUuid() = runTest { + val uuid = UUID.randomUUID() + val testData = "test uri content".toByteArray() + imageManager.addUuidByteArrayPair(uuid, testData) + + // Проверяем блокирующую версию + val blockingUri = imageManager.getUriForUuidBlocking(uuid) + assertNotNull(blockingUri) + + // Проверяем Flow версию + val flowUri = imageManager.getUriForUuid(uuid).first() + assertEquals(blockingUri, flowUri) + } + + @Test + fun fileNotExistsAfterRemoval() = runTest { + val uuid = UUID.randomUUID() + val file = imageManager.addUuidByteArrayPair(uuid, "test".toByteArray()) + + // Удаляем + imageManager.removeUuidUriPair(uuid) + + // Проверяем, что файл действительно удален + assertFalse(file.exists()) + } + + @Test + fun addInvalidUriThrowsException() = runTest { + val uuid = UUID.randomUUID() + val invalidUri = Uri.parse("content://invalid.uri") + + assertThrows(java.io.FileNotFoundException::class.java) { + runBlocking { + imageManager.addUuidUriPair(uuid, invalidUri) + } + } + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/ConvertersTest.kt b/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/ConvertersTest.kt new file mode 100644 index 0000000..a1c951a --- /dev/null +++ b/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/ConvertersTest.kt @@ -0,0 +1,43 @@ +package ru.hse.routemoodclient.routesstoragetests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.maps.model.LatLng +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertNotNull +import junit.framework.TestCase.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import ru.hse.routemoodclient.data.Converters + +@RunWith(AndroidJUnit4::class) +class ConvertersTest { + private val converters = Converters() + + @Test + fun testLatLngListConversion() { + val originalList = listOf( + LatLng(59.9386, 30.3141), + LatLng(55.7558, 37.6173) + ) + + val json = converters.fromLatLngList(originalList) + assertNotNull(json) + + val convertedList = converters.toLatLngList(json) + assertEquals(originalList, convertedList) + } + + @Test + fun testNullConversion() { + assertEquals("null", converters.fromLatLngList(null)) + assertNull(converters.toLatLngList(null)) + } + + @Test + fun testEmptyListConversion() { + val emptyList = emptyList() + val json = converters.fromLatLngList(emptyList) + val convertedList = converters.toLatLngList(json) + assertEquals(emptyList, convertedList) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/DataRepositoryTest.kt b/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/DataRepositoryTest.kt new file mode 100644 index 0000000..02e8656 --- /dev/null +++ b/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/DataRepositoryTest.kt @@ -0,0 +1,95 @@ +package ru.hse.routemoodclient.routesstoragetests + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.maps.model.LatLng +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertFalse +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import ru.hse.routemoodclient.data.DataRepository +import ru.hse.routemoodclient.data.RouteDao +import ru.hse.routemoodclient.data.RouteDatabase +import ru.hse.routemoodclient.data.RouteEntity +import ru.hse.routemoodclient.data.RouteUiState +import ru.hse.routemoodclient.data.UserState +import ru.hse.routemoodclient.data.toRouteEntity + +@ExperimentalCoroutinesApi +@RunWith(AndroidJUnit4::class) +class DataRepositoryTest { + private lateinit var repository: DataRepository + private lateinit var dao: RouteDao + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + val database = Room.inMemoryDatabaseBuilder( + context, + RouteDatabase::class.java + ).allowMainThreadQueries().build() + dao = database.routeDao() + repository = DataRepository(dao) + } + + @Test + fun insertAndGetRoute() = runTest { + val route = RouteEntity(name = "Repo Test", route = listOf(LatLng(1.0, 1.0))) + repository.insertRoute(route) + + val routes = repository.getRouteList().first() + assertEquals(1, routes.size) + assertEquals("Repo Test", routes[0].name) + } + + @Test + fun updateUserState() = runTest { + val newState = UserState(username = "newUser", login = "newLogin") + repository.updateUserState(newState) + + val currentState = repository.userState.value + assertEquals("newUser", currentState.username) + assertEquals("newLogin", currentState.login) + } + + @Test + fun updateRouteState() = runTest { + val newState = RouteUiState(name = "New Route", isStartSet = true) + repository.updateRouteState(newState) + + val currentState = repository.routeState.value + assertEquals("New Route", currentState.name) + assertTrue(currentState.isStartSet) + } + + @Test + fun loadingState() = runTest { + repository.setLoading(true) + assertTrue(repository.isLoading.value) + + repository.setLoading(false) + assertFalse(repository.isLoading.value) + } + + @Test + fun routeConversion() = runTest { + val uiState = RouteUiState( + name = "Test", + route = listOf(LatLng(1.0, 1.0), LatLng(2.0, 2.0)) + ) + val entity = uiState.toRouteEntity() + + repository.insertRoute(entity) + val loaded = repository.getRouteList().first()[0] + + assertEquals(uiState.name, loaded.name) + assertEquals(uiState.route, loaded.route) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/ExtensionFunctionsTest.kt b/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/ExtensionFunctionsTest.kt new file mode 100644 index 0000000..b12295b --- /dev/null +++ b/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/ExtensionFunctionsTest.kt @@ -0,0 +1,50 @@ +package ru.hse.routemoodclient.routesstoragetests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.maps.model.LatLng +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import ru.hse.routemoodclient.data.RouteEntity +import ru.hse.routemoodclient.data.RouteUiState +import ru.hse.routemoodclient.data.toRouteEntity +import ru.hse.routemoodclient.data.toRouteUiState + +@RunWith(AndroidJUnit4::class) +class ExtensionFunctionsTest { + @Test + fun testRouteUiStateToEntityConversion() { + val uiState = RouteUiState( + name = "Test Route", + routeRequest = "Custom request", + route = listOf(LatLng(1.0, 1.0), LatLng(2.0, 2.0)) + ) + + val entity = uiState.toRouteEntity() + + assertEquals(uiState.name, entity.name) + assertEquals(uiState.routeRequest, entity.routeRequest) + assertEquals(uiState.route, entity.route) + } + + @Test + fun testRouteEntityToUiStateConversion() { + val entity = RouteEntity( + id = 1, + name = "Test Entity", + routeRequest = "Entity request", + route = listOf(LatLng(1.0, 1.0), LatLng(2.0, 2.0)) + ) + + val uiState = entity.toRouteUiState() + + assertEquals(entity.name, uiState.name) + assertEquals(entity.routeRequest, uiState.routeRequest) + assertEquals(entity.route, uiState.route) + assertEquals(entity.route[0], uiState.start) + assertEquals(entity.route.last(), uiState.end) + assertTrue(uiState.isStartSet) + assertTrue(uiState.isEndSet) + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/RoutesDaoTest.kt b/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/RoutesDaoTest.kt new file mode 100644 index 0000000..646ee8a --- /dev/null +++ b/app/src/androidTest/java/ru/hse/routemoodclient/routesstoragetests/RoutesDaoTest.kt @@ -0,0 +1,83 @@ +package ru.hse.routemoodclient.routesstoragetests + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.gms.maps.model.LatLng +import junit.framework.TestCase.assertEquals +import junit.framework.TestCase.assertTrue +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import ru.hse.routemoodclient.data.RouteDao +import ru.hse.routemoodclient.data.RouteDatabase +import ru.hse.routemoodclient.data.RouteEntity + +@RunWith(AndroidJUnit4::class) +class RouteDaoTest { + private lateinit var database: RouteDatabase + private lateinit var dao: RouteDao + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + database = Room.inMemoryDatabaseBuilder( + context, + RouteDatabase::class.java + ).allowMainThreadQueries().build() + dao = database.routeDao() + } + + @After + fun closeDb() { + database.close() + } + + @Test + fun insertRouteAndGetById() = runTest { + val route = RouteEntity(name = "Test Route", route = listOf(LatLng(1.0, 1.0))) + dao.insert(route) + + val loaded = dao.getRouteById(1).first() + assertEquals(route.copy(id = 1), loaded) + } + + @Test + fun getAllRoutes() = runTest { + val route1 = RouteEntity(name = "Route 1", route = listOf(LatLng(1.0, 1.0))) + val route2 = RouteEntity(name = "Route 2", route = listOf(LatLng(2.0, 2.0))) + + dao.insert(route1) + dao.insert(route2) + + val routes = dao.getAllRoutes().first() + assertEquals(2, routes.size) + } + + @Test + fun deleteRoute() = runTest { + val route = RouteEntity(name = "To Delete", route = listOf(LatLng(1.0, 1.0))) + dao.insert(route) + + val inserted = dao.getRouteById(1).first() + dao.delete(inserted) + + val routes = dao.getAllRoutes().first() + assertTrue(routes.isEmpty()) + } + + @Test + fun nukeTable() = runTest { + val route = RouteEntity(name = "Test", route = listOf(LatLng(1.0, 1.0))) + dao.insert(route) + + dao.nukeTable() + + val routes = dao.getAllRoutes().first() + assertTrue(routes.isEmpty()) + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e2a70bc..caa7a2c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,8 +6,10 @@ - + + markerState.position }, - width = 10f + color = Color.Red, + width = 2f ) } Polyline( points = builtPositions, + color = Color.Blue, width = 10f ) } - Column { + Column ( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Bottom, + horizontalAlignment = AbsoluteAlignment.Right + ) { GreenButton( onClick = { if (markers.size > 1) { diff --git a/app/src/main/java/ru/hse/routemoodclient/map/ShowMap.kt b/app/src/main/java/ru/hse/routemoodclient/map/ShowMap.kt index aab5c05..ede8372 100644 --- a/app/src/main/java/ru/hse/routemoodclient/map/ShowMap.kt +++ b/app/src/main/java/ru/hse/routemoodclient/map/ShowMap.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.tooling.preview.Preview import androidx.lifecycle.viewmodel.compose.viewModel import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -118,6 +119,7 @@ fun ShowMap ( if (routeState.route.isNotEmpty()) { Polyline( points = routeState.route, + color = Color.Blue, width = 10f ) } diff --git a/app/src/main/java/ru/hse/routemoodclient/map/ShowNetRoute.kt b/app/src/main/java/ru/hse/routemoodclient/map/ShowNetRoute.kt new file mode 100644 index 0000000..515c2e0 --- /dev/null +++ b/app/src/main/java/ru/hse/routemoodclient/map/ShowNetRoute.kt @@ -0,0 +1,108 @@ +package ru.hse.routemoodclient.map + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.sp +import com.google.android.gms.maps.model.CameraPosition +import com.google.android.gms.maps.model.LatLng +import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapProperties +import com.google.maps.android.compose.MapUiSettings +import com.google.maps.android.compose.Marker +import com.google.maps.android.compose.Polyline +import com.google.maps.android.compose.rememberCameraPositionState + +@Composable +fun ShowNetRoute ( + route: List +) { + if (route.isEmpty()) { + Text( + text = "Route is empty!", + modifier = Modifier.fillMaxSize(), + fontSize = 24.sp, + fontWeight = FontWeight.Bold, + color = Color.Red, + textAlign = TextAlign.Center + ) + } else { + val startMarkerState = rememberUpdatedMarkerState(route.first()) + val endMarkerState = rememberUpdatedMarkerState(route.last()) + + val cameraPositionState = rememberCameraPositionState { + position = CameraPosition.fromLatLngZoom(route.first(), 10f) + } + + var mapProperties by remember { + mutableStateOf( + MapProperties( + isBuildingEnabled = true, + isIndoorEnabled = true + ) + ) + } + + var mapUiSettings by remember { mutableStateOf(MapUiSettings()) } + + LocationPermission( + { + mapProperties = mapProperties.copy( + isBuildingEnabled = true, + isIndoorEnabled = true, + isMyLocationEnabled = true + ) + }, + { + mapUiSettings = mapUiSettings.copy( + indoorLevelPickerEnabled = true, + mapToolbarEnabled = true, + myLocationButtonEnabled = true, + rotationGesturesEnabled = true + ) + } + ) + + GoogleMap( + modifier = Modifier.fillMaxSize(), + cameraPositionState = cameraPositionState, + properties = mapProperties, + uiSettings = mapUiSettings + ) { + Marker( + state = startMarkerState, + title = "Start" + ) + + Marker( + state = endMarkerState, + title = "End" + ) + + Polyline( + points = route, + color = Color.Blue, + width = 10f + ) + } + } +} + + +@Preview +@Composable +fun ShowNetRoutePreview() { + ShowNetRoute( + route = listOf() + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/hse/routemoodclient/navigation/Navigation.kt b/app/src/main/java/ru/hse/routemoodclient/navigation/Navigation.kt index 0e944b3..ab2634c 100644 --- a/app/src/main/java/ru/hse/routemoodclient/navigation/Navigation.kt +++ b/app/src/main/java/ru/hse/routemoodclient/navigation/Navigation.kt @@ -1,18 +1,13 @@ package ru.hse.routemoodclient.navigation import androidx.annotation.StringRes -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.compose.composable @@ -23,6 +18,7 @@ import com.google.android.gms.maps.model.LatLng import ru.hse.routemoodclient.R import ru.hse.routemoodclient.map.CreateUserRoute import ru.hse.routemoodclient.map.ShowMap +import ru.hse.routemoodclient.map.ShowNetRoute import ru.hse.routemoodclient.profile.ProfileSheet import ru.hse.routemoodclient.profile.PublishedRoutesScreen import ru.hse.routemoodclient.profile.RoutesListScreen @@ -48,6 +44,10 @@ enum class RouteMoodScreen(@StringRes val title: Int, val color: Color) { title = R.string.map_screen, color = LightGreen ), + ShowNetRoute( + title = R.string.net_route_screen, + color = LightGreen + ), SetStart( title = R.string.set_start_marker_screen, color = LightGreen @@ -170,8 +170,8 @@ fun RouteMoodApp( navController.navigate(RouteMoodScreen.SetUserRoute.name) }, onGenerateButtonClicked = { - //serverViewModel.askRoute() - serverViewModel.askFictiveRoute() + serverViewModel.askRoute() + //serverViewModel.askFictiveRoute() navController.navigate(RouteMoodScreen.LoadingScreen.name) }, onDiscardButtonClicked = { @@ -182,7 +182,10 @@ fun RouteMoodApp( composable(route = RouteMoodScreen.Network.name) { NetworkScreen( routeViewModel = routeViewModel, - serverViewModel = serverViewModel + serverViewModel = serverViewModel, + toRoutePreview = { + navController.navigate(RouteMoodScreen.ShowNetRoute.name) + } ) } composable(route = RouteMoodScreen.Map.name) { @@ -191,6 +194,10 @@ fun RouteMoodApp( onMapClick = {} ) } + composable(route = RouteMoodScreen.ShowNetRoute.name) { + val route = serverViewModel.previewRoute.collectAsState() + ShowNetRoute(route.value) + } composable(route = RouteMoodScreen.SetStart.name) { ShowMap( viewModel = serverViewModel, diff --git a/app/src/main/java/ru/hse/routemoodclient/profile/ProfileScreen.kt b/app/src/main/java/ru/hse/routemoodclient/profile/ProfileScreen.kt index b6b9644..8b958ab 100644 --- a/app/src/main/java/ru/hse/routemoodclient/profile/ProfileScreen.kt +++ b/app/src/main/java/ru/hse/routemoodclient/profile/ProfileScreen.kt @@ -1,20 +1,13 @@ package ru.hse.routemoodclient.profile -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -23,7 +16,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -49,6 +41,9 @@ fun ProfileScreen( .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { + item { + ProfileImagePainter(userState.profileImageId, serverViewModel) + } item { Text( text = userState.username, diff --git a/app/src/main/java/ru/hse/routemoodclient/profile/UserSettingsScreen.kt b/app/src/main/java/ru/hse/routemoodclient/profile/UserSettingsScreen.kt index b6433c2..d805e51 100644 --- a/app/src/main/java/ru/hse/routemoodclient/profile/UserSettingsScreen.kt +++ b/app/src/main/java/ru/hse/routemoodclient/profile/UserSettingsScreen.kt @@ -1,10 +1,34 @@ package ru.hse.routemoodclient.profile +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Build +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -15,8 +39,25 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import androidx.core.content.FileProvider +import coil.compose.rememberAsyncImagePainter +import coil.compose.rememberImagePainter +import ru.hse.routemoodclient.R +import ru.hse.routemoodclient.data.UserState import ru.hse.routemoodclient.ui.ServerViewModel import ru.hse.routemoodclient.ui.components.GreenButton +import ru.hse.routemoodclient.ui.theme.LightGreen +import java.io.File +import java.util.UUID @Composable fun UserSettingsScreen( @@ -25,38 +66,192 @@ fun UserSettingsScreen( val userState by serverViewModel.userState.collectAsState() var userLogin by remember { mutableStateOf(userState.username) } var userPassword by remember { mutableStateOf(userState.password) } - Column( + var isPasswordVisible by remember { mutableStateOf(false) } + + val images by serverViewModel.images.collectAsState() + val galleryLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent(), + onResult = { uri -> uri?.let { serverViewModel.saveProfileImage(it) } } + ) + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - OutlinedTextField( - value = userLogin, - onValueChange = { userLogin = it }, - label = { Text("Change username") } - ) - OutlinedTextField( - value = userPassword, - onValueChange = { userPassword = it }, - label = { Text("Change password") } - ) - Row { - Spacer(Modifier.weight(1F)) - GreenButton( - onClick = { - // TODO submit to network + item { + ProfileImagePainter(userState.profileImageId, serverViewModel) + } + if (images[userState.profileImageId] != null && !serverViewModel.isRefreshing) { + item { + Row ( + modifier = Modifier.padding(vertical = 16.dp) + ) { + Spacer(Modifier.weight(1F)) + Button( + onClick = { galleryLauncher.launch("image/*") }, + colors = ButtonDefaults.buttonColors( + containerColor = LightGreen, + contentColor = Color.White + ), + enabled = !serverViewModel.isRefreshing, + modifier = Modifier.weight(5F) + ) { + Text("Upload Photo") + } + Spacer(Modifier.weight(1F)) + Button( + onClick = { serverViewModel.deleteProfileImage() }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.error + ), + modifier = Modifier.weight(5F) + ) { + Text("Delete Photo") + } + Spacer(Modifier.weight(1F)) + } + } + } + else { + item { + Button( + onClick = { galleryLauncher.launch("image/*") }, + modifier = Modifier.padding(vertical = 8.dp), + enabled = !serverViewModel.isRefreshing + ) { + Text(if (serverViewModel.isRefreshing) "Loading..." else "Upload Photo") + } + } + } + item { + OutlinedTextField( + value = userLogin, + onValueChange = { userLogin = it }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + label = { + Text( + "Change username", + style = MaterialTheme.typography.bodyLarge + ) }, - buttonText = "Confirm", - modifier = Modifier.weight(5F) + leadingIcon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = Color.Black + ) + } ) - Spacer(Modifier.weight(1F)) - GreenButton( - onClick = { - userLogin = serverViewModel.userState.value.username - userPassword = serverViewModel.userState.value.password + } + item { + OutlinedTextField( + value = userPassword, + onValueChange = { userPassword = it }, + label = { + Text( + "Change password", + style = MaterialTheme.typography.bodyLarge + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + shape = RoundedCornerShape(12.dp), + leadingIcon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = Color.Black + ) }, - buttonText = "Cancel", - modifier = Modifier.weight(5F) + trailingIcon = { + IconButton( + onClick = { isPasswordVisible = !isPasswordVisible } + ) { + Icon( + imageVector = if (isPasswordVisible) Icons.Default.Search + else Icons.Default.Build, + contentDescription = if (isPasswordVisible) "Hide password" + else "Show password", + tint = Color.Black + ) + } + }, + visualTransformation = if (isPasswordVisible) VisualTransformation.None + else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + singleLine = true ) - Spacer(Modifier.weight(1F)) + } + item { + Row { + Spacer(Modifier.weight(1F)) + GreenButton( + onClick = { + // TODO submit to network + }, + buttonText = "Confirm", + modifier = Modifier.weight(5F) + ) + Spacer(Modifier.weight(1F)) + GreenButton( + onClick = { + userLogin = serverViewModel.userState.value.username + userPassword = serverViewModel.userState.value.password + }, + buttonText = "Cancel", + modifier = Modifier.weight(5F) + ) + Spacer(Modifier.weight(1F)) + } + } + } +} + +@Composable +fun ProfileImagePainter( + profileImageId: UUID?, + serverViewModel: ServerViewModel +) { + val images by serverViewModel.images.collectAsState() + if (profileImageId != null) { + serverViewModel.loadImageFromServer(profileImageId) + } + Box( + modifier = Modifier + .size(200.dp) + .fillMaxWidth() + .clip(shape = RoundedCornerShape(50.dp)), + contentAlignment = Alignment.Center + ) { + when { + serverViewModel.isRefreshing -> { + CircularProgressIndicator() + } + profileImageId != null && images[profileImageId] != null -> { + Image( + painter = rememberAsyncImagePainter( + model = images[profileImageId] + ), + contentDescription = "Profile Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } + else -> { + Image( + painter = painterResource(R.drawable.logo), + contentDescription = "Empty Profile", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/ru/hse/routemoodclient/screens/LoadingScreen.kt b/app/src/main/java/ru/hse/routemoodclient/screens/LoadingScreen.kt index 4894001..799bc3b 100644 --- a/app/src/main/java/ru/hse/routemoodclient/screens/LoadingScreen.kt +++ b/app/src/main/java/ru/hse/routemoodclient/screens/LoadingScreen.kt @@ -37,7 +37,7 @@ fun LoadingScreen( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val showSkipButton = remember { mutableStateOf(false) } - val videoUrl = "http://192.168.0.107:8080/api/ads/download/random.mp4" + val videoUrl = "http://158.160.135.170:8080/api/ads/download/random.mp4" val player = remember (videoUrl) { ExoPlayer.Builder(context).build().apply { diff --git a/app/src/main/java/ru/hse/routemoodclient/screens/NetworkScreen.kt b/app/src/main/java/ru/hse/routemoodclient/screens/NetworkScreen.kt index 6dd537a..49ad690 100644 --- a/app/src/main/java/ru/hse/routemoodclient/screens/NetworkScreen.kt +++ b/app/src/main/java/ru/hse/routemoodclient/screens/NetworkScreen.kt @@ -1,11 +1,9 @@ package ru.hse.routemoodclient.screens import androidx.compose.animation.animateColorAsState -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -19,67 +17,62 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Favorite -import androidx.compose.material.icons.filled.FavoriteBorder +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Star -import androidx.compose.material.icons.outlined.FavoriteBorder -import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults -import androidx.compose.material3.IconToggleButton -import androidx.compose.material3.LargeTopAppBar import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel +import kotlinx.coroutines.launch import ru.hse.routemoodclient.R -import ru.hse.routemoodclient.data.RouteEntity +import ru.hse.routemoodclient.profile.ProfileImagePainter import ru.hse.routemoodclient.ui.PublishedRoute import ru.hse.routemoodclient.ui.RouteViewModel import ru.hse.routemoodclient.ui.ServerViewModel -import ru.hse.routemoodclient.ui.theme.LightGreen @OptIn(ExperimentalMaterial3Api::class) @Composable fun NetworkScreen( routeViewModel: RouteViewModel, - serverViewModel: ServerViewModel + serverViewModel: ServerViewModel, + toRoutePreview: () -> Unit, ) { val globalRoutes by serverViewModel.networkRoutesState.collectAsState() + val userState by serverViewModel.userState.collectAsState() + val state = rememberPullToRefreshState() PullToRefreshBox( state = state, isRefreshing = serverViewModel.isRefreshing, onRefresh = { - serverViewModel.askListRoutes() + serverViewModel.askFirstPageRoutes() + // serverViewModel.askListRoutes() } ) { LazyColumn( @@ -99,30 +92,41 @@ fun NetworkScreen( item { Spacer(modifier = Modifier.height(35.dp)) } - itemsIndexed(globalRoutes) { id, route -> + itemsIndexed(globalRoutes) { _, route -> RouteCard( - index = id, routeEntity = route, routeViewModel = routeViewModel, serverViewModel = serverViewModel, + toRoutePreview = toRoutePreview, modifier = Modifier .padding(horizontal = 16.dp) ) } + item { + Button( + onClick = { + serverViewModel.askNextPageRoutes() + } + ) { + Text("Load next") + } + } item { Spacer(modifier = Modifier.height(80.dp)) } } } + + } @OptIn(ExperimentalFoundationApi::class) @Composable fun RouteCard( - index: Int, routeEntity: PublishedRoute, routeViewModel: RouteViewModel, serverViewModel: ServerViewModel, + toRoutePreview: () -> Unit, modifier: Modifier = Modifier ) { Card( @@ -131,7 +135,12 @@ fun RouteCard( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh ), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - modifier = modifier.fillMaxWidth() + modifier = modifier + .fillMaxWidth() + .clickable { + serverViewModel.setPreviewRoute(routeEntity.route) + toRoutePreview() + } ) { Column( modifier = Modifier.padding(16.dp) @@ -142,17 +151,16 @@ fun RouteCard( ) { Box( modifier = Modifier - .size(40.dp) + .size(60.dp) .background( color = MaterialTheme.colorScheme.primaryContainer, shape = CircleShape ), contentAlignment = Alignment.Center ) { - Text( - text = "${index + 1}", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer + ProfileImagePainter( + routeEntity.profileId, + serverViewModel ) } @@ -190,6 +198,7 @@ fun RouteCard( RatingBar( globalRating = routeEntity.rating, + userRate = routeEntity.userRating, onRatingChanged = { newRating -> serverViewModel.askAddRate( routeId = routeEntity.id, @@ -214,12 +223,11 @@ fun RouteCard( @Composable fun RatingBar( globalRating: Double, - maxRating: Int = 5, + userRate: Int, onRatingChanged: (Int) -> Unit, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + maxRating: Int = 5 ) { - var userRating by remember { mutableIntStateOf(0) } - Row( verticalAlignment = Alignment.CenterVertically, modifier = modifier @@ -227,7 +235,7 @@ fun RatingBar( for (i in 1..maxRating) { val starColor by animateColorAsState( when { - i <= userRating -> MaterialTheme.colorScheme.primary + i <= userRate -> MaterialTheme.colorScheme.primary else -> MaterialTheme.colorScheme.outline }, label = "starColor" @@ -235,7 +243,6 @@ fun RatingBar( IconButton( onClick = { - userRating = i onRatingChanged(i) }, modifier = Modifier.size(32.dp), @@ -269,99 +276,3 @@ fun RatingBar( } } -/* -@Composable -fun RouteCard( - index: Int, - routeEntity: PublishedRoute, - serverViewModel: ServerViewModel -) { - Card( - shape = RoundedCornerShape(15.dp), - colors = CardDefaults.elevatedCardColors( - containerColor = LightGreen, - ), - elevation = CardDefaults.elevatedCardElevation(defaultElevation = 10.dp), - modifier = Modifier.padding(8.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(10.dp) - ) { - Spacer(Modifier.width(10.dp)) - Column(modifier = Modifier.weight(5f)) { - Text( - text = "${routeEntity.name}:", - fontSize = 18.sp - ) - RatingBar( - globalRating = routeEntity.rating, - onRatingChanged = { newRating -> - serverViewModel.askAddRate( - routeId = routeEntity.id, - rating = newRating - ) - } - ) - Text( - text = routeEntity.description, - fontSize = 20.sp - ) - } - IconButton( - onClick = { - - } - ) { - Icon( - imageVector = Icons.Default.FavoriteBorder, - contentDescription = "save icon", - modifier = Modifier.weight(1f) - ) - } - } - } -} - -@Composable -fun RatingBar( - globalRating: Double, - maxRating: Int = 5, - onRatingChanged: (Int) -> Unit -) { - var userRating by remember { mutableIntStateOf(0) } - Row( - verticalAlignment = Alignment.CenterVertically - ) { - for (i in 1..maxRating) { - IconButton( - onClick = { - onRatingChanged(i) - userRating = i - }, - modifier = Modifier.size(25.dp) - ) { - if (i <= userRating) { - Icon( - imageVector = Icons.Default.Star, - contentDescription = "filled star", - modifier = Modifier.size(25.dp), - tint = Color.Yellow - ) - } else { - Icon( - imageVector = Icons.Default.Star, - contentDescription = "filled star", - modifier = Modifier.size(25.dp), - tint = Color.Gray - ) - } - } - } - Text( - text = "Total: ${globalRating}", - fontSize = 20.sp - ) - } -} -*/ diff --git a/app/src/main/java/ru/hse/routemoodclient/ui/ImageStorageManager.kt b/app/src/main/java/ru/hse/routemoodclient/ui/ImageStorageManager.kt new file mode 100644 index 0000000..110968e --- /dev/null +++ b/app/src/main/java/ru/hse/routemoodclient/ui/ImageStorageManager.kt @@ -0,0 +1,146 @@ +package ru.hse.routemoodclient.ui + +import android.content.Context +import android.net.Uri +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import androidx.datastore.preferences.preferencesDataStoreFile +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import java.io.File +import java.util.UUID +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton + +@Singleton +class ImageManager @Inject constructor( + @Named("imagePrefs") private val dataStore: DataStore, + @ApplicationContext private val context: Context +) { + companion object { + const val IMAGE_DIR = "user_images" + } + + private val imageDir by lazy { + File(context.filesDir, IMAGE_DIR).apply { + if (!exists()) mkdirs() + } + } + + fun uuidKey(uuid: UUID) = stringPreferencesKey("image_${uuid}") + + // Добавление/обновление пары UUID -> Uri и сохранение файла + suspend fun addUuidUriPair(uuid: UUID, uri: Uri): File { + val file = createImageFile(uuid) + context.contentResolver.openInputStream(uri)?.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } ?: throw IllegalStateException("Could not open input stream for URI: $uri") + + dataStore.edit { preferences -> + preferences[uuidKey(uuid)] = file.absolutePath + } + + return file + } + + suspend fun addUuidByteArrayPair(uuid: UUID, byteArray: ByteArray): File { + val file = createImageFile(uuid) + + // Write the byte array directly to the file + file.outputStream().use { output -> + output.write(byteArray) + } + + // Save the file path in DataStore + dataStore.edit { preferences -> + preferences[uuidKey(uuid)] = file.absolutePath + } + + return file + } + + // Синхронное получение File по UUID (блокирующий вызов) + fun getFileForUuidBlocking(uuid: UUID): File? { + return runBlocking { + dataStore.data + .map { preferences -> preferences[uuidKey(uuid)] } + .first() + ?.let { File(it) } + ?.takeIf { it.exists() } + } + } + + // Синхронное получение Uri по UUID (блокирующий вызов) + fun getUriForUuidBlocking(uuid: UUID): Uri? { + return getFileForUuidBlocking(uuid)?.toUri() + } + + // Получение File по UUID + fun getFileForUuid(uuid: UUID): Flow = dataStore.data + .map { preferences -> + preferences[uuidKey(uuid)]?.let { File(it) } + } + + // Получение Uri по UUID + fun getUriForUuid(uuid: UUID): Flow = getFileForUuid(uuid) + .map { it?.toUri() } + + // Удаление пары и файла + suspend fun removeUuidUriPair(uuid: UUID) { + val filePath = dataStore.data + .map { preferences -> preferences[uuidKey(uuid)] } + .first() + + dataStore.edit { preferences -> + preferences.remove(uuidKey(uuid)) + } + + filePath?.let { File(it).delete() } + } + + // Получение всех пар + fun getAllPairs(): Flow> = dataStore.data + .map { preferences -> + preferences.asMap() + .filterKeys { it.name.startsWith("image_") } + .mapKeys { UUID.fromString(it.key.name.removePrefix("image_")) } + .mapValues { File(it.value.toString()) } + .filterValues { it.exists() } + } + + private fun createImageFile(uuid: UUID): File { + return File(imageDir, "${uuid}.jpg") + } + + private fun File.toUri(): Uri { + return Uri.fromFile(this) + } +} + +@Module +@InstallIn(SingletonComponent::class) +object DataStoreModule { + @Provides + @Named("imagePrefs") + fun provideImagePreferencesDataStore(@ApplicationContext context: Context): DataStore { + return PreferenceDataStoreFactory.create( + produceFile = { context.preferencesDataStoreFile("image_prefs") } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/hse/routemoodclient/ui/ServerViewModel.kt b/app/src/main/java/ru/hse/routemoodclient/ui/ServerViewModel.kt index 74b1d77..2ff3a14 100644 --- a/app/src/main/java/ru/hse/routemoodclient/ui/ServerViewModel.kt +++ b/app/src/main/java/ru/hse/routemoodclient/ui/ServerViewModel.kt @@ -1,33 +1,52 @@ package ru.hse.routemoodclient.ui import android.content.Context +import android.net.Uri import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.net.toUri import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel import com.google.android.gms.maps.model.LatLng import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import okhttp3.MediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody import ru.hse.routemood.ApiCallback import ru.hse.routemood.Controller import ru.hse.routemood.dto.AuthRequest import ru.hse.routemood.dto.AuthResponse import ru.hse.routemood.dto.GptRequest +import ru.hse.routemood.dto.ImageLoadResponse +import ru.hse.routemood.dto.ImageSaveResponse +import ru.hse.routemood.dto.PageResponse import ru.hse.routemood.dto.RateRequest import ru.hse.routemood.dto.RatingRequest import ru.hse.routemood.dto.RatingResponse import ru.hse.routemood.dto.RegisterRequest +import ru.hse.routemood.dto.UserResponse import ru.hse.routemood.models.Route import ru.hse.routemood.models.Route.RouteItem import ru.hse.routemoodclient.data.DataRepository import ru.hse.routemoodclient.data.RouteUiState import ru.hse.routemoodclient.data.UserState -import java.util.UUID -import javax.inject.Inject import java.io.File import java.io.FileOutputStream +import java.util.UUID +import javax.inject.Inject sealed interface UserUiState { data class Success(val userState: StateFlow) : UserUiState @@ -40,6 +59,8 @@ data class PublishedRoute ( val name: String = "default route", val description: String = "default", val rating: Double = 0.0, + val userRating: Int = 0, + val profileId: UUID? = null, val authorUsername: String = "", val route: List = listOf() ) @@ -50,8 +71,15 @@ data class PublishedRoute ( @HiltViewModel class ServerViewModel @Inject constructor( private val dataRepository: DataRepository, + private val imageManager: ImageManager, ) : ViewModel() { private var controller = Controller() + private var pageToken : String? = null + /** + * Operation's state + */ + var isRefreshing by mutableStateOf(false) + private set fun copyAssetToFile(context: Context, assetFileName: String): File { val file = File(context.cacheDir, assetFileName) @@ -65,6 +93,256 @@ class ServerViewModel @Inject constructor( return file } + /** + * User's image URI state + */ + private val _images = MutableStateFlow>(emptyMap()) + val images: StateFlow> = _images.asStateFlow() + + init { + loadAllImages() + } + + /** + * Update user's Ui image + */ + private fun loadAllImages() { + viewModelScope.launch { + imageManager.getAllPairs().collect { images -> + _images.value = images.mapValues { + it.value.toUri() + } + } + } + } + + /** + * Takes image's [uri], uploads it on the server, + * saves new uuid to user state + */ + fun saveProfileImage(uri: Uri) { + isRefreshing = true + try { + updateAvatar(uri) { uuid -> + val updatedUserState = userState.value.copy(profileImageId = uuid) + dataRepository.updateUserState(updatedUserState) + viewModelScope.launch { + imageManager.addUuidUriPair(uuid, uri) + } + _images.value += (uuid to uri) + } + + } catch (e: Exception) { + println(e.message) + // TODO + } finally { + isRefreshing = false + } + } + + /** + * Delete image by UUID + */ + fun deleteProfileImage() { + isRefreshing = true + try { + val uuid = userState.value.profileImageId!! + deleteImageFromServer(uuid) + viewModelScope.launch { + imageManager.removeUuidUriPair(uuid) + } + _images.value -= uuid + } catch (e: Exception) { + println(e.message) + // TODO + } finally { + isRefreshing = false + } + + } + + private fun updateAvatar(uri: Uri, handler: (UUID) -> Unit) { + dataRepository.setLoading(true); + val getAvatarResponse = object : ApiCallback { + override fun onSuccess(result: UUID?) { + if (result != null) { + handler(result) + } + } + + override fun onError(error: String) { + System.err.println(error) + } + } + val getResponse = object : ApiCallback { + override fun onSuccess(result: AuthResponse?) { + val file = runBlocking { + imageManager.addUuidUriPair(UUID(0,0), uri) + } + + val requestFile: RequestBody = file.asRequestBody("image/jpeg".toMediaTypeOrNull()) + + val filePart = MultipartBody.Part.createFormData( + "file", + file.name, + requestFile + ) + + try { + controller.updateAvatar( + filePart, + getAvatarResponse + ) + } catch (ex: Exception) { + // TODO + } + dataRepository.setLoading(false); + } + + override fun onError(error: String) { + System.err.println(error) + dataRepository.setLoading(false); + } + } + + try { + controller.loginUser( + AuthRequest(userState.value.username, userState.value.password), + getResponse + ) + } catch (ex: Exception) { + // TODO + } + } + + fun loadImageFromServer(uuid: UUID) { + if (images.value.containsKey(uuid)) { + return + } + val imageLoadResponse = object : ApiCallback { + override fun onSuccess(result: ImageLoadResponse?) { + if (result != null) { + val file = runBlocking { + imageManager.addUuidByteArrayPair(uuid, result.fileData) + } + _images.value += (uuid to file.toUri()) + } + } + override fun onError(error: String) { + System.err.println(error) + } + } + val saveResponse = object : ApiCallback { + override fun onSuccess(result: AuthResponse?) { + controller.loadImage( + uuid, + imageLoadResponse + ) + } + + override fun onError(error: String) { + System.err.println(error) + } + } + + try { + controller.loginUser( + AuthRequest(userState.value.username, userState.value.password), + saveResponse + ) + } catch (ex: Exception) { + // TODO + } + } + + /** + * Upload image by [uri] on the server + * and passes it's new uuid to a [handler] function + */ + private fun uploadImageOnServer(uri: Uri, handler: (UUID) -> Unit) { + val imageSaveResponse = object : ApiCallback { + override fun onSuccess(result: ImageSaveResponse?) { + if (result != null) { + handler(result.id) + } + } + override fun onError(error: String) { + System.err.println(error) + } + } + val saveResponse = object : ApiCallback { + override fun onSuccess(result: AuthResponse?) { + val file = runBlocking { + imageManager.addUuidUriPair(UUID(0,0), uri) + } + + val requestFile: RequestBody = file.asRequestBody("image/jpeg".toMediaTypeOrNull()) + + val filePart = MultipartBody.Part.createFormData( + "file", + file.name, + requestFile + ) + + controller.saveImage( + filePart, + imageSaveResponse + ) + } + + override fun onError(error: String) { + System.err.println(error) + } + } + + try { + controller.loginUser( + AuthRequest(userState.value.username, userState.value.password), + saveResponse + ) + } catch (ex: Exception) { + // TODO + } + } + + /** + * Delete image by [uuid] from the server + */ + private fun deleteImageFromServer(uuid: UUID) { + val imageDeleteResponse = object : ApiCallback { + override fun onSuccess(result: Void?) { + } + override fun onError(error: String) { + System.err.println(error) + } + } + val saveResponse = object : ApiCallback { + override fun onSuccess(result: AuthResponse?) { + try { + controller.deleteImage( + uuid, + imageDeleteResponse + ) + } catch (ex: Exception) { + // TODO + } + } + + override fun onError(error: String) { + System.err.println(error) + } + } + + try { + controller.loginUser( + AuthRequest(userState.value.username, userState.value.password), + saveResponse + ) + } catch (ex: Exception) { + // TODO + } + } + /** * User State */ @@ -78,13 +356,6 @@ class ServerViewModel @Inject constructor( * Route state */ val routeState: StateFlow = dataRepository.routeState - /** - * Operation's state - */ - var isRefreshing by mutableStateOf(false) - private set - - /** * User's published routes */ @@ -96,21 +367,22 @@ class ServerViewModel @Inject constructor( private val _networkRoutesState = MutableStateFlow(listOf()) val networkRoutesState: StateFlow> = _networkRoutesState.asStateFlow() - fun saveUsername( - username: String - ) { + private val _previewRoute = MutableStateFlow(listOf()) + val previewRoute: StateFlow> = _previewRoute.asStateFlow() + + fun setPreviewRoute(list: List) { + _previewRoute.value = list + } + + fun saveUsername(username: String) { val updatedUserState = userState.value.copy(username = username) dataRepository.updateUserState(updatedUserState) } - fun saveUserLogin( - login: String - ) { + fun saveUserLogin(login: String) { val updatedUserState = userState.value.copy(login = login) dataRepository.updateUserState(updatedUserState) } - fun saveUserPassword( - password: String - ) { + fun saveUserPassword(password: String) { val updatedUserState = userState.value.copy(password = password) dataRepository.updateUserState(updatedUserState) } @@ -118,6 +390,56 @@ class ServerViewModel @Inject constructor( dataRepository.updateUserState(UserState()) } + private fun updateUserInfo() { + dataRepository.setLoading(true); + val getUserInfoResponse = object : ApiCallback { + override fun onSuccess(result: UserResponse?) { + val updatedUserState = userState.value.copy( + profileImageId = result?.avatarId + ) + dataRepository.updateUserState(updatedUserState) +// setLoading(false); + } + + override fun onError(error: String) { + val updatedUserState = userState.value.copy( + profileImageId = null + ) + dataRepository.updateUserState(updatedUserState) + // Give UUID(0, 0) to user + System.err.println(error) +// setLoading(false); + } + } + val getResponse = object : ApiCallback { + override fun onSuccess(result: AuthResponse?) { + try { + controller.getUserInfo( + userState.value.username, + getUserInfoResponse + ) + } catch (ex: Exception) { + // TODO + } + dataRepository.setLoading(false); + } + + override fun onError(error: String) { + System.err.println(error) + dataRepository.setLoading(false); + } + } + + try { + controller.loginUser( + AuthRequest(userState.value.username, userState.value.password), + getResponse + ) + } catch (ex: Exception) { + // TODO + } + } + /** * Ask login user from server */ @@ -129,8 +451,11 @@ class ServerViewModel @Inject constructor( override fun onSuccess(result: AuthResponse?) { dataRepository.setLoading(false) if (result != null) { - val updatedUserState = userState.value.copy(token = result.token) + val updatedUserState = userState.value.copy( + token = result.token + ) dataRepository.updateUserState(updatedUserState) + updateUserInfo() UserUiState.Success(userState) } else { UserUiState.Error("Connection failed") @@ -164,6 +489,7 @@ class ServerViewModel @Inject constructor( if (result != null) { val updatedUserState = userState.value.copy(token = result.token) dataRepository.updateUserState(updatedUserState) + updateUserInfo() UserUiState.Success(userState) } } @@ -262,7 +588,8 @@ class ServerViewModel @Inject constructor( name = result.name, description = result.description, rating = result.rating, - authorUsername = result.authorUsername, + profileId = result.author.avatarId, + authorUsername = result.author.username, route = convertedRoute ) _publishedRoutesState.value += newRoute @@ -313,7 +640,7 @@ class ServerViewModel @Inject constructor( fun askAddRate(routeId: UUID, rating: Int) { val addRateResponse = object : ApiCallback { override fun onSuccess(result: RatingResponse?) { - // TODO + askListRoutes() } override fun onError(error: String) { System.err.println(error) @@ -350,6 +677,47 @@ class ServerViewModel @Inject constructor( } } + /** + * Ask user's route rate from server + */ + fun askUserRate(routeId: UUID, rating: Int) { + val askRateResponse = object : ApiCallback { + override fun onSuccess(result: Int?) { + // TODO + } + override fun onError(error: String) { + System.err.println(error) + } + } + val saveResponse = object : ApiCallback { + override fun onSuccess(result: AuthResponse?) { + try { + controller.getUserRate( + routeId, + userState.value.username, + askRateResponse + ) + } catch (ex: Exception) { + // TODO + } + + } + + override fun onError(error: String) { + System.err.println(error) + } + } + + try { + controller.loginUser( + AuthRequest(userState.value.username, userState.value.password), + saveResponse + ) + } catch (ex: Exception) { + // TODO + } + } + /** * Ask delete route from server */ @@ -404,7 +772,9 @@ class ServerViewModel @Inject constructor( name = route.name, description = route.description, rating = route.rating, - authorUsername = route.authorUsername, + userRating = route.rate?: 0, + profileId = route.author.avatarId, + authorUsername = route.author.username, route = latlngList(route.route) ) } @@ -455,10 +825,12 @@ class ServerViewModel @Inject constructor( _networkRoutesState.value = result.map { route -> PublishedRoute( id = route.id, - name = route.name, - description = route.description, + name = route.name?: "No name", + description = route.description?: "No description", rating = route.rating, - authorUsername = route.authorUsername, + userRating = route.rate?: 0, + profileId = route.author.avatarId, + authorUsername = route.author.username?: "Unknown", route = latlngList(route.route) ) } @@ -495,16 +867,124 @@ class ServerViewModel @Inject constructor( } } + private fun updatePageState(items: List) { + isRefreshing = true + _networkRoutesState.value += items.map { route -> + PublishedRoute( + id = route.id, + name = route.name?: "No name", + description = route.description?: "No description", + rating = route.rating, + userRating = route.rate?: 0, + profileId = route.author.avatarId, + authorUsername = route.author.username?: "Unknown", + route = latlngList(route.route) + ) + } + isRefreshing = false + } + + fun askFirstPageRoutes() { + isRefreshing = true + val routePageResponse = object : ApiCallback { + override fun onSuccess(result: PageResponse?) { + if (result != null) { + pageToken = result.nextPageToken + _networkRoutesState.value = result.items.map { route -> + PublishedRoute( + id = route.id, + name = route.name?: "No name", + description = route.description?: "No description", + rating = route.rating, + userRating = route.rate?: 0, + profileId = route.author.avatarId, + authorUsername = route.author.username?: "Unknown", + route = latlngList(route.route) + ) + } + } + isRefreshing = false + } + override fun onError(error: String) { + isRefreshing = false + System.err.println(error) + } + } + val updateResponse = object : ApiCallback { + override fun onSuccess(result: AuthResponse?) { + try { + controller.getFirstPage(routePageResponse) + } catch (ex: Exception) { + // TODO + } + } + override fun onError(error: String) { + System.err.println(error) + isRefreshing = false + } + } + + try { + controller.loginUser( + AuthRequest(userState.value.username, userState.value.password), + updateResponse + ) + } catch (ex: Exception) { + // TODO + } + } + /** - * Returns a list of [LatLng] from [route] + * Ask update on rating route list */ - private fun latlngList(route: Route?): List { - val convertedList = mutableListOf() - if (route != null) { - for (rt in route.route) { - convertedList.add(LatLng(rt.latitude, rt.longitude)) + fun askNextPageRoutes() { + isRefreshing = true + val routePageResponse = object : ApiCallback { + override fun onSuccess(result: PageResponse?) { + if (result != null) { + pageToken = result.nextPageToken + updatePageState(result.items) + } + isRefreshing = false + } + override fun onError(error: String) { + isRefreshing = false + System.err.println(error) + } + } + val updateResponse = object : ApiCallback { + override fun onSuccess(result: AuthResponse?) { + try { + if (pageToken != null) { + controller.getNextPage(pageToken, routePageResponse) + } + } catch (ex: Exception) { + // TODO + } + } + override fun onError(error: String) { + System.err.println(error) + isRefreshing = false } } - return convertedList.toList() + + try { + controller.loginUser( + AuthRequest(userState.value.username, userState.value.password), + updateResponse + ) + } catch (ex: Exception) { + // TODO + } + } + + /** + * Returns a list of [LatLng] from [route] + */ + private fun latlngList(route: Route?): List { + return route?.route + ?.filterIndexed { index, _ -> index % 2 == 0 } + ?.map { rt -> LatLng(rt.latitude, rt.longitude) } + ?: emptyList() } } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e096cf3..c379cae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,6 +1,7 @@ RouteMoodClient MapScreen + NetRouteScreen NetworkScreen RoutesListScreen UserSettingsScreen diff --git a/serverInteractor/src/main/java/ru/hse/routemood/RouteMoodServerApi.java b/serverInteractor/src/main/java/ru/hse/routemood/RouteMoodServerApi.java index ef8b768..99417d1 100644 --- a/serverInteractor/src/main/java/ru/hse/routemood/RouteMoodServerApi.java +++ b/serverInteractor/src/main/java/ru/hse/routemood/RouteMoodServerApi.java @@ -30,7 +30,7 @@ public interface RouteMoodServerApi { - String BASE_URL = "http://localhost:8080/"; + String BASE_URL = "http://158.160.135.170:8080/"; @GET("/gpt-fictive-message") Call getFictiveRoute(@Query("latitude") Double latitude,