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)
+
+## Содержание
+- [Описание](#описание)
+- [Возможности](#возможности)
+- [Скриншоты](#скриншоты)
+- [Технологии](#технологии)
+- [Установка и запуск](#установка-и-запуск)
+- [Лицензия](#лицензия)
+
+## Описание
+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