diff --git a/LICENSE b/LICENSE index 261eeb9..f49a4e1 100644 --- a/LICENSE +++ b/LICENSE @@ -198,4 +198,4 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and - limitations under the License. + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index 3855e65..e240602 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,46 @@ -![Превью](images/readme_preview.png) +

+ GoodRoad banner +

## GoodRoad (Android client) **Авторы**: ```Городкова Ксения```, ```Грудцына Виктория```, ```Толстограева Виктория``` -## Описание проекта - -**GoodRoad** — это мобильное приложение для построения пеших инклюзивных маршрутов. В отличие от стандартной навигации по кратчайшему пути, приложение подстраивает путь под ограничения здоровья пользователя. Если полностью безбарьерного маршрута нет, GoodRoad показывает проблемные участки и дает возможность запросить помощь волонтера. - -Данные о доступности инфраструктуры собираются пользователями в игровом формате с системой бонусов. Запросы на помощь находятся в отдельной ленте. - -## Основная функциональность - -- **Профиль пользователя:** - - Выбор препятствий, которые нужно избегать при построении маршрута; - - Интерактивные задания, связанные с оценкой объектов и волонтерством; - - Рейтинг и система начисления баллов за отзывы и волонтерство; - - Лента заявок на помощь с фильтрами по местоположению и времени; - - Подача заявки на улучшение инфраструктуры. -- **Профиль модератора:** - - Обработка заявок о помощи: система реагирования на критические ситуации; - - Модерация отзывов; -- **Карта и маршруты:** - - Влияние отзывов на построение пути. - - Построение 3 вариантов маршрута: **доступный**, **быстрый**, **компромиссный**; - - Выделение проблемных участков и карточки объектов с деталями; - - История маршрутов и сохранение последнего построенного пути. - -## MVP - -- **Регистрация / вход / восстановление доступа.** -- **Маршрутизация:** - - Выбор избегаемых препятствий в профиле; - - Построение маршрута; - - Отображение маршрута и проблемных участков; - - Хранение препятствий (тип и тяжесть). -- **Отзывы:** - - Чек-лист для оценивания; - - Прикрепление фотографии (опционально); - - Учет отзывов в маршрутизации. -- **Модерация:** - - Проверка отзывов; - - Управление младшими модераторами (в случае главного). +## Краткое описание проекта + +**GoodRoad** — мобильное приложение для построения пеших инклюзивных маршрутов с учетом состояния дороги, препятствий на пути и персональных ограничений пользователя. + +Обычный навигатор часто показывает только самый короткий или быстрый путь, но не объясняет, насколько этот путь удобен на практике. На маршруте могут встретиться лестницы, высокие бордюры, ямы, крутые участки дороги и другие барьеры. GoodRoad решает именно эту задачу: приложение помогает заранее понять, подходит ли путь конкретному человеку, показывает проблемные места и позволяет собирать отзывы о доступности дорог и объектов. + +Сейчас проект представлен в формате MVP. В этой версии мы показываем основной сценарий работы приложения: пользователь настраивает ограничения, строит маршрут с учетом препятствий и оставляет отзывы, а модератор проверяет пользовательский контент. + +## Возможности для каждого типа пользователя (MVP) + +### Пользователь +- Регистрация, вход, восстановление пароля; +- Просмотр и редактирование профиля; +- Выбор препятствий, которые нужно избегать при построении маршрута, и их тяжесть; +- Построение маршрута с учетом ограничений пользователя; +- Создание, редактирование и удаление отзывов о состоянии дороги или объекта; +- Добавление фотографий к отзыву; +- Просмотр статуса модерации отзыва и начисленных за него баллов. + +### Модератор +- Просмотр отзывов, отправленных на проверку; +- Одобрение и отклонение отзывов; +- Работа с модерацией пользовательского контента. + +### Главный модератор +- Все возможности обычного модератора; +- Добавление и удаление других модераторов. + +## Что планируем добавить к защите + +- Дополнительные сценарии для построения маршрута; +- Расширение работы с картой и подсветкой проблемных участков; +- Взаимодействие с волонтерами: их поиск, связь и оказание помощи людям с ограниченными возможностями; +- Расширение системы баллов в игровом формате: выполнение заданий, связанных с волонтерством и сбором данных о доступности дорог и объектов; +- Расширение модераторского сценария. ## Наши продукты @@ -54,57 +53,93 @@ - Временно недоступно + Скоро тут появится ссылка +## Как запустить backend в Docker +На данный момент доступна локальная проверка клиентского приложения. Для этого рядом должен находиться серверный репозиторий [GoodRoad-Server](https://github.com/GoodRoad-Project/GoodRoadServer). +Ожидаемая структура каталогов: +```text +GoodRoad-Client/ +GoodRoad-Server/ +``` -## Технологии +### 1. Подготовьте `.env` +Из корня клиентского репозитория выполните: - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Основные технологии и инструменты (клиент)
КомпонентТехнологии / Инструменты
UIAndroid SDK, Material Components/ Jetpack Compose
КартыMapbox Android SDK (слои для маршрутов и препятствий)
ГеопозицияAndroid Location API
Оффлайн-данныеRoom (кэш профиля, ограничений, препятствий и последнего маршрута)
ГеометрияJTS (офлайн-операции с геометрией при учете препятствий)
+```bash +cp docker/.env.backend.example docker/.env.backend +``` -*Технологии серверной части: [в отдельном репозитории](https://github.com/GoodRoad-Project/GoodRoadServer).* +При необходимости можно изменить значения в `docker/.env.backend`. -## Архитектура клиента +### 2. Запустите контейнеры +Из корня `GoodRoad-Client`: + +```bash +docker-compose up --build +``` -**Временно недоступно** +После запуска поднимутся PostgreSQL + PostGIS, а также backend GoodRoad на Spring Boot. -## Безопасность +### 3. Используйте сервер из Android-клиента +В клиенте используется адрес: -**Временно недоступно** +```text +http://10.0.2.2:8080/ +``` -## Структура проекта +Этот адрес подходит для Android-эмулятора. Если запускаете приложение на физическом устройстве, адрес сервера нужно заменить на локальный IP компьютера в вашей сети. -| **Схема клиентского приложения**
![Архитектура приложения](images/android_scheme.png) | -|:----------------------------------------------------------------------------------------:| +## Технологии клиента + +| Категория | Технологии | Назначение | +|---|---|---| +| Язык | Kotlin | Основной язык клиентского приложения | +| UI | Jetpack Compose, Material 3 | Построение экранов и компонентов интерфейса | +| Навигация | Navigation Compose | Переходы между экранами и сценариями | +| Состояние экрана | ViewModel, LiveData | Хранение состояния и обработка логики экранов | +| Асинхронность | Kotlin Coroutines | Выполнение сетевых и фоновых операций | +| Сеть | Retrofit, OkHttp | Работа с REST API | +| JSON | Gson Converter | Сериализация и десериализация DTO | +| Изображения | Coil | Загрузка и отображение изображений | + +## Архитектура клиента -*Схемы серверной части: [в отдельном репозитории](https://github.com/GoodRoad-Project/GoodRoadServer)*. \ No newline at end of file +Клиент реализован как Android-приложение на Jetpack Compose. Основной поток взаимодействия внутри клиента: **надо добавить** + +### Слои клиентской архитектуры + +| Слой | Что включает | Назначение | +|----|---|---| +| UI | `ui/auth`, `ui/user`, `ui/maps`, `ui/reviews`, `ui/theme` | Экраны, компоненты интерфейса, навигационные сценарии | +| ViewModel | `AuthViewModel`, `UserViewModel`, `MapsViewModel`, `ReviewsViewModel` | Состояние экранов, обработка действий пользователя, связь UI и данных | +| Data | `data/auth`, `data/user`, `data/obstacle`, `data/review`, `data/network` | Работа с DTO, репозиториями и сетевыми запросами | +| Network | `ApiClient`, Retrofit API interfaces | Подключение к backend, настройка base URL, сериализация и HTTP-клиент | + +### Структура проекта + +```text +app/src/main/java/com/example/goodroad/ +├── MainActivity.kt +├── data/ +│ ├── auth/ +│ ├── network/ +│ ├── obstacle/ +│ ├── review/ +│ └── user/ +└── ui/ + ├── auth/ + ├── common/ + ├── maps/ + ├── reviews/ + ├── theme/ + ├── user/ + └── viewmodel/ +``` + +## Схема клиентского приложения + +**надо добавить (а может и не надо)** diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..3cdafda --- /dev/null +++ b/SETUP.md @@ -0,0 +1 @@ +## Тут что-то будет позже \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..2121d65 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,120 @@ +plugins { + id 'com.android.application' + id 'org.jetbrains.kotlin.android' +} + +android { + namespace 'com.example.goodroad' + compileSdk 35 + + defaultConfig { + applicationId 'com.example.goodroad' + minSdk 28 + targetSdk 35 + versionCode 1 + versionName '1.0' + + testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + buildConfigField "boolean", "MOCK_AUTH", "false" + buildConfigField "String", "GOODROAD_SERVER_URL", "\"http://10.0.2.2:8080/\"" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + applicationIdSuffix '.debug' + debuggable true + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + + buildFeatures { + compose true + buildConfig true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.5.14' + } + + packaging { + resources { + excludes += '/META-INF/{AL2.0,LGPL2.1}' + } + } +} + +dependencies { + implementation 'androidx.compose.foundation:foundation:1.10.5' + implementation 'androidx.compose.runtime:runtime-livedata:1.10.6' + implementation 'androidx.compose.runtime:runtime:1.10.6' + def composeBom = platform('androidx.compose:compose-bom:2024.06.00') + + implementation 'androidx.core:core-ktx:1.15.0' + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.7' + implementation 'androidx.activity:activity-compose:1.9.3' + implementation composeBom + androidTestImplementation composeBom + + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.navigation:navigation-compose:2.8.5' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7' + implementation 'com.google.android.material:material:1.12.0' + + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0' + implementation 'com.squareup.retrofit2:retrofit:2.11.0' + implementation 'com.squareup.retrofit2:converter-gson:2.11.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' + implementation 'io.coil-kt:coil-compose:2.7.0' + + implementation 'org.maplibre.gl:android-sdk:13.0.2' + implementation 'com.google.maps.android:android-maps-utils:3.8.2' + implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.23' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' + implementation 'androidx.core:core-ktx:1.12.0' + implementation 'androidx.appcompat:appcompat:1.6.1' + implementation 'com.google.android.material:material:1.11.0' + implementation 'androidx.activity:activity:1.8.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + implementation 'org.locationtech.jts:jts-core:1.20.0' + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.retrofit2:converter-gson:2.9.0' + implementation 'org.maplibre.gl:android-sdk:13.0.2' + implementation 'com.google.android.gms:play-services-location:21.3.0' + implementation 'com.graphhopper:graphhopper-core:11.0' + implementation 'com.google.maps.android:android-maps-utils:3.4.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0' + implementation 'com.google.maps.android:android-maps-utils:3.8.2' + + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' + + implementation 'org.maplibre.gl:android-sdk:11.5.1' +} + +configurations.all { + resolutionStrategy { + force 'org.jetbrains.kotlin:kotlin-stdlib:1.9.23' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk7:1.9.23' + force 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.23' + force 'org.jetbrains.kotlin:kotlin-stdlib-common:1.9.23' + } +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..5cdf401 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1 @@ +# пока пусто diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f0b2ff6 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/MainActivity.kt b/app/src/main/java/com/example/goodroad/MainActivity.kt new file mode 100644 index 0000000..1f6f878 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/MainActivity.kt @@ -0,0 +1,19 @@ +package com.example.goodroad + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.example.goodroad.ui.auth.* +import com.example.goodroad.ui.theme.* + +class MainActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + GoodRoadTheme { + AuthApp() + } + } + } +} diff --git a/app/src/main/java/com/example/goodroad/MapActivity.kt b/app/src/main/java/com/example/goodroad/MapActivity.kt new file mode 100644 index 0000000..a5fa7c7 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/MapActivity.kt @@ -0,0 +1,286 @@ +package com.example.goodroad + +import android.location.Address +import org.maplibre.android.camera.CameraUpdateFactory +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.lifecycle.lifecycleScope +import com.example.goodroad.features.location.LocationTracker +import kotlinx.coroutines.launch +import org.maplibre.android.MapLibre +import org.maplibre.android.maps.MapView +import org.maplibre.android.maps.Style +import android.location.Geocoder +import android.widget.Button +import android.widget.EditText +import android.widget.Toast +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.Locale +import com.example.goodroad.model.RouteRequest +import com.example.goodroad.model.RouteResponse +import com.example.goodroad.features.network.api.GoodRoadApi +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import com.example.goodroad.features.network.utils.decodePoints +import com.example.goodroad.model.PathResponse +import org.maplibre.android.geometry.LatLng +import com.google.maps.android.PolyUtil +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.style.layers.LineLayer +import org.maplibre.android.style.sources.GeoJsonSource +import org.maplibre.android.style.layers.PropertyFactory +import com.example.goodroad.data.network.ApiClient +import com.example.goodroad.data.obstacle.ObstacleApi +import kotlin.collections.firstOrNull +import android.Manifest +import android.content.pm.PackageManager +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat + +class MapActivity : AppCompatActivity() { + private lateinit var mapView: MapView + private lateinit var locationTracker: LocationTracker + private lateinit var addressEditText: EditText + private lateinit var setDestinationButton: Button + + companion object { + private const val LOCATION_PERMISSION_REQUEST_CODE = 1001 + } + private var startLat: Double = 0.0 + private var startLon: Double = 0.0 + + private val api: GoodRoadApi by lazy { + ApiClient.routeApi + } + + private lateinit var backButton: Button + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + supportActionBar?.hide() + MapLibre.getInstance(this) + setContentView(R.layout.activity_map) + + mapView = findViewById(R.id.mapView) + mapView.onCreate(savedInstanceState) + + addressEditText = findViewById(R.id.addressEditText) + setDestinationButton = findViewById(R.id.setDestinationButton) + + locationTracker = LocationTracker(this) + + mapView.getMapAsync { map -> + map.setStyle(Style.Builder().fromUri("https://tiles.openfreemap.org/styles/positron")) { + if (hasLocationPermission()) { + getUserLocation() + } else { + requestLocationPermission() + } + } + } + + setDestinationButton.setOnClickListener { + val destinationAddress = addressEditText.text.toString() + if (destinationAddress.isNotBlank()) { + getCoordinatesFromAddress(destinationAddress) + } else { + Toast.makeText(this, "Введите адрес назначения", Toast.LENGTH_SHORT).show() + } + } + + backButton = findViewById(R.id.backButton) + backButton.setOnClickListener { + finish() + } + + } + + private fun getUserLocation() { + lifecycleScope.launch { + val location = locationTracker.getCurrentLocation() + if (location != null) { + startLat = location.latitude + startLon = location.longitude + Toast.makeText( + this@MapActivity, + "Ваше местоположение: $startLat, $startLon", + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText(this@MapActivity, "Не удалось определить местоположение", Toast.LENGTH_SHORT).show() + } + } + } + + private fun getCoordinatesFromAddress(address: String) { + lifecycleScope.launch { + Toast.makeText(this@MapActivity, "Поиск адреса...", Toast.LENGTH_SHORT).show() + + val addresses = withContext(Dispatchers.IO) { + try { + val geocoder = Geocoder(this@MapActivity, Locale.getDefault()) + geocoder.getFromLocationName(address, 1) + } catch (e: Exception) { + null + } + } + + if (!addresses.isNullOrEmpty()) { + val destination = addresses[0] + val endLat = destination.latitude + val endLon = destination.longitude + + Toast.makeText( + this@MapActivity, + "Маршрут от \$startLat,\$startLon до \$endLat,\$endLon", + Toast.LENGTH_LONG + ).show() + + buildRoute(endLat, endLon) + } else { + Toast.makeText(this@MapActivity, "Адрес не найден. Попробуйте точнее.", Toast.LENGTH_SHORT).show() + } + } + } + + private fun buildRoute(endLat: Double, endLon: Double) { + lifecycleScope.launch { + //взять реальный Id пользователя + if (startLat == 0.0 || startLon == 0.0) { + Toast.makeText(this@MapActivity, "Стартовая точка не определена", Toast.LENGTH_SHORT).show() + return@launch + } + + val res = ApiClient.obstacleApi.getUserObstaclePolicies() + val policies = res.body() + val allowedTypes = setOf("SAND", "GRAVEL") + + if(policies != null) { + val request = RouteRequest( + start = "$startLat,$startLon", + end = "$endLat,$endLon", + avoidStairs = policies.find { it.obstacleType == "STAIRS" }?.selected == true, + maxCurbHeight = policies.find { it.obstacleType == "CURB" }?.maxAllowedSeverity?.toInt(), + maxSlopeAngle = policies.find { it.obstacleType == "ROAD_SLOPE" }?.maxAllowedSeverity?.toDouble(), + avoidBadRoad = policies.find { it.obstacleType == "POTHOLES" }?.selected == true, + avoidSurfaceTypes = policies.filter { it.selected && it.obstacleType in allowedTypes } + .map { it.obstacleType } + ) + + //drawRoute(RouteResponse(id = "test", paths = emptyList())) + + try { + val response = api.getRoute(request) + drawRoute(response) + } catch (e: Exception) { + Toast.makeText(this@MapActivity, "Ошибка: ${e.message}", Toast.LENGTH_SHORT) + .show() + e.printStackTrace() + } + } + } + } + + private fun drawRoute(response: RouteResponse) { + val path = response.paths.firstOrNull() + if (path == null) { + Toast.makeText(this, "Маршрут не найден", Toast.LENGTH_SHORT).show() + return + } + + val points = decodePoints(path.points) + if (points.isEmpty()) { + Toast.makeText(this, "Нет точек для отрисовки", Toast.LENGTH_SHORT).show() + return + } + + val latLngs = points.map { LatLng(it.latitude, it.longitude) } + + mapView.getMapAsync { map -> + map.getStyle { style -> + style.removeLayer("route-layer") + style.removeSource("route-source") + + // Создаём GeoJSON строку вручную + val coordinates = latLngs.joinToString(", ") { + "[${it.longitude}, ${it.latitude}]" + } + val geojson = """ + { + "type": "FeatureCollection", + "features": [{ + "type": "Feature", + "geometry": { + "type": "LineString", + "coordinates": [$coordinates] + } + }] + } + """.trimIndent() + + val source = GeoJsonSource("route-source", geojson) + style.addSource(source) + + val lineLayer = LineLayer("route-layer", "route-source").apply { + setProperties( + PropertyFactory.lineColor("#8B7AC6"), + PropertyFactory.lineWidth(6f), + PropertyFactory.lineOpacity(0.9f) + ) + } + style.addLayer(lineLayer) + + if (latLngs.isNotEmpty()) { + map.animateCamera( + CameraUpdateFactory.newLatLngZoom(latLngs.first(), 14.0), + 1000 + ) + } + } + } + } + + private fun hasLocationPermission(): Boolean { + return ContextCompat.checkSelfPermission( + this, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } + + private fun requestLocationPermission() { + ActivityCompat.requestPermissions( + this, + arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), + LOCATION_PERMISSION_REQUEST_CODE + ) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == LOCATION_PERMISSION_REQUEST_CODE) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + getUserLocation() + } else { + Toast.makeText(this, "Без разрешения геолокация не будет работать", Toast.LENGTH_LONG).show() + } + } + } + + override fun onStart() { super.onStart(); mapView.onStart() } + override fun onResume() { super.onResume(); mapView.onResume() } + override fun onPause() { super.onPause(); mapView.onPause() } + override fun onStop() { super.onStop(); mapView.onStop() } + override fun onDestroy() { super.onDestroy(); mapView.onDestroy() } + override fun onLowMemory() { super.onLowMemory(); mapView.onLowMemory() } + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + mapView.onSaveInstanceState(outState) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/data/auth/AuthApi.kt b/app/src/main/java/com/example/goodroad/data/auth/AuthApi.kt new file mode 100644 index 0000000..a5c88d8 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/auth/AuthApi.kt @@ -0,0 +1,16 @@ +package com.example.goodroad.data.auth + +import retrofit2.* +import retrofit2.http.* + +interface AuthApi { + + @POST("/auth/login") + suspend fun login(@Body req: LoginReq): AuthResp + + @POST("/auth/register") + suspend fun register(@Body req: RegisterReq): AuthResp + + @POST("/auth/recover-password") + suspend fun recoverPassword(@Body req: RecoverPasswordReq): Response +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/data/auth/AuthModels.kt b/app/src/main/java/com/example/goodroad/data/auth/AuthModels.kt new file mode 100644 index 0000000..81a7f02 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/auth/AuthModels.kt @@ -0,0 +1,39 @@ +package com.example.goodroad.data.auth + +data class LoginReq( + val phone: String, + val password: String +) + +data class RegisterReq( + val firstName: String, + val lastName: String, + val phone: String, + val password: String +) + +data class RecoverPasswordReq( + val phone: String, + val firstName: String, + val lastName: String, + val newPassword: String +) + +data class AuthResp( + val user: UserDto? = null, + val message: String? = null +) + +data class UserDto( + val id: String? = null, + val firstName: String? = null, + val lastName: String? = null, + val role: String? = null, + val totalPoints: Int? = null +) + +data class ApiErrorDto( + val error: String? = null, + val message: String? = null, + val details: Map? = null +) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/data/auth/AuthRepository.kt b/app/src/main/java/com/example/goodroad/data/auth/AuthRepository.kt new file mode 100644 index 0000000..7497dff --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/auth/AuthRepository.kt @@ -0,0 +1,49 @@ +package com.example.goodroad.data.auth + +import com.example.goodroad.data.network.ApiClient +import retrofit2.HttpException +import java.io.IOException + +class AuthRepository { + + private val api = ApiClient.authApi + + suspend fun loginUser(phone: String, password: String): AuthResp { + return try { + api.login(LoginReq(phone, password)) + } catch (e: HttpException) { + throw e + } catch (e: IOException) { + throw IOException() + } + } + + suspend fun registerUser( + firstName: String, + lastName: String, + phone: String, + password: String + ): AuthResp { + return try { + api.register(RegisterReq(firstName, lastName, phone, password)) + } catch (e: HttpException) { + throw e + } catch (e: IOException) { + throw IOException() + } + } + + suspend fun recoverPassword( + phone: String, + firstName: String, + lastName: String, + newPassword: String + ): Boolean { + val response = api.recoverPassword( + RecoverPasswordReq(phone, firstName, lastName, newPassword) + ) + + if (response.isSuccessful) return true + throw HttpException(response) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/data/network/ApiClient.kt b/app/src/main/java/com/example/goodroad/data/network/ApiClient.kt new file mode 100644 index 0000000..dbd40d3 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/network/ApiClient.kt @@ -0,0 +1,83 @@ +package com.example.goodroad.data.network + +import com.example.goodroad.BuildConfig +import com.example.goodroad.data.auth.* +import com.example.goodroad.data.obstacle.* +import com.example.goodroad.data.review.* +import com.example.goodroad.data.user.* +import okhttp3.* +import okhttp3.logging.* +import retrofit2.* +import retrofit2.converter.gson.* +import java.util.concurrent.* +import com.example.goodroad.features.network.api.GoodRoadApi + +object ApiClient { + + private val logging = HttpLoggingInterceptor().apply { + level = HttpLoggingInterceptor.Level.BODY + } + + private var userPhone: String? = null + private var userPassword: String? = null + + fun updateCredentials(phone: String? = null, password: String? = null) { + if (!phone.isNullOrBlank()) { + userPhone = phone + } + if (!password.isNullOrBlank()) { + userPassword = password + } + } + + fun clearCredentials() { + userPhone = null + userPassword = null + } + + private val client: OkHttpClient + get() = OkHttpClient.Builder() + .addInterceptor(logging) + .addInterceptor { chain -> + val requestBuilder = chain.request().newBuilder() + val phone = userPhone + val password = userPassword + if (!phone.isNullOrBlank() && !password.isNullOrBlank()) { + val credential = Credentials.basic(phone, password) + requestBuilder.addHeader("Authorization", credential) + } + chain.proceed(requestBuilder.build()) + } + .connectTimeout(20, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .writeTimeout(20, TimeUnit.SECONDS) + .build() + + private fun retrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl(BuildConfig.GOODROAD_SERVER_URL) + .client(client) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + + val authApi: AuthApi by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + retrofit().create(AuthApi::class.java) + } + + val userApi: UserApi by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + retrofit().create(UserApi::class.java) + } + + val obstacleApi: ObstacleApi by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + retrofit().create(ObstacleApi::class.java) + } + + val reviewApi: ReviewApi by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + retrofit().create(ReviewApi::class.java) + } + + val routeApi: GoodRoadApi by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + retrofit().create(GoodRoadApi::class.java) + } +} diff --git a/app/src/main/java/com/example/goodroad/data/obstacle/ObstacleApi.kt b/app/src/main/java/com/example/goodroad/data/obstacle/ObstacleApi.kt new file mode 100644 index 0000000..4a7c5bc --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/obstacle/ObstacleApi.kt @@ -0,0 +1,15 @@ +package com.example.goodroad.data.obstacle + +import retrofit2.* +import retrofit2.http.* + +interface ObstacleApi { + + @GET("/users/obstacles") + suspend fun getUserObstaclePolicies(): Response> + + @PUT("/users/obstacles") + suspend fun replaceUserObstaclePolicies( + @Body req: ReplaceObstaclePolicyReq + ): Response> +} diff --git a/app/src/main/java/com/example/goodroad/data/obstacle/ObstacleModels.kt b/app/src/main/java/com/example/goodroad/data/obstacle/ObstacleModels.kt new file mode 100644 index 0000000..e5bb2b0 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/obstacle/ObstacleModels.kt @@ -0,0 +1,11 @@ +package com.example.goodroad.data.obstacle + +data class ObstaclePolicyItem( + val obstacleType: String, + val selected: Boolean, + val maxAllowedSeverity: Short? +) + +data class ReplaceObstaclePolicyReq( + val items: List +) diff --git a/app/src/main/java/com/example/goodroad/data/obstacle/ObstacleRepository.kt b/app/src/main/java/com/example/goodroad/data/obstacle/ObstacleRepository.kt new file mode 100644 index 0000000..3f1aabb --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/obstacle/ObstacleRepository.kt @@ -0,0 +1,22 @@ +package com.example.goodroad.data.obstacle + +import retrofit2.* + +class ObstacleRepository(private val api: ObstacleApi) { + + suspend fun getUserObstaclePolicies(): List { + val response = api.getUserObstaclePolicies() + if (response.isSuccessful) { + return response.body().orEmpty() + } + throw HttpException(response) + } + + suspend fun replaceUserObstaclePolicies(req: ReplaceObstaclePolicyReq): List { + val response = api.replaceUserObstaclePolicies(req) + if (response.isSuccessful) { + return response.body().orEmpty() + } + throw HttpException(response) + } +} diff --git a/app/src/main/java/com/example/goodroad/data/review/ReviewApi.kt b/app/src/main/java/com/example/goodroad/data/review/ReviewApi.kt new file mode 100644 index 0000000..7db2e75 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/review/ReviewApi.kt @@ -0,0 +1,25 @@ +package com.example.goodroad.data.review + +import retrofit2.Response +import retrofit2.http.* + +interface ReviewApi { + + @GET("/reviews/own") + suspend fun getOwnReviews(): Response> + + @GET("/reviews/points") + suspend fun getOwnReviewPoints(): Response + + @POST("/reviews") + suspend fun createReview(@Body req: UpsertReviewReq): Response + + @PATCH("/reviews/{id}") + suspend fun updateReview( + @Path("id") reviewId: String, + @Body req: UpsertReviewReq + ): Response + + @DELETE("/reviews/{id}") + suspend fun deleteReview(@Path("id") reviewId: String): Response +} diff --git a/app/src/main/java/com/example/goodroad/data/review/ReviewModels.kt b/app/src/main/java/com/example/goodroad/data/review/ReviewModels.kt new file mode 100644 index 0000000..6c280ad --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/review/ReviewModels.kt @@ -0,0 +1,47 @@ +package com.example.goodroad.data.review + +data class ReviewAddress( + val country: String, + val region: String, + val localityType: String, + val city: String, + val street: String, + val house: String, + val placeName: String? = null +) + +data class ReviewObstacle( + val obstacleType: String, + val severity: Short +) + +data class UpsertReviewReq( + val latitude: Double, + val longitude: Double, + val address: ReviewAddress, + val rating: Short, + val obstacles: List, + val comment: String? = null, + val photoUrls: List = emptyList() +) + +data class ReviewCardResp( + val id: String, + val featureId: String, + val address: ReviewAddress, + val latitude: Double, + val longitude: Double, + val rating: Short, + val obstacles: List, + val comment: String? = null, + val photoUrls: List = emptyList(), + val status: String, + val createdAt: String, + val awardedPoints: Int, + val moderatorComment: String? = null +) + +data class ReviewPointsResp( + val totalPoints: Int, + val approvedReviews: Long +) diff --git a/app/src/main/java/com/example/goodroad/data/review/ReviewRepository.kt b/app/src/main/java/com/example/goodroad/data/review/ReviewRepository.kt new file mode 100644 index 0000000..7a73cce --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/review/ReviewRepository.kt @@ -0,0 +1,59 @@ +package com.example.goodroad.data.review + +import com.example.goodroad.data.user.UserApi +import okhttp3.MultipartBody +import retrofit2.HttpException + +class ReviewRepository( + private val reviewApi: ReviewApi, + private val userApi: UserApi +) { + + suspend fun getOwnReviews(): List { + val response = reviewApi.getOwnReviews() + if (response.isSuccessful) { + return response.body().orEmpty() + } + throw HttpException(response) + } + + suspend fun getOwnReviewPoints(): ReviewPointsResp? { + val response = reviewApi.getOwnReviewPoints() + if (response.isSuccessful) { + return response.body() + } + throw HttpException(response) + } + + suspend fun createReview(req: UpsertReviewReq): ReviewCardResp? { + val response = reviewApi.createReview(req) + if (response.isSuccessful) { + return response.body() + } + throw HttpException(response) + } + + suspend fun updateReview(reviewId: String, req: UpsertReviewReq): ReviewCardResp? { + val response = reviewApi.updateReview(reviewId, req) + if (response.isSuccessful) { + return response.body() + } + throw HttpException(response) + } + + suspend fun deleteReview(reviewId: String) { + val response = reviewApi.deleteReview(reviewId) + if (!response.isSuccessful) { + throw HttpException(response) + } + } + + suspend fun uploadReviewPhoto(file: MultipartBody.Part): String { + val response = userApi.uploadAvatar(file) + if (!response.isSuccessful) { + throw HttpException(response) + } + val body = response.body() ?: throw IllegalStateException("Сервер не вернул ссылку на фото") + return body.photoUrl + } +} diff --git a/app/src/main/java/com/example/goodroad/data/user/UserApi.kt b/app/src/main/java/com/example/goodroad/data/user/UserApi.kt new file mode 100644 index 0000000..588d319 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/user/UserApi.kt @@ -0,0 +1,27 @@ +package com.example.goodroad.data.user + +import okhttp3.MultipartBody +import retrofit2.Response +import retrofit2.http.* + +interface UserApi { + + @GET("/users") + suspend fun getCurrentUser(): Response + + @PUT("/users") + suspend fun updateCurrentUser(@Body req: UpdateUserReq): Response + + @POST("/users") + suspend fun changePassword( + @Query("oldPassword") oldPassword: String, + @Query("newPassword") newPassword: String + ): Response + + @Multipart + @POST("/users/avatar") + suspend fun uploadAvatar(@Part file: MultipartBody.Part): Response + + @HTTP(method = "DELETE", path = "/users", hasBody = true) + suspend fun deleteCurrentUser(@Body req: DeleteAccountReq): Response +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/data/user/UserModels.kt b/app/src/main/java/com/example/goodroad/data/user/UserModels.kt new file mode 100644 index 0000000..0394b80 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/user/UserModels.kt @@ -0,0 +1,34 @@ +package com.example.goodroad.data.user + +data class UserDto( + val id: String, + val role: String, + val firstName: String?, + val lastName: String?, + val photoUrl: String?, + val active: Boolean +) + +data class UpdateUserReq( + val firstName: String? = null, + val lastName: String? = null, + val photoUrl: String? = null, + val phone: String? = null +) + +data class AvatarUploadResp( + val photoUrl: String +) + +data class SettingsView( + val id: String, + val role: String, + val firstName: String?, + val lastName: String?, + val photoUrl: String?, + val active: Boolean +) + +data class DeleteAccountReq( + val password: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/data/user/UserRepository.kt b/app/src/main/java/com/example/goodroad/data/user/UserRepository.kt new file mode 100644 index 0000000..1df6475 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/data/user/UserRepository.kt @@ -0,0 +1,35 @@ +package com.example.goodroad.data.user + +import okhttp3.MultipartBody +import retrofit2.HttpException + +class UserRepository(private val api: UserApi) { + + suspend fun getCurrentUser(): SettingsView? { + val response = api.getCurrentUser() + if (response.isSuccessful) return response.body() + throw HttpException(response) + } + + suspend fun updateCurrentUser(req: UpdateUserReq): SettingsView? { + val response = api.updateCurrentUser(req) + if (response.isSuccessful) return response.body() + throw HttpException(response) + } + + suspend fun changePassword(oldPassword: String, newPassword: String) { + val response = api.changePassword(oldPassword, newPassword) + if (!response.isSuccessful) throw HttpException(response) + } + + suspend fun uploadAvatar(file: MultipartBody.Part): AvatarUploadResp? { + val response = api.uploadAvatar(file) + if (response.isSuccessful) return response.body() + throw HttpException(response) + } + + suspend fun deleteCurrentUser(req: DeleteAccountReq) { + val response = api.deleteCurrentUser(req) + if (!response.isSuccessful) throw HttpException(response) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/domain/model/LocationPoint.kt b/app/src/main/java/com/example/goodroad/domain/model/LocationPoint.kt new file mode 100644 index 0000000..71dea23 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/domain/model/LocationPoint.kt @@ -0,0 +1,9 @@ +package com.example.goodroad.domain.model + +data class LocationPoint( + val latitude: Double, + val longitude: Double, + val timestamp: Long? = null +) { + fun ToLatLanString() : String = "$latitude,$longitude" +} diff --git a/app/src/main/java/com/example/goodroad/domain/model/Obstacles.kt b/app/src/main/java/com/example/goodroad/domain/model/Obstacles.kt new file mode 100644 index 0000000..336bdef --- /dev/null +++ b/app/src/main/java/com/example/goodroad/domain/model/Obstacles.kt @@ -0,0 +1,36 @@ +package com.example.goodroad.domain.model + +data class Obstacle( + val id: String, + val position: LocationPoint, + val type: ObstacleType, + val details: ObstacleDetails +) + +enum class ObstacleType{ + CURB, + STAIRS, + ROAD_SLOPE, + POTHOLES, + SAND, + GRAVEL; +} + +sealed class ObstacleDetails { + + data class Stairs( + val stepCount: Int, + val hasRamp: Boolean = false + ) : ObstacleDetails() + + data class Slope( + val angleDegrees: Double, + ) : ObstacleDetails() + + data class Curb( + val heightCm: Int, + val hasRampCut: Boolean = false + ) : ObstacleDetails() + +} + diff --git a/app/src/main/java/com/example/goodroad/features/location/LocationErrorHandler.kt b/app/src/main/java/com/example/goodroad/features/location/LocationErrorHandler.kt new file mode 100644 index 0000000..ceabe97 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/features/location/LocationErrorHandler.kt @@ -0,0 +1,76 @@ +package com.example.goodroad.features.location + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.provider.Settings +import androidx.appcompat.app.AlertDialog +import com.google.android.gms.common.api.ResolvableApiException +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.LocationSettingsRequest +import com.google.android.gms.location.Priority +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import com.example.goodroad.features.location.LocationError +import android.net.Uri + +class LocationErrorHandler ( + private val context: Context +) { + fun GetErrorMessage(error: LocationError): String { + return when(error) { + is LocationError.PermissionsDenied -> { + "Нет разрешения на доступ геолокации. Разрешите доступ в настройках" + } + is LocationError.GPSDisabled -> { + "GPS выключен." + } + is LocationError.NoLocation -> { + "Не удалось определить мостоположение. Попробуйте позже" + } + } + } + + fun ShowErrorDialog( + error: LocationError, + onPositiveClick: (() -> Unit)? = null + ) { + val message = GetErrorMessage(error) + + val builder = AlertDialog.Builder(context) + .setTitle("Ошибка геолокации") + .setMessage(message) + .setPositiveButton("ОК") {_, _ -> + onPositiveClick?.invoke() + } + when (error) { + is LocationError.PermissionsDenied -> { + builder.setNeutralButton("Настройки") { _, _ -> + openAppSettings() + } + } + is LocationError.GPSDisabled -> { + builder.setNeutralButton("Настройки GPS") { _, _ -> + openGpsSettings() + } + } + is LocationError.NoLocation -> { + } + } + + builder.show() + } + + private fun openAppSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.data = Uri.parse("package:${context.packageName}") + context.startActivity(intent) + } + + private fun openGpsSettings() { + val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + context.startActivity(intent) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/features/location/LocationTracker.kt b/app/src/main/java/com/example/goodroad/features/location/LocationTracker.kt new file mode 100644 index 0000000..0392141 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/features/location/LocationTracker.kt @@ -0,0 +1,143 @@ +package com.example.goodroad.features.location + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.location.Location +import androidx.core.content.ContextCompat +import com.example.goodroad.domain.model.LocationPoint +import com.google.android.gms.location.FusedLocationProviderClient +import com.google.android.gms.location.LocationCallback +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume +import android.os.Looper + +sealed class LocationError: Exception() { + object PermissionsDenied: LocationError() + object GPSDisabled: LocationError() + object NoLocation: LocationError() +} + +class LocationTracker ( + private val ctx: Context +) { + private val fusedLocationClient: FusedLocationProviderClient = + LocationServices.getFusedLocationProviderClient(ctx) + + fun hasPermissions(): Boolean { + return ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.ACCESS_FINE_LOCATION + ) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission( + ctx, + Manifest.permission.ACCESS_COARSE_LOCATION + ) == PackageManager.PERMISSION_GRANTED + } + + suspend fun getCurrentLocation() : LocationPoint? { + if (!hasPermissions()) return null + + return suspendCancellableCoroutine { continuation -> + try { + fusedLocationClient.lastLocation + .addOnSuccessListener { location -> + if (location != null) { + continuation.resume(location.toLocationPoint()) + } else { + requestFreshLocation { freshLocation -> + continuation.resume(freshLocation?.toLocationPoint()) + } + } + } + .addOnFailureListener { + continuation.resume(null) + } + } catch (e: SecurityException) { + continuation.resume(null) + } + } + } + + fun locationUpdates(): Flow = callbackFlow { + if (!hasPermissions()) { + close(LocationError.PermissionsDenied) + return@callbackFlow + } + + val locationManager = ctx.getSystemService(Context.LOCATION_SERVICE) as android.location.LocationManager + if (!locationManager.isProviderEnabled(android.location.LocationManager.GPS_PROVIDER)) { + close(LocationError.GPSDisabled) + return@callbackFlow + } + + val locationRequest = LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, + 5000L // 5 sec + ).apply { + setMinUpdateIntervalMillis(2000L) + }.build() + + val locationCallback = object : LocationCallback() { + override fun onLocationResult(locationResult: LocationResult) { + locationResult.lastLocation?.let { location -> + trySend(location.toLocationPoint()) + } + } + } + + try { + fusedLocationClient.requestLocationUpdates( + locationRequest, + locationCallback, + ctx.mainLooper + ) + } catch (e: SecurityException) { + close(LocationError.PermissionsDenied) + return@callbackFlow + } + + awaitClose { + fusedLocationClient.removeLocationUpdates(locationCallback) + } + } + + private fun requestFreshLocation(callback: (Location?) -> Unit) { + val locationRequest = LocationRequest.Builder( + Priority.PRIORITY_HIGH_ACCURACY, + 1000L + ).setMaxUpdates(1).build() + + val locationCallback = object : LocationCallback() { + override fun onLocationResult(locationResult: LocationResult) { + callback(locationResult.lastLocation) + } + } + + try { + fusedLocationClient.requestLocationUpdates( + locationRequest, + locationCallback, + Looper.getMainLooper() + ) + } catch (e: SecurityException) { + callback(null) + } + } + + private fun Location.toLocationPoint(): LocationPoint { + return LocationPoint( + latitude = latitude, + longitude = longitude, + timestamp = time + ) + } + +} diff --git a/app/src/main/java/com/example/goodroad/features/network/api/GoodRoadApi.kt b/app/src/main/java/com/example/goodroad/features/network/api/GoodRoadApi.kt new file mode 100644 index 0000000..60d904d --- /dev/null +++ b/app/src/main/java/com/example/goodroad/features/network/api/GoodRoadApi.kt @@ -0,0 +1,17 @@ +package com.example.goodroad.features.network.api + +import com.example.goodroad.model.RouteRequest +import com.example.goodroad.model.RouteResponse +import retrofit2.http.Body +import retrofit2.http.POST +import retrofit2.http.Query +import retrofit2.http.GET +import retrofit2.http.PUT +import retrofit2.http.Header +import com.example.goodroad.obstacle.ObstacleMapItemResp +import com.example.goodroad.obstacle.PolicyItem +import com.example.goodroad.obstacle.ReplacePolicyReq +interface GoodRoadApi { + @POST("/api/v1/routes") + suspend fun getRoute(@Body request: RouteRequest): RouteResponse +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/features/network/utils/decodePoints.kt b/app/src/main/java/com/example/goodroad/features/network/utils/decodePoints.kt new file mode 100644 index 0000000..f42fbed --- /dev/null +++ b/app/src/main/java/com/example/goodroad/features/network/utils/decodePoints.kt @@ -0,0 +1,14 @@ +package com.example.goodroad.features.network.utils + +import com.google.maps.android.PolyUtil +import com.example.goodroad.domain.model.LocationPoint + +fun decodePoints(encodedPoints: String): List { + return PolyUtil.decode(encodedPoints).map { + LocationPoint( + latitude = it.latitude, + longitude = it.longitude + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/model/RouteRequest.kt b/app/src/main/java/com/example/goodroad/model/RouteRequest.kt new file mode 100644 index 0000000..03f9165 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/model/RouteRequest.kt @@ -0,0 +1,36 @@ +package com.example.goodroad.model + +import com.fasterxml.jackson.annotation.JsonProperty + +data class RouteRequest( + val start: String, // (lat,lon) + val end: String, // (lat,lon) + @JsonProperty("user_id") + val userId: String? = null, + + @JsonProperty("max_stairs") + val maxStairsCount: Int? = null, // сколько ступенек максимум + @JsonProperty("max_slope") + val maxSlopeAngle: Double? = null, // макс угол уклона + @JsonProperty("max_curb_height") + val maxCurbHeight: Int? = null, // макс высота бордюра + @JsonProperty("min_path_width") + val minPathWidth: Int? = null, // мин ширина прохода + + @JsonProperty("avoid_stairs") + val avoidStairs: Boolean = false, // избегать лестниц + @JsonProperty("need_ramp") + val needRamp: Boolean = false, // нужен пандус + @JsonProperty("avoid_bad_road") + val avoidBadRoad: Boolean = false, // избегать плохих дорог + + // какие поверхности избегать + @JsonProperty("avoid_surfaces") + val avoidSurfaceTypes: List = emptyList(), // "SAND", "GRAVEL" + + val locale: String = "ru", // язык инструкций + @JsonProperty("alternatives") + val needAlternatives: Boolean = true, // нужны ли альтернативы + @JsonProperty("points_encoded") + val pointsEncoded: Boolean = true +) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/model/RouteResponse.kt b/app/src/main/java/com/example/goodroad/model/RouteResponse.kt new file mode 100644 index 0000000..a083e40 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/model/RouteResponse.kt @@ -0,0 +1,60 @@ +package com.example.goodroad.model + +import com.fasterxml.jackson.annotation.JsonProperty +import java.time.Duration + +data class RouteResponse( + val id: String, // уникальный ID маршрута + val paths: List, // варианты маршрутов + val info: ResponseInfo? = null +) + +data class PathResponse( + val distance: Double, + val time: Long, + + @JsonProperty("points_encoded") + val pointsEncoded: Boolean = true, + val points: String, + + val obstacles: List = emptyList(), + + @JsonProperty("route_type") + val routeType: String = "fast", // fast, safe, balanced +) + + +data class ObstacleResponse( + val id: String, + + @JsonProperty("lat") + val latitude: Double, + + @JsonProperty("lon") + val longitude: Double, + + val type: String, // STAIRS, CURB и т.д. + + val details: ObstacleDetailsResponse? = null +) + +data class ObstacleDetailsResponse( + @JsonProperty("step_count") + val stepCount: Int? = null, // для лестниц + + @JsonProperty("height_cm") + val heightCm: Int? = null, // для бордюров + + @JsonProperty("angle_degrees") + val angleDegrees: Double? = null, // для уклонов + + @JsonProperty("has_ramp") + val hasRamp: Boolean? = null, // есть пандус + + @JsonProperty("surface_type") + val surfaceType: String? = null // покрытие +) + +data class ResponseInfo( + val took: Double +) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/obstacle/ObstacleModels.kt b/app/src/main/java/com/example/goodroad/obstacle/ObstacleModels.kt new file mode 100644 index 0000000..3d2a594 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/obstacle/ObstacleModels.kt @@ -0,0 +1,26 @@ +package com.example.goodroad.obstacle + +// data/obstacles/ObstacleModels.kt + +import java.time.Instant + +data class ObstacleMapItemResp( + val id: String, + val type: String, + val latitude: Double, + val longitude: Double, + val address: AddressResp?, + val severityEstimate: Short?, + val reviewsCount: Int, + val lastReviewedAt: Instant? +) + +data class AddressResp( + val country: String?, + val region: String?, + val localityType: String?, + val city: String?, + val street: String?, + val house: String?, + val placeName: String? +) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/obstacle/UserObstacleModels.kt b/app/src/main/java/com/example/goodroad/obstacle/UserObstacleModels.kt new file mode 100644 index 0000000..9fee99a --- /dev/null +++ b/app/src/main/java/com/example/goodroad/obstacle/UserObstacleModels.kt @@ -0,0 +1,11 @@ +package com.example.goodroad.obstacle + +data class PolicyItem( + val obstacleType: String, + val selected: Boolean, + val maxAllowedSeverity: Short? +) + +data class ReplacePolicyReq( + val items: List +) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/auth/AuthApp.kt b/app/src/main/java/com/example/goodroad/ui/auth/AuthApp.kt new file mode 100644 index 0000000..540f1be --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/auth/AuthApp.kt @@ -0,0 +1,90 @@ +package com.example.goodroad.ui.auth + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Surface +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.* +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.user.UserNav +@Composable +fun AuthApp( + navController: NavHostController = rememberNavController() +) { + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + NavHost( + navController = navController, + startDestination = LOGIN_ROUTE + ) { + composable(LOGIN_ROUTE) { + LoginScreen( + onLoginSuccess = { role -> + navController.navigate(homeRoute(role)) { + popUpTo(LOGIN_ROUTE) { inclusive = true } + launchSingleTop = true + } + }, + onSignUp = { + navController.navigate(REGISTER_ROUTE) + }, + onForgotPassword = { + navController.navigate(RECOVER_ROUTE) + } + ) + } + + composable(REGISTER_ROUTE) { + RegisterScreen( + onRegisterSuccess = { role -> + navController.navigate(homeRoute(role)) { + popUpTo(LOGIN_ROUTE) { + inclusive = true + } + launchSingleTop = true + } + }, + onLogin = { + navController.popBackStack() + } + ) + } + + composable(RECOVER_ROUTE) { + RecoverPasswordScreen( + onLogin = { + navController.popBackStack() + } + ) + } + + composable(USER_HOME_ROUTE) { + UserNav( + onLogout = { + navController.navigate(LOGIN_ROUTE) { + popUpTo(USER_HOME_ROUTE) { inclusive = true } + launchSingleTop = true + } + } + ) + } + + composable(MODERATOR_HOME_ROUTE) { + RoleStubScreen( + title = "Главный экран модератора", + onLogout = { + navController.navigate(LOGIN_ROUTE) { + popUpTo(MODERATOR_HOME_ROUTE) { + inclusive = true + } + launchSingleTop = true + } + } + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/auth/AuthComponents.kt b/app/src/main/java/com/example/goodroad/ui/auth/AuthComponents.kt new file mode 100644 index 0000000..9a7a71f --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/auth/AuthComponents.kt @@ -0,0 +1,113 @@ +package com.example.goodroad.ui.auth + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.goodroad.ui.theme.* +import kotlinx.coroutines.delay + +@Composable +fun AuthButton( + text: String, + backgroundColor: Color = SafeGreen, + contentColor: Color = BackgroundLight, + enabled: Boolean = true, + onClick: () -> Unit +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = Modifier + .fillMaxWidth() + .height(54.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = backgroundColor, + contentColor = contentColor, + disabledContainerColor = backgroundColor.copy(alpha = 0.6f), + disabledContentColor = contentColor + ) + ) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } +} + +@Composable +fun AuthFooter( + prefix: String, + action: String, + onClick: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically + ) { + Text( + text = prefix, + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary + ) + Spacer(Modifier.width(4.dp)) + TextButton( + onClick = onClick, + contentPadding = PaddingValues(0.dp) + ) { + Text( + text = action, + style = MaterialTheme.typography.bodyMedium, + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +@Composable +fun AuthStatusText( + text: String?, + onTimeout: (() -> Unit)? = null +) { + if (text == null) return + + LaunchedEffect(text) { + delay(5_000) + onTimeout?.invoke() + } + + Spacer(Modifier.height(12.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = AlertRed + ) +} + +@Composable +fun AuthSuccessText( + text: String?, + onTimeout: (() -> Unit)? = null +) { + if (text == null) return + + LaunchedEffect(text) { + delay(5_000) + onTimeout?.invoke() + } + + Spacer(Modifier.height(12.dp)) + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = SafeGreen + ) +} diff --git a/app/src/main/java/com/example/goodroad/ui/auth/AuthDecor.kt b/app/src/main/java/com/example/goodroad/ui/auth/AuthDecor.kt new file mode 100644 index 0000000..47195a7 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/auth/AuthDecor.kt @@ -0,0 +1,165 @@ +package com.example.goodroad.ui.auth + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.* +import androidx.compose.ui.graphics.* +import com.example.goodroad.ui.theme.* +@Composable +fun AuthScreenFrame( + title: String, + subtitle: String? = null, + action: @Composable () -> Unit, + footer: @Composable () -> Unit, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(BackgroundLight) + .verticalScroll(rememberScrollState()) + ) { + AuthDecor() + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary + ) + if (subtitle != null) { + Text( + text = subtitle, + modifier = Modifier.padding(top = 8.dp), + style = MaterialTheme.typography.bodyMedium, + color = TextSecondary + ) + } + Spacer(Modifier.height(28.dp)) + content() + Spacer(Modifier.height(28.dp)) + action() + Spacer(Modifier.height(16.dp)) + footer() + } + } +} + +@Composable +fun AuthDecor() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(230.dp) + .statusBarsPadding() + .padding(horizontal = 18.dp, vertical = 12.dp) + .clip(RoundedCornerShape(32.dp)) + .background(SurfaceWarm) + ) { + Canvas(modifier = Modifier.matchParentSize()) { + val lightPatch = Path().apply { + moveTo(0f, size.height * 0.76f) + cubicTo( + size.width * 0.1f, size.height * 0.7f, + size.width * 0.2f, size.height * 0.45f, + size.width * 0.34f, size.height * 0.42f + ) + cubicTo( + size.width * 0.49f, size.height * 0.38f, + size.width * 0.54f, size.height * 0.18f, + size.width * 0.72f, size.height * 0.12f + ) + lineTo(size.width, size.height * 0.12f) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + } + + drawPath( + path = lightPatch, + color = BackgroundLight + ) + + val road = Path().apply { + moveTo(size.width * 0.88f, -8f) + cubicTo( + size.width * 0.8f, size.height * 0.06f, + size.width * 0.7f, size.height * 0.14f, + size.width * 0.62f, size.height * 0.28f + ) + cubicTo( + size.width * 0.55f, size.height * 0.4f, + size.width * 0.42f, size.height * 0.52f, + size.width * 0.26f, size.height * 0.58f + ) + cubicTo( + size.width * 0.15f, size.height * 0.62f, + size.width * 0.07f, size.height * 0.68f, + -8f, size.height * 0.8f + ) + } + + drawPath( + path = road, + brush = SolidColor(UrbanBrown), + style = Stroke( + width = size.width * 0.09f, + cap = StrokeCap.Square, + join = StrokeJoin.Round + ) + ) + + drawPath( + path = road, + color = BackgroundLight.copy(alpha = 0.95f), + style = Stroke( + width = size.width * 0.014f, + cap = StrokeCap.Butt, + join = StrokeJoin.Round, + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(size.width * 0.06f, size.width * 0.04f) + ) + ) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .background(BackgroundLight.copy(alpha = 0.92f)), + contentAlignment = Alignment.Center + ) { + Text( + text = "GR", + style = MaterialTheme.typography.titleMedium, + color = TextPrimary, + fontWeight = FontWeight.SemiBold + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/auth/AuthFields.kt b/app/src/main/java/com/example/goodroad/ui/auth/AuthFields.kt new file mode 100644 index 0000000..e4147b2 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/auth/AuthFields.kt @@ -0,0 +1,157 @@ +package com.example.goodroad.ui.auth + +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.* +import androidx.compose.foundation.text.* +import androidx.compose.material.icons.* +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.text.input.* +import androidx.compose.ui.unit.* +import com.example.goodroad.ui.common.validation.* +import com.example.goodroad.ui.theme.* + +@Composable +fun PhoneField( + value: String, + onValueChange: (String) -> Unit, + label: String, + warning: String? = null, + maxLength: Int = PHONE_MAX_LENGTH +) { + PlainField( + value = value, + onValueChange = onValueChange, + label = label, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + icon = { + Icon( + imageVector = Icons.Default.Phone, + contentDescription = null, + tint = UrbanBrown + ) + }, + warning = warning, + maxLength = maxLength, + prefix = { + Text( + text = "+", + color = TextSecondary + ) + } + ) +} + +@Composable +fun PasswordField( + value: String, + onValueChange: (String) -> Unit, + label: String, + maxLength: Int = PASSWORD_MAX_LENGTH +) { + var visible by remember { mutableStateOf(false) } + + PlainField( + value = value, + onValueChange = onValueChange, + label = label, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + visualTransformation = if (visible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + icon = { + Icon( + imageVector = Icons.Default.Lock, + contentDescription = null, + tint = UrbanBrown + ) + }, + maxLength = maxLength, + trailing = { + Icon( + imageVector = if (visible) { + Icons.Default.VisibilityOff + } else { + Icons.Default.Visibility + }, + contentDescription = null, + tint = TextSecondary, + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = LocalIndication.current + ) { + visible = !visible + } + ) + } + ) +} + +@Composable +fun PlainField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + visualTransformation: VisualTransformation = VisualTransformation.None, + icon: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, + prefix: @Composable (() -> Unit)? = null, + warning: String? = null, + maxLength: Int = Int.MAX_VALUE +) { + TextField( + value = value, + onValueChange = { newValue -> + if (newValue.length <= maxLength) { + onValueChange(newValue) + } + }, + modifier = modifier.fillMaxWidth(), + label = { + Text( + text = label, + color = UrbanBrown + ) + }, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = TextPrimary), + singleLine = true, + keyboardOptions = keyboardOptions, + visualTransformation = visualTransformation, + leadingIcon = icon, + trailingIcon = trailing, + prefix = prefix, + isError = warning != null, + supportingText = { + if (warning != null) { + Text( + text = warning, + style = MaterialTheme.typography.bodySmall, + color = AlertRed + ) + } + }, + colors = TextFieldDefaults.colors( + focusedContainerColor = BackgroundLight, + unfocusedContainerColor = BackgroundLight, + disabledContainerColor = BackgroundLight, + focusedIndicatorColor = if (warning != null) AlertRed else SafeGreen, + unfocusedIndicatorColor = if (warning != null) AlertRed else BorderWarm, + cursorColor = SafeGreen, + focusedLabelColor = UrbanBrown, + unfocusedLabelColor = UrbanBrown, + focusedLeadingIconColor = UrbanBrown, + unfocusedLeadingIconColor = UrbanBrown, + focusedTrailingIconColor = TextSecondary, + unfocusedTrailingIconColor = TextSecondary + ), + shape = RoundedCornerShape(18.dp) + ) +} diff --git a/app/src/main/java/com/example/goodroad/ui/auth/LoginScreen.kt b/app/src/main/java/com/example/goodroad/ui/auth/LoginScreen.kt new file mode 100644 index 0000000..d735a97 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/auth/LoginScreen.kt @@ -0,0 +1,118 @@ +package com.example.goodroad.ui.auth + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.goodroad.ui.common.validation.* +import com.example.goodroad.ui.theme.UrbanBrown +import com.example.goodroad.ui.viewmodel.AuthViewModel +@Composable +fun LoginScreen( + onLoginSuccess: (String) -> Unit, + onSignUp: () -> Unit, + onForgotPassword: () -> Unit +) { + var phone by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var phoneWarning by rememberSaveable { mutableStateOf(null) } + var errorText by rememberSaveable { mutableStateOf(null) } + + val viewModel: AuthViewModel = viewModel() + val loginResult by viewModel.loginResult.observeAsState() + val error by viewModel.error.observeAsState() + val loading by viewModel.isLoading.observeAsState(initial = false) + + LaunchedEffect(loginResult) { + loginResult?.user?.role?.let { role -> + onLoginSuccess(role) + } + } + + AuthScreenFrame( + title = "Вход", + action = { + AuthButton( + text = if (loading) "Входим..." else "Войти", + enabled = !loading + ) { + val phoneDigits = normalizeRequiredRussianPhone(phone) + if (phoneDigits == null || password.isBlank()) { + phoneWarning = if (phone.isNotBlank() && !isValidRussianPhoneDigits(phone.trim())) { + PHONE_FORMAT_WARNING + } else null + errorText = "Заполните телефон и пароль" + return@AuthButton + } + errorText = null + viewModel.login(formatPhoneForRequest(phoneDigits), password) + } + }, + footer = { + AuthFooter( + prefix = "Нет аккаунта?", + action = "Зарегистрироваться", + onClick = onSignUp + ) + } + ) { + PhoneField( + value = phone, + onValueChange = { value -> + when { + !isAllowedDigitsInput(value) -> phoneWarning = PHONE_FORMAT_WARNING + value.length > 11 -> phoneWarning = PHONE_FORMAT_WARNING + value.isNotEmpty() && value.first() !in listOf('7', '8') -> phoneWarning = PHONE_FORMAT_WARNING + else -> { + if (value != phone) { + phone = value + phoneWarning = null + } + } + } + }, + label = "Телефон", + warning = phoneWarning + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = password, + onValueChange = { password = it }, + label = "Пароль" + ) + + Spacer(Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + onClick = onForgotPassword, + contentPadding = PaddingValues(0.dp) + ) { + Text( + text = "Забыли пароль?", + color = UrbanBrown + ) + } + } + + AuthStatusText( + text = error ?: errorText, + onTimeout = { + errorText = null + viewModel.clearError() + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/auth/RecoverPasswordScreen.kt b/app/src/main/java/com/example/goodroad/ui/auth/RecoverPasswordScreen.kt new file mode 100644 index 0000000..d412756 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/auth/RecoverPasswordScreen.kt @@ -0,0 +1,187 @@ +package com.example.goodroad.ui.auth + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.goodroad.ui.common.validation.* +import com.example.goodroad.ui.theme.UrbanBrown +import com.example.goodroad.ui.viewmodel.AuthViewModel +@Composable +fun RecoverPasswordScreen( + onLogin: () -> Unit +) { + var firstName by rememberSaveable { mutableStateOf("") } + var lastName by rememberSaveable { mutableStateOf("") } + var phone by rememberSaveable { mutableStateOf("") } + var newPassword by rememberSaveable { mutableStateOf("") } + var confirmPassword by rememberSaveable { mutableStateOf("") } + var firstNameWarning by rememberSaveable { mutableStateOf(null) } + var lastNameWarning by rememberSaveable { mutableStateOf(null) } + var phoneWarning by rememberSaveable { mutableStateOf(null) } + var errorText by rememberSaveable { mutableStateOf(null) } + + val viewModel: AuthViewModel = viewModel() + val recoverResult by viewModel.recoverResult.observeAsState() + val error by viewModel.error.observeAsState() + val successText = if (recoverResult == true) "Пароль успешно восстановлен" else null + + AuthScreenFrame( + title = "Смена пароля", + subtitle = "Для восстановления введите имя, фамилию, номер телефона и новый пароль.", + action = { + AuthButton( + text = "Сменить пароль", + enabled = recoverResult != true + ) { + val firstNameNormalized = normalizeRequiredCyrillic(firstName) + if (firstNameNormalized == null) { + firstNameWarning = CYRILLIC_WARNING + errorText = "Имя обязательно и должно содержать только кириллицу, пробел и -" + return@AuthButton + } + + val lastNameNormalized = normalizeRequiredCyrillic(lastName) + if (lastNameNormalized == null) { + lastNameWarning = CYRILLIC_WARNING + errorText = "Фамилия обязательна и должна содержать только кириллицу, пробел и -" + return@AuthButton + } + + val phoneDigits = normalizeRequiredRussianPhone(phone) + if (phoneDigits == null || newPassword.isBlank() || confirmPassword.isBlank()) { + phoneWarning = PHONE_FORMAT_WARNING + errorText = "Заполните все поля" + return@AuthButton + } + + if (newPassword != confirmPassword) { + errorText = "Пароли не совпадают" + return@AuthButton + } + + errorText = null + viewModel.recoverPassword( + phone = formatPhoneForRequest(phoneDigits), + firstName = firstNameNormalized, + lastName = lastNameNormalized, + newPassword = newPassword + ) + } + }, + footer = { + AuthFooter( + prefix = "Вспомнили пароль?", + action = "Вернуться ко входу", + onClick = onLogin + ) + } + ) { + PlainField( + value = firstName, + onValueChange = { value -> + when { + !isAllowedCyrillicInput(value) -> firstNameWarning = CYRILLIC_WARNING + else -> { + firstName = value + firstNameWarning = null + } + } + }, + label = "Имя", + icon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = UrbanBrown + ) + }, + warning = firstNameWarning, + maxLength = NAME_MAX_LENGTH + ) + + Spacer(Modifier.height(12.dp)) + + PlainField( + value = lastName, + onValueChange = { value -> + when { + !isAllowedCyrillicInput(value) -> lastNameWarning = CYRILLIC_WARNING + else -> { + lastName = value + lastNameWarning = null + } + } + }, + label = "Фамилия", + icon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = UrbanBrown + ) + }, + warning = lastNameWarning, + maxLength = NAME_MAX_LENGTH + ) + + Spacer(Modifier.height(12.dp)) + + PhoneField( + value = phone, + onValueChange = { value -> + when { + !isAllowedDigitsInput(value) -> phoneWarning = PHONE_CHARS_WARNING + value.length > 11 || value.isNotEmpty() && value.first() !in listOf('7', '8') -> + phoneWarning = PHONE_FORMAT_WARNING + else -> { + phone = value + phoneWarning = null + } + } + }, + label = "Телефон", + warning = phoneWarning + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = newPassword, + onValueChange = { newPassword = it }, + label = "Новый пароль" + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + label = "Подтвердите пароль" + ) + + AuthStatusText( + text = error ?: errorText, + onTimeout = { + errorText = null + viewModel.clearError() + } + ) + AuthSuccessText( + text = successText, + onTimeout = { + viewModel.clearRecoverResult() + } + ) + } +} diff --git a/app/src/main/java/com/example/goodroad/ui/auth/RegisterScreen.kt b/app/src/main/java/com/example/goodroad/ui/auth/RegisterScreen.kt new file mode 100644 index 0000000..78ddeef --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/auth/RegisterScreen.kt @@ -0,0 +1,202 @@ +package com.example.goodroad.ui.auth + +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material3.Icon +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.goodroad.BuildConfig +import com.example.goodroad.ui.common.validation.* +import com.example.goodroad.ui.theme.UrbanBrown +import com.example.goodroad.ui.viewmodel.AuthViewModel +@Composable +fun RegisterScreen( + onRegisterSuccess: (String) -> Unit, + onLogin: () -> Unit +) { + var firstName by rememberSaveable { mutableStateOf("") } + var lastName by rememberSaveable { mutableStateOf("") } + var phone by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var confirmPassword by rememberSaveable { mutableStateOf("") } + var firstNameWarning by rememberSaveable { mutableStateOf(null) } + var lastNameWarning by rememberSaveable { mutableStateOf(null) } + var phoneWarning by rememberSaveable { mutableStateOf(null) } + var errorText by rememberSaveable { mutableStateOf(null) } + + val viewModel: AuthViewModel = viewModel() + val registerResult by viewModel.loginResult.observeAsState() + val error by viewModel.error.observeAsState() + val loading by viewModel.isLoading.observeAsState(initial = false) + + LaunchedEffect(registerResult) { + registerResult?.user?.role?.let { role -> + onRegisterSuccess(role) + } + } + + AuthScreenFrame( + title = "Создать аккаунт", + action = { + AuthButton( + text = if (loading) "Создаем..." else "Зарегистрироваться", + enabled = !loading + ) { + val firstNameNormalized = normalizeRequiredCyrillic(firstName) + if (firstNameNormalized == null) { + firstNameWarning = CYRILLIC_WARNING + errorText = "Имя обязательно и должно содержать только кириллицу, пробел и -" + return@AuthButton + } + + val lastNameNormalized = normalizeRequiredCyrillic(lastName) + if (lastNameNormalized == null) { + lastNameWarning = CYRILLIC_WARNING + errorText = "Фамилия обязательна и должна содержать только кириллицу, пробел и -" + return@AuthButton + } + + val phoneDigits = normalizeRequiredRussianPhone(phone) + if (phoneDigits == null || password.isBlank()) { + phoneWarning = PHONE_FORMAT_WARNING + errorText = "Телефон и пароль обязательны" + return@AuthButton + } + + if (password != confirmPassword) { + errorText = "Пароли не совпадают" + return@AuthButton + } + + if (BuildConfig.MOCK_AUTH) { + errorText = null + onRegisterSuccess("USER") + return@AuthButton + } + + viewModel.register( + firstNameNormalized, + lastNameNormalized, + formatPhoneForRequest(phoneDigits), + password + ) + } + }, + footer = { + AuthFooter( + prefix = "Уже есть аккаунт?", + action = "Войти", + onClick = onLogin + ) + } + ) { + PlainField( + value = firstName, + onValueChange = { value -> + when { + !isAllowedCyrillicInput(value) -> { + firstNameWarning = CYRILLIC_WARNING + } + value != firstName -> { + firstName = value + firstNameWarning = null + } + } + }, + label = "Имя", + icon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = UrbanBrown + ) + }, + warning = firstNameWarning, + maxLength = NAME_MAX_LENGTH + ) + + Spacer(Modifier.height(12.dp)) + + PlainField( + value = lastName, + onValueChange = { value -> + when { + !isAllowedCyrillicInput(value) -> { + lastNameWarning = CYRILLIC_WARNING + } + value != lastName -> { + lastName = value + lastNameWarning = null + } + } + }, + label = "Фамилия", + icon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = UrbanBrown + ) + }, + warning = lastNameWarning, + maxLength = NAME_MAX_LENGTH + ) + + Spacer(Modifier.height(12.dp)) + + PhoneField( + value = phone, + onValueChange = { value -> + when { + !isAllowedDigitsInput(value) -> { + phoneWarning = PHONE_CHARS_WARNING + } + value.length > 11 -> { + phoneWarning = PHONE_FORMAT_WARNING + } + value.isNotEmpty() && value.first() !in listOf('7', '8') -> { + phoneWarning = PHONE_FORMAT_WARNING + } + else -> { + if (value != phone) { + phone = value + phoneWarning = null + } + } + } + }, + label = "Телефон", + warning = phoneWarning + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = password, + onValueChange = { password = it }, + label = "Пароль" + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = confirmPassword, + onValueChange = { confirmPassword = it }, + label = "Подтвердите пароль" + ) + + AuthStatusText( + text = error ?: errorText, + onTimeout = { + errorText = null + viewModel.clearError() + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/auth/RoleStubScreen.kt b/app/src/main/java/com/example/goodroad/ui/auth/RoleStubScreen.kt new file mode 100644 index 0000000..b8bafc3 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/auth/RoleStubScreen.kt @@ -0,0 +1,39 @@ +package com.example.goodroad.ui.auth + +import androidx.compose.foundation.background +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.* +import com.example.goodroad.ui.theme.* +@Composable +fun RoleStubScreen( + title: String, + onLogout: () -> Unit +) { + Box( + modifier = Modifier + .fillMaxSize() + .background(BackgroundLight) + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + color = TextPrimary + ) + Spacer(Modifier.height(20.dp)) + AuthButton( + text = "Выйти", + onClick = onLogout + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/auth/Routes.kt b/app/src/main/java/com/example/goodroad/ui/auth/Routes.kt new file mode 100644 index 0000000..ef5dd53 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/auth/Routes.kt @@ -0,0 +1,15 @@ +package com.example.goodroad.ui.auth + +const val LOGIN_ROUTE = "login" +const val REGISTER_ROUTE = "register" +const val RECOVER_ROUTE = "recover" +const val USER_HOME_ROUTE = "user_home" +const val MODERATOR_HOME_ROUTE = "moderator_home" + +fun homeRoute(role: String): String { + return if (role.startsWith("MODERATOR")) { + MODERATOR_HOME_ROUTE + } else { + USER_HOME_ROUTE + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/common/validation/UserValidators.kt b/app/src/main/java/com/example/goodroad/ui/common/validation/UserValidators.kt new file mode 100644 index 0000000..d8b79e5 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/common/validation/UserValidators.kt @@ -0,0 +1,49 @@ +package com.example.goodroad.ui.common.validation + +private val cyrillicInputRegex = Regex("^[\\p{IsCyrillic} -]*$") +private val digitsInputRegex = Regex("^\\d*$") +private val cyrillicValueRegex = Regex("^(?=.*\\p{IsCyrillic})[\\p{IsCyrillic} -]+$") +private val russianPhoneDigitsRegex = Regex("^[78]\\d{10}$") + +const val CYRILLIC_WARNING = "Допустимы только кириллица, пробел и -" +const val PHONE_CHARS_WARNING = "Допустимы только цифры. Знак + добавляется автоматически" +const val PHONE_FORMAT_WARNING = "Введите российский номер: 11 цифр, первая — 7 или 8" + +const val NAME_MAX_LENGTH = 80 +const val PHONE_MAX_LENGTH = 11 +const val PASSWORD_MAX_LENGTH = 100 +const val PLACE_NAME_MAX_LENGTH = 180 +const val COMMENT_MAX_LENGTH = 1000 +const val COORDINATE_MAX_LENGTH = 20 + +fun isAllowedCyrillicInput(value: String): Boolean { + return cyrillicInputRegex.matches(value) +} + +fun isAllowedDigitsInput(value: String): Boolean { + return digitsInputRegex.matches(value) +} + +fun isValidRussianPhoneDigits(value: String): Boolean { + return russianPhoneDigitsRegex.matches(value) +} + +fun normalizeRequiredCyrillic(value: String): String? { + val normalized = value.trim() + if (normalized.isEmpty()) { + return null + } + return if (cyrillicValueRegex.matches(normalized)) normalized else null +} + +fun normalizeRequiredRussianPhone(value: String): String? { + val normalized = value.trim() + if (normalized.isEmpty()) { + return null + } + return if (isValidRussianPhoneDigits(normalized)) normalized else null +} + +fun formatPhoneForRequest(phoneDigits: String): String { + return "+$phoneDigits" +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/maps/MapsNav.kt b/app/src/main/java/com/example/goodroad/ui/maps/MapsNav.kt new file mode 100644 index 0000000..b506a74 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/maps/MapsNav.kt @@ -0,0 +1,34 @@ +package com.example.goodroad.ui.maps + +import androidx.compose.runtime.* +import androidx.lifecycle.* +import androidx.lifecycle.viewmodel.compose.* +import com.example.goodroad.data.network.* +import com.example.goodroad.data.obstacle.* +import com.example.goodroad.ui.viewmodel.* + +@Composable +fun MapsNav( + onBackToProfile: () -> Unit, + onSaved: () -> Unit +) { + val api = ApiClient.obstacleApi + + val factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(MapsViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return MapsViewModel(ObstacleRepository(api)) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } + + val mapsViewModel: MapsViewModel = viewModel(factory = factory) + + ObstacleSelectScreen( + mapsViewModel = mapsViewModel, + onBackToProfile = onBackToProfile, + onSaved = onSaved + ) +} diff --git a/app/src/main/java/com/example/goodroad/ui/maps/ObstacleSelectScreen.kt b/app/src/main/java/com/example/goodroad/ui/maps/ObstacleSelectScreen.kt new file mode 100644 index 0000000..e70bfec --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/maps/ObstacleSelectScreen.kt @@ -0,0 +1,261 @@ +package com.example.goodroad.ui.maps + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.unit.* +import com.example.goodroad.data.obstacle.* +import com.example.goodroad.ui.auth.* +import com.example.goodroad.ui.theme.* +import com.example.goodroad.ui.user.* +import com.example.goodroad.ui.viewmodel.* + +data class ObstacleOption( + val obstacleType: String, + val title: String +) + +private val ServerObstacleOptions = listOf( + ObstacleOption("CURB", "Бордюры"), + ObstacleOption("STAIRS", "Лестницы"), + ObstacleOption("ROAD_SLOPE", "Наклон дороги"), + ObstacleOption("POTHOLES", "Ямы"), + ObstacleOption("SAND", "Песок"), + ObstacleOption("GRAVEL", "Гравий") +) + +@Composable +fun ObstacleSelectScreen( + mapsViewModel: MapsViewModel, + onBackToProfile: () -> Unit, + onSaved: () -> Unit +) { + val policies by mapsViewModel.policies + val isLoading by mapsViewModel.isLoading + val isSaving by mapsViewModel.isSaving + val errorMessage by mapsViewModel.errorMessage + val successMessage by mapsViewModel.successMessage + + val selectedMap = remember { + mutableStateMapOf().apply { + ServerObstacleOptions.forEach { put(it.obstacleType, false) } + } + } + + val severityMap = remember { + mutableStateMapOf().apply { + ServerObstacleOptions.forEach { put(it.obstacleType, 1) } + } + } + + LaunchedEffect(Unit) { + mapsViewModel.loadPolicies() + } + + LaunchedEffect(policies) { + if (policies.isNotEmpty()) { + policies.forEach { item -> + selectedMap[item.obstacleType] = item.selected + severityMap[item.obstacleType] = item.maxAllowedSeverity?.toInt() ?: 1 + } + } + } + + val scrollState = rememberScrollState() + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(24.dp) + ) { + UserDecor() + + Text( + text = "Выбор препятствий", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary + ) + + Spacer(modifier = Modifier.height(20.dp)) + + Text( + text = "Отметьте препятствия, которых нужно избегать, и укажите максимальную допустимую тяжесть.", + style = MaterialTheme.typography.bodyLarge, + color = UrbanBrown + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "1 — слабая тяжесть, 2 — средняя тяжесть, 3 — сильная тяжесть.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + + Spacer(modifier = Modifier.height(20.dp)) + + if (isLoading && policies.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = SafeGreen) + } + } + + ServerObstacleOptions.forEach { obstacle -> + val checked = selectedMap[obstacle.obstacleType] == true + val severity = severityMap[obstacle.obstacleType] ?: 1 + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Checkbox( + checked = checked, + onCheckedChange = { isChecked -> + selectedMap[obstacle.obstacleType] = isChecked + mapsViewModel.clearMessages() + }, + colors = CheckboxDefaults.colors( + checkedColor = UrbanBrown, + uncheckedColor = UrbanBrown, + checkmarkColor = WhiteSoft + ) + ) + + Spacer(modifier = Modifier.width(10.dp)) + + Text( + text = obstacle.title, + style = MaterialTheme.typography.bodyLarge, + color = UrbanBrown + ) + } + + if (checked) { + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Максимальная допустимая тяжесть", + style = MaterialTheme.typography.bodyMedium, + color = UrbanBrown + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + (1..3).forEach { value -> + FilterChip( + selected = severity == value, + onClick = { + severityMap[obstacle.obstacleType] = value + mapsViewModel.clearMessages() + }, + label = { + Text( + text = value.toString(), + style = MaterialTheme.typography.bodyLarge + ) + }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = SafeGreen.copy(alpha = 0.18f), + selectedLabelColor = SafeGreen, + containerColor = BackgroundLight, + labelColor = UrbanBrown + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = severity == value, + borderColor = if (severity == value) SafeGreen else BorderWarm, + selectedBorderColor = SafeGreen + ) + ) + } + } + } + } + } + + AuthStatusText( + text = errorMessage, + onTimeout = mapsViewModel::clearMessages + ) + AuthSuccessText( + text = successMessage, + onTimeout = mapsViewModel::clearMessages + ) + + Spacer(modifier = Modifier.height(30.dp)) + + AuthButton( + text = if (isSaving) "Сохраняем..." else "Сохранить", + enabled = !isSaving && !isLoading + ) { + val items = ServerObstacleOptions.map { obstacle -> + val selected = selectedMap[obstacle.obstacleType] == true + ObstaclePolicyItem( + obstacleType = obstacle.obstacleType, + selected = selected, + maxAllowedSeverity = if (selected) { + (severityMap[obstacle.obstacleType] ?: 1).toShort() + } else { + null + } + ) + } + + mapsViewModel.savePolicies(items) { + onSaved() + } + } + + Spacer(modifier = Modifier.height(10.dp)) + + AuthButton( + text = "Назад в профиль", + backgroundColor = UrbanBrown, + contentColor = WhiteSoft, + enabled = !isSaving + ) { + onBackToProfile() + } + } + + Box( + modifier = Modifier + .fillMaxHeight() + .width(6.dp) + .align(Alignment.CenterEnd) + .background(UrbanBrown.copy(alpha = 0.25f)) + ) + + Box( + modifier = Modifier + .width(6.dp) + .height(60.dp) + .offset(y = (scrollState.value * 0.2f).dp) + .align(Alignment.TopEnd) + .background(UrbanBrown) + ) + } + } +} diff --git a/app/src/main/java/com/example/goodroad/ui/reviews/ReviewDetailsScreen.kt b/app/src/main/java/com/example/goodroad/ui/reviews/ReviewDetailsScreen.kt new file mode 100644 index 0000000..d87692d --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/reviews/ReviewDetailsScreen.kt @@ -0,0 +1,164 @@ +package com.example.goodroad.ui.reviews + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.unit.* +import com.example.goodroad.data.review.* +import com.example.goodroad.ui.auth.* +import com.example.goodroad.ui.theme.* +import com.example.goodroad.ui.user.* +import com.example.goodroad.ui.viewmodel.* + +@Composable +fun ReviewDetailsScreen( + review: ReviewCardResp, + reviewsViewModel: ReviewsViewModel, + onBack: () -> Unit, + onEdit: () -> Unit, + onDeleted: () -> Unit +) { + val isSubmitting by reviewsViewModel.isSubmitting + val errorMessage by reviewsViewModel.errorMessage + var showDeleteDialog by remember { mutableStateOf(false) } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { + Text( + text = "Вы уверены?", + color = TextPrimary + ) + }, + text = { + Text( + text = "Отзыв будет удален без возможности восстановления.", + color = UrbanBrown + ) + }, + confirmButton = { + TextButton( + onClick = { + showDeleteDialog = false + reviewsViewModel.deleteReview(review.id, onDeleted) + } + ) { + Text("Да", color = UrbanBrown) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("Нет", color = UrbanBrown) + } + } + ) + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp) + ) { + UserDecor() + + Text( + text = "Подробности отзыва", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary + ) + + Spacer(Modifier.height(20.dp)) + + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + border = BorderStroke(2.dp, moderationStatusColor(review.status)), + colors = CardDefaults.outlinedCardColors(containerColor = BackgroundLight) + ) { + Column(modifier = Modifier.padding(16.dp)) { + ReviewCardSummary(review) + + Spacer(Modifier.height(16.dp)) + ReviewInfoRow("Координаты", "${review.latitude}, ${review.longitude}") + Spacer(Modifier.height(12.dp)) + ReviewInfoRow("Комментарий", review.comment?.ifBlank { "—" } ?: "—") + Spacer(Modifier.height(12.dp)) + ReviewInfoRow( + "Комментарий модератора", + review.moderatorComment?.ifBlank { "—" } ?: "—" + ) + + Spacer(Modifier.height(16.dp)) + Text( + text = "Фотографии", + style = MaterialTheme.typography.titleMedium, + color = UrbanBrown + ) + Spacer(Modifier.height(8.dp)) + ReviewPhotosStrip(review.photoUrls) + + Spacer(Modifier.height(16.dp)) + Text( + text = "Препятствия", + style = MaterialTheme.typography.titleMedium, + color = UrbanBrown + ) + Spacer(Modifier.height(8.dp)) + review.obstacles.forEach { obstacle -> + Text( + text = "${obstacleLabel(obstacle.obstacleType)}: ${obstacleSeverityText(obstacle.severity.toInt())}", + style = MaterialTheme.typography.bodyLarge, + color = TextPrimary + ) + Spacer(Modifier.height(4.dp)) + } + } + } + + AuthStatusText( + text = errorMessage, + onTimeout = reviewsViewModel::clearErrorMessage + ) + + Spacer(Modifier.height(20.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + ReviewSquareActionButton( + text = "Редактировать", + backgroundColor = SafeGreen, + modifier = Modifier.weight(1f) + ) { + onEdit() + } + ReviewSquareActionButton( + text = if (isSubmitting) "Удаляем..." else "Удалить", + backgroundColor = AlertRed, + modifier = Modifier.weight(1f), + enabled = !isSubmitting + ) { + showDeleteDialog = true + } + } + + Spacer(Modifier.height(12.dp)) + + ReviewSquareActionButton( + text = "Назад к отзывам", + backgroundColor = UrbanBrown, + modifier = Modifier.fillMaxWidth() + ) { + onBack() + } + } + } +} diff --git a/app/src/main/java/com/example/goodroad/ui/reviews/ReviewFormScreen.kt b/app/src/main/java/com/example/goodroad/ui/reviews/ReviewFormScreen.kt new file mode 100644 index 0000000..d8a9e4e --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/reviews/ReviewFormScreen.kt @@ -0,0 +1,450 @@ +package com.example.goodroad.ui.reviews + +import android.content.* +import android.location.* +import androidx.activity.compose.* +import androidx.activity.result.contract.* +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.* +import androidx.compose.material.icons.* +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.platform.* +import androidx.compose.ui.text.input.* +import androidx.compose.ui.unit.* +import com.example.goodroad.data.review.* +import com.example.goodroad.ui.auth.* +import com.example.goodroad.ui.common.validation.* +import com.example.goodroad.ui.theme.* +import com.example.goodroad.ui.user.* +import com.example.goodroad.ui.viewmodel.* +import kotlinx.coroutines.* +import java.util.* + +@Composable +fun ReviewFormScreen( + reviewsViewModel: ReviewsViewModel, + initialReview: ReviewCardResp?, + onBack: () -> Unit, + onSaved: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val isEdit = initialReview != null + val reviewKey = initialReview?.id ?: "new" + + var placeName by remember(reviewKey) { mutableStateOf(initialReview?.address?.placeName ?: "") } + var latitude by remember(reviewKey) { mutableStateOf(initialReview?.latitude?.toString() ?: "") } + var longitude by remember(reviewKey) { mutableStateOf(initialReview?.longitude?.toString() ?: "") } + var rating by remember(reviewKey) { mutableStateOf(initialReview?.rating?.toInt()) } + var comment by remember(reviewKey) { mutableStateOf(initialReview?.comment ?: "") } + var formError by remember(reviewKey) { mutableStateOf(null) } + val photoUrls = remember(reviewKey) { + mutableStateListOf().apply { + addAll(initialReview?.photoUrls.orEmpty()) + } + } + + val obstacleSeverities = remember(reviewKey) { + mutableStateMapOf().apply { + ReviewObstacleTypes.forEach { type -> + val current = initialReview?.obstacles + ?.firstOrNull { it.obstacleType == type } + ?.severity + ?.toInt() + ?: 0 + put(type, current) + } + } + } + + val isSubmitting by reviewsViewModel.isSubmitting + val isPhotoUploading by reviewsViewModel.isPhotoUploading + val serverError by reviewsViewModel.errorMessage + + var isPreparingSubmit by remember(reviewKey) { mutableStateOf(false) } + val submitInProgress = isSubmitting || isPreparingSubmit + + LaunchedEffect(isSubmitting) { + if (isSubmitting) { + isPreparingSubmit = false + } + } + + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris -> + if (uris.isNotEmpty()) { + reviewsViewModel.uploadReviewPhotos(context, uris) { uploadedUrls -> + photoUrls.addAll(uploadedUrls) + } + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp) + ) { + UserDecor() + + Text( + text = if (isEdit) "Редактирование отзыва" else "Новый отзыв", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary + ) + + Spacer(Modifier.height(20.dp)) + + PlainField( + value = placeName, + onValueChange = { placeName = it }, + label = "Название места", + maxLength = PLACE_NAME_MAX_LENGTH + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Название места можно не заполнять.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + PlainField( + value = latitude, + onValueChange = { latitude = it }, + label = "Широта", + maxLength = COORDINATE_MAX_LENGTH, + modifier = Modifier.weight(1f), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) + ) + + PlainField( + value = longitude, + onValueChange = { longitude = it }, + label = "Долгота", + maxLength = COORDINATE_MAX_LENGTH, + modifier = Modifier.weight(1f), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal) + ) + } + + Spacer(Modifier.height(12.dp)) + + Text( + text = "Адрес будет определен автоматически по введенным координатам.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Координаты должны быть в числовом формате. Можно использовать точку или запятую.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + + Spacer(Modifier.height(20.dp)) + + Text( + text = "Оценка", + style = MaterialTheme.typography.titleMedium, + color = UrbanBrown + ) + Spacer(Modifier.height(8.dp)) + SeveritySelector( + value = rating, + range = 1..5, + onValueChange = { rating = it } + ) + + Spacer(Modifier.height(20.dp)) + + Text( + text = "Препятствия и их тяжесть", + style = MaterialTheme.typography.titleMedium, + color = UrbanBrown + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "0 — нет такого препятствия, 1 — слабая тяжесть, 2 — средняя тяжесть, 3 — сильная тяжесть.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + Spacer(Modifier.height(4.dp)) + Text( + text = "Хотя бы у одного препятствия тяжесть должна быть больше 0.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + + ReviewObstacleTypes.forEach { type -> + Spacer(Modifier.height(12.dp)) + Text( + text = obstacleLabel(type), + style = MaterialTheme.typography.bodyLarge, + color = TextPrimary + ) + Spacer(Modifier.height(6.dp)) + SeveritySelector( + value = obstacleSeverities[type] ?: 0, + range = 0..3, + onValueChange = { obstacleSeverities[type] = it } + ) + } + + Spacer(Modifier.height(20.dp)) + + TextField( + value = comment, + onValueChange = { value -> + if (value.length <= COMMENT_MAX_LENGTH) { + comment = value + } + }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 56.dp), + label = { + Text( + text = "Комментарий", + color = UrbanBrown + ) + }, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = TextPrimary), + singleLine = false, + minLines = 1, + maxLines = 5, + colors = TextFieldDefaults.colors( + focusedContainerColor = BackgroundLight, + unfocusedContainerColor = BackgroundLight, + focusedIndicatorColor = SafeGreen, + unfocusedIndicatorColor = BorderWarm, + focusedLabelColor = UrbanBrown, + unfocusedLabelColor = UrbanBrown, + cursorColor = SafeGreen + ) + ) + + Spacer(Modifier.height(16.dp)) + + OutlinedButton( + onClick = { photoPickerLauncher.launch("image/*") }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = BackgroundLight, + contentColor = UrbanBrown + ), + border = BorderStroke(1.dp, BorderWarm) + ) { + Icon( + imageVector = Icons.Default.Photo, + contentDescription = null, + tint = UrbanBrown + ) + Spacer(Modifier.width(12.dp)) + Text( + text = if (isPhotoUploading) "Загружаем фото..." else "Добавить фотографии", + color = UrbanBrown + ) + } + + Spacer(Modifier.height(12.dp)) + + ReviewPhotosStrip( + photoUrls = photoUrls, + onRemove = { photoUrls.remove(it) } + ) + + AuthStatusText( + text = formError ?: serverError, + onTimeout = { + formError = null + reviewsViewModel.clearMessages() + } + ) + + Spacer(Modifier.height(20.dp)) + + AuthButton( + text = when { + isPhotoUploading -> "Загружаем фото..." + submitInProgress -> "Сохраняем..." + isEdit -> "Сохранить изменения" + else -> "Отправить отзыв" + }, + enabled = !submitInProgress && !isPhotoUploading + ) { + scope.launch { + isPreparingSubmit = true + var sentToViewModel = false + + try { + val validationError = validateReviewForm( + latitude = latitude, + longitude = longitude, + rating = rating, + obstacleSeverities = obstacleSeverities + ) + + if (validationError != null) { + formError = validationError + return@launch + } + + val lat = latitude.trim().replace(',', '.').toDouble() + val lon = longitude.trim().replace(',', '.').toDouble() + + val generatedAddress = resolveReviewAddress( + context = context, + latitude = lat, + longitude = lon, + placeName = placeName, + fallbackAddress = initialReview?.address + ) + + formError = null + reviewsViewModel.clearMessages() + + val request = UpsertReviewReq( + latitude = lat, + longitude = lon, + address = generatedAddress, + rating = rating!!.toShort(), + obstacles = ReviewObstacleTypes.map { type -> + ReviewObstacle( + obstacleType = type, + severity = (obstacleSeverities[type] ?: 0).toShort() + ) + }, + comment = comment.trim().ifBlank { null }, + photoUrls = photoUrls.toList() + ) + + sentToViewModel = true + + if (isEdit) { + reviewsViewModel.updateReview(initialReview!!.id, request, onSaved) + } else { + reviewsViewModel.createReview(request, onSaved) + } + } finally { + if (!sentToViewModel) { + isPreparingSubmit = false + } + } + } + } + + Spacer(Modifier.height(10.dp)) + + AuthButton( + text = "Назад к отзывам", + backgroundColor = UrbanBrown + ) { + onBack() + } + } + } +} + +private fun validateReviewForm( + latitude: String, + longitude: String, + rating: Int?, + obstacleSeverities: Map +): String? { + val lat = latitude.trim().replace(',', '.').toDoubleOrNull() + val lon = longitude.trim().replace(',', '.').toDoubleOrNull() + if (lat == null || lon == null) { + return "Введите корректные координаты" + } + if (lat < -90.0 || lat > 90.0 || lon < -180.0 || lon > 180.0) { + return "Координаты выходят за допустимый диапазон" + } + if (rating == null) { + return "Поставьте оценку отзыву" + } + if (obstacleSeverities.values.none { it > 0 }) { + return "Укажите тяжесть хотя бы для одного препятствия" + } + return null +} + +private suspend fun resolveReviewAddress( + context: Context, + latitude: Double, + longitude: Double, + placeName: String, + fallbackAddress: ReviewAddress? +): ReviewAddress = withContext(Dispatchers.IO) { + val normalizedPlaceName = placeName.trim().ifBlank { null } + val baseAddress = fallbackAddress ?: ReviewAddress( + country = "Россия", + region = "Регион не указан", + localityType = "город", + city = "Населенный пункт не указан", + street = "Улица не указана", + house = "Без номера", + placeName = normalizedPlaceName + ) + val geocoder = Geocoder(context, Locale("ru")) + + return@withContext try { + val rawAddress = geocoder.getFromLocation(latitude, longitude, 1)?.firstOrNull() + if (rawAddress == null) { + baseAddress.copy(placeName = normalizedPlaceName) + } else { + ReviewAddress( + country = rawAddress.countryName?.takeIf { it.isNotBlank() } + ?: baseAddress.country, + region = listOf(rawAddress.adminArea, rawAddress.subAdminArea) + .firstNotBlank() + ?: baseAddress.region, + localityType = detectLocalityType(rawAddress, baseAddress), + city = listOf(rawAddress.locality, rawAddress.subLocality, rawAddress.subAdminArea, rawAddress.adminArea) + .firstNotBlank() + ?: baseAddress.city, + street = listOf(rawAddress.thoroughfare, rawAddress.subLocality, rawAddress.featureName) + .firstNotBlank() + ?: baseAddress.street, + house = listOf(rawAddress.subThoroughfare, rawAddress.premises) + .firstNotBlank() + ?: baseAddress.house, + placeName = normalizedPlaceName + ) + } + } catch (_: Exception) { + baseAddress.copy(placeName = normalizedPlaceName) + } +} + +private fun List.firstNotBlank(): String? { + return firstOrNull { !it.isNullOrBlank() }?.trim() +} + +private fun detectLocalityType(address: Address, fallbackAddress: ReviewAddress): String { + return when { + !address.locality.isNullOrBlank() -> "город" + !address.subAdminArea.isNullOrBlank() -> "район" + else -> fallbackAddress.localityType + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/reviews/ReviewUi.kt b/app/src/main/java/com/example/goodroad/ui/reviews/ReviewUi.kt new file mode 100644 index 0000000..055680a --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/reviews/ReviewUi.kt @@ -0,0 +1,297 @@ +package com.example.goodroad.ui.reviews + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.draw.* +import androidx.compose.ui.graphics.* +import androidx.compose.ui.layout.* +import androidx.compose.ui.text.font.* +import androidx.compose.ui.text.style.* +import androidx.compose.ui.unit.* +import coil.compose.* +import com.example.goodroad.data.review.* +import com.example.goodroad.ui.theme.* +import java.time.* +import java.time.format.* + +val ReviewObstacleTypes = listOf( + "CURB", + "STAIRS", + "ROAD_SLOPE", + "POTHOLES", + "SAND", + "GRAVEL" +) + +fun obstacleLabel(type: String): String { + return when (type) { + "CURB" -> "Бордюр" + "STAIRS" -> "Лестницы" + "ROAD_SLOPE" -> "Наклон дороги" + "POTHOLES" -> "Ямы" + "SAND" -> "Песок" + "GRAVEL" -> "Гравий" + else -> type + } +} + +fun obstacleSeverityText(severity: Int): String { + return when (severity) { + 0 -> "нет" + 1 -> "слабая тяжесть" + 2 -> "средняя тяжесть" + 3 -> "сильная тяжесть" + else -> severity.toString() + } +} + +fun moderationStatusText(status: String): String { + return when (status) { + "APPROVED" -> "Одобрен" + "REJECTED" -> "Отклонен" + else -> "На модерации" + } +} + +fun moderationStatusColor(status: String): Color { + return when (status) { + "APPROVED" -> SafeGreen + "REJECTED" -> AlertRed + else -> UrbanBrown + } +} + +fun buildAddressLine(address: ReviewAddress): String { + val parts = mutableListOf() + + address.country + ?.takeIf { it.isNotBlank() } + ?.let { parts += it } + + address.region + ?.takeIf { it.isNotBlank() } + ?.let { parts += it } + + val locality = listOfNotNull( + address.localityType?.takeIf { it.isNotBlank() }, + address.city?.takeIf { it.isNotBlank() } + ).joinToString(" ") + + if (locality.isNotBlank()) { + parts += locality + } + + address.street + ?.takeIf { it.isNotBlank() } + ?.let { parts += it } + + address.house + ?.takeIf { it.isNotBlank() } + ?.let { parts += it } + + address.placeName + ?.takeIf { it.isNotBlank() } + ?.let { parts += it } + + return parts.joinToString(", ") +} + +fun formatReviewDate(raw: String): String { + return try { + val instant = Instant.parse(raw) + val formatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + formatter.format(instant.atZone(ZoneId.systemDefault())) + } catch (_: Exception) { + raw.take(10) + } +} + +@Composable +fun ReviewInfoRow(label: String, value: String) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + Text( + text = value, + style = MaterialTheme.typography.bodyLarge, + color = TextPrimary + ) + } +} + +@Composable +fun SeveritySelector( + value: Int?, + range: IntRange, + onValueChange: (Int) -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + range.forEach { item -> + val selected = item == value + OutlinedButton( + onClick = { onValueChange(item) }, + border = BorderStroke(1.dp, if (selected) SafeGreen else BorderWarm), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (selected) SafeGreen.copy(alpha = 0.12f) else Color.Transparent, + contentColor = if (selected) SafeGreen else UrbanBrown + ), + shape = RoundedCornerShape(20.dp), + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(horizontal = 0.dp, vertical = 12.dp) + ) { + Text(item.toString()) + } + } + } +} + +@Composable +fun ReviewStatusBadge(status: String) { + val color = moderationStatusColor(status) + Surface( + color = color.copy(alpha = 0.08f), + contentColor = color, + shape = RoundedCornerShape(18.dp), + border = BorderStroke(1.dp, color) + ) { + Box( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = moderationStatusText(status), + style = MaterialTheme.typography.bodyMedium + ) + } + } +} + +@Composable +fun ReviewCardSummary(review: ReviewCardResp) { + Column(modifier = Modifier.fillMaxWidth()) { + ReviewInfoRow("Адрес", buildAddressLine(review.address)) + Spacer(Modifier.height(8.dp)) + ReviewInfoRow("Оценка", review.rating.toString()) + Spacer(Modifier.height(8.dp)) + ReviewInfoRow("Баллы", review.awardedPoints.toString()) + Spacer(Modifier.height(8.dp)) + ReviewInfoRow("Дата", formatReviewDate(review.createdAt)) + Spacer(Modifier.height(12.dp)) + ReviewStatusBadge(review.status) + } +} + +@Composable +fun ReviewActionButton( + text: String, + modifier: Modifier = Modifier, + backgroundColor: Color = SafeGreen, + enabled: Boolean = true, + onClick: () -> Unit +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.height(56.dp), + colors = ButtonDefaults.buttonColors( + containerColor = backgroundColor, + contentColor = WhiteSoft, + disabledContainerColor = backgroundColor.copy(alpha = 0.6f), + disabledContentColor = WhiteSoft + ), + shape = RoundedCornerShape(16.dp) + ) { + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + softWrap = false, + textAlign = TextAlign.Center + ) + } +} + +@Composable +fun ReviewSquareActionButton( + text: String, + modifier: Modifier = Modifier, + backgroundColor: Color = SafeGreen, + enabled: Boolean = true, + onClick: () -> Unit +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.height(64.dp), + colors = ButtonDefaults.buttonColors( + containerColor = backgroundColor, + contentColor = WhiteSoft, + disabledContainerColor = backgroundColor.copy(alpha = 0.6f), + disabledContentColor = WhiteSoft + ), + shape = RoundedCornerShape(18.dp) + ) { + Text( + text = text, + fontSize = 17.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + softWrap = false, + textAlign = TextAlign.Center + ) + } +} + +@Composable +fun ReviewPhotosStrip( + photoUrls: List, + onRemove: ((String) -> Unit)? = null +) { + if (photoUrls.isEmpty()) { + Text( + text = "Фото не добавлены", + style = MaterialTheme.typography.bodyMedium, + color = UrbanBrown + ) + return + } + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + photoUrls.forEach { url -> + Column { + AsyncImage( + model = url, + contentDescription = null, + modifier = Modifier + .size(120.dp) + .clip(RoundedCornerShape(16.dp)), + contentScale = ContentScale.Crop + ) + if (onRemove != null) { + TextButton(onClick = { onRemove(url) }) { + Text( + text = "Убрать", + color = UrbanBrown + ) + } + } + } + } + } +} diff --git a/app/src/main/java/com/example/goodroad/ui/reviews/UserReviewsScreen.kt b/app/src/main/java/com/example/goodroad/ui/reviews/UserReviewsScreen.kt new file mode 100644 index 0000000..72bbfe5 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/reviews/UserReviewsScreen.kt @@ -0,0 +1,192 @@ +package com.example.goodroad.ui.reviews + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.* +import androidx.compose.ui.unit.* +import com.example.goodroad.data.review.* +import com.example.goodroad.ui.auth.* +import com.example.goodroad.ui.theme.* +import com.example.goodroad.ui.user.* +import com.example.goodroad.ui.viewmodel.* + +@Composable +fun UserReviewsScreen( + reviewsViewModel: ReviewsViewModel, + onBack: () -> Unit, + onAddReview: () -> Unit, + onOpenDetails: (ReviewCardResp) -> Unit, + onEditReview: (ReviewCardResp) -> Unit +) { + val reviews by reviewsViewModel.reviews + val isLoading by reviewsViewModel.isLoading + val errorMessage by reviewsViewModel.errorMessage + val successMessage by reviewsViewModel.successMessage + + val approvedCount = reviews.count { it.status == "APPROVED" } + val rejectedCount = reviews.count { it.status == "REJECTED" } + val pendingCount = reviews.count { it.status == "PENDING" } + + LaunchedEffect(Unit) { + reviewsViewModel.loadReviews() + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + UserDecor() + + Text( + text = "Мои отзывы", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary + ) + + Spacer(Modifier.height(20.dp)) + + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + border = BorderStroke(1.dp, UrbanBrown.copy(alpha = 0.4f)), + colors = CardDefaults.outlinedCardColors( + containerColor = UrbanBrown.copy(alpha = 0.06f) + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Статистика по отзывам", + style = MaterialTheme.typography.titleMedium, + color = UrbanBrown + ) + Spacer(Modifier.height(8.dp)) + Text( + text = "Одобренных отзывов: $approvedCount", + style = MaterialTheme.typography.bodyLarge, + color = UrbanBrown + ) + Text( + text = "Отклоненных отзывов: $rejectedCount", + style = MaterialTheme.typography.bodyLarge, + color = UrbanBrown + ) + Text( + text = "На модерации: $pendingCount", + style = MaterialTheme.typography.bodyLarge, + color = UrbanBrown + ) + } + } + + Spacer(Modifier.height(16.dp)) + + AuthButton(text = "Добавить отзыв") { + reviewsViewModel.clearMessages() + onAddReview() + } + + Spacer(Modifier.height(10.dp)) + + AuthButton( + text = "Назад в профиль", + backgroundColor = UrbanBrown + ) { + onBack() + } + + AuthSuccessText( + text = successMessage, + onTimeout = reviewsViewModel::clearSuccessMessage + ) + AuthStatusText( + text = errorMessage, + onTimeout = reviewsViewModel::clearErrorMessage + ) + + Spacer(Modifier.height(16.dp)) + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (reviews.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = "Пока нет ни одного отзыва", + style = MaterialTheme.typography.bodyLarge, + color = UrbanBrown + ) + } + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items(reviews, key = { it.id }) { review -> + ReviewListItem( + review = review, + onOpenDetails = { onOpenDetails(review) }, + onEdit = { onEditReview(review) } + ) + } + } + } + } + } +} + +@Composable +private fun ReviewListItem( + review: ReviewCardResp, + onOpenDetails: () -> Unit, + onEdit: () -> Unit +) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + border = BorderStroke(2.dp, moderationStatusColor(review.status)), + colors = CardDefaults.outlinedCardColors(containerColor = BackgroundLight) + ) { + Column(modifier = Modifier.padding(16.dp)) { + ReviewCardSummary(review) + + Spacer(Modifier.height(12.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + ReviewActionButton( + text = "Редактировать", + backgroundColor = UrbanBrown, + modifier = Modifier.weight(1f) + ) { + onEdit() + } + ReviewActionButton( + text = "Подробнее", + backgroundColor = SafeGreen, + modifier = Modifier.weight(1f) + ) { + onOpenDetails() + } + } + } + } +} diff --git a/app/src/main/java/com/example/goodroad/ui/theme/Color.kt b/app/src/main/java/com/example/goodroad/ui/theme/Color.kt new file mode 100644 index 0000000..41b5a68 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/theme/Color.kt @@ -0,0 +1,31 @@ +package com.example.goodroad.ui.theme + +import androidx.compose.ui.graphics.Color + +val PrimaryBlue = Color(0xFF4F87C9) +val SafeGreen = Color(0xFF549671) +val InclusiveViolet = Color(0xFF8B7AC6) +val AlertRed = Color(0xFFD56B63) + +val UrbanBrown = Color(0xFF887058) + +val BackgroundLight = Color(0xFFF7F5F0) +val SurfaceWarm = Color(0xFFEEE7DD) +val BorderWarm = Color(0xFFD8CEC0) + +val MapBackground = Color(0xFFF3EFE7) +val Buildings = Color(0xFFDCCFBE) +val Water = Color(0xFFBFDCF3) +val Parks = Color(0xFFCFE3C8) + +val SafeRoute = Color(0xFF4E9B6F) +val BalancedRoute = Color(0xFF4F87C9) +val FastRoute = Color(0xFF7C6BCB) +val Obstacle = Color(0xFFD56B63) + + +val TextPrimary = Color(0xFF2F2B28) +val TextSecondary = Color(0xFF7A6F66) +val WhiteSoft = Color(0xFFFFFBF7) + +val GrayButton = Color(0xFF9E9E9E) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/theme/Theme.kt b/app/src/main/java/com/example/goodroad/ui/theme/Theme.kt new file mode 100644 index 0000000..d87037b --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/theme/Theme.kt @@ -0,0 +1,96 @@ +package com.example.goodroad.ui.theme + +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +private val GoodRoadLightColors = lightColorScheme( + primary = SafeRoute, + onPrimary = WhiteSoft, + + secondary = PrimaryBlue, + onSecondary = WhiteSoft, + + tertiary = InclusiveViolet, + onTertiary = WhiteSoft, + + error = AlertRed, + onError = WhiteSoft, + + background = BackgroundLight, + onBackground = TextPrimary, + + surface = SurfaceWarm, + onSurface = TextPrimary, + + surfaceVariant = BorderWarm, + onSurfaceVariant = TextSecondary, + + outline = BorderWarm, + + primaryContainer = SafeGreen, + onPrimaryContainer = TextPrimary, + + secondaryContainer = Water, + onSecondaryContainer = TextPrimary, + + tertiaryContainer = FastRoute.copy(alpha = 0.18f), + onTertiaryContainer = TextPrimary, + + errorContainer = Obstacle.copy(alpha = 0.18f), + onErrorContainer = TextPrimary +) + +private val GoodRoadDarkColors = darkColorScheme( + primary = SafeRoute, + onPrimary = WhiteSoft, + + secondary = PrimaryBlue, + onSecondary = WhiteSoft, + + tertiary = InclusiveViolet, + onTertiary = WhiteSoft, + + error = AlertRed, + onError = WhiteSoft, + + background = TextPrimary, + onBackground = BackgroundLight, + + surface = Color(0xFF211D1A), + onSurface = BackgroundLight, + + surfaceVariant = Color(0xFF4B433D), + onSurfaceVariant = BorderWarm, + + outline = Color(0xFF6D635A), + + primaryContainer = Color(0xFF355F48), + onPrimaryContainer = BackgroundLight, + + secondaryContainer = Color(0xFF355D87), + onSecondaryContainer = BackgroundLight, + + tertiaryContainer = Color(0xFF5D5194), + onTertiaryContainer = BackgroundLight, + + errorContainer = Color(0xFF8D4A45), + onErrorContainer = BackgroundLight +) + +@Composable +fun GoodRoadTheme( + darkTheme: Boolean = false, + content: @Composable () -> Unit +) { + val colorScheme = if (darkTheme) { + GoodRoadDarkColors + } else { + GoodRoadLightColors + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/theme/Type.kt b/app/src/main/java/com/example/goodroad/ui/theme/Type.kt new file mode 100644 index 0000000..61b0e52 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/theme/Type.kt @@ -0,0 +1,40 @@ +package com.example.goodroad.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +val Typography = Typography( + headlineLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Bold, + fontSize = 34.sp, + lineHeight = 40.sp + ), + titleMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.SemiBold, + fontSize = 18.sp, + lineHeight = 24.sp + ), + bodyLarge = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 22.sp + ), + bodyMedium = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 14.sp, + lineHeight = 20.sp + ), + bodySmall = TextStyle( + fontFamily = FontFamily.SansSerif, + fontWeight = FontWeight.Normal, + fontSize = 12.sp, + lineHeight = 16.sp + ) +) diff --git a/app/src/main/java/com/example/goodroad/ui/user/UserComponents.kt b/app/src/main/java/com/example/goodroad/ui/user/UserComponents.kt new file mode 100644 index 0000000..52473ed --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/user/UserComponents.kt @@ -0,0 +1,26 @@ +package com.example.goodroad.ui.user + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.* +import com.example.goodroad.ui.theme.* + +@Composable +fun UserInfoBlock(label: String, value: String?) { + Column { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + Text( + text = value ?: "-", + style = MaterialTheme.typography.bodyLarge, + color = TextPrimary + ) + Spacer(Modifier.height(8.dp)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/user/UserDecor.kt b/app/src/main/java/com/example/goodroad/ui/user/UserDecor.kt new file mode 100644 index 0000000..f01f295 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/user/UserDecor.kt @@ -0,0 +1,120 @@ +package com.example.goodroad.ui.user + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.layout.* +import com.example.goodroad.ui.theme.* +@Composable +fun UserDecor() { + Box( + modifier = Modifier + .fillMaxWidth() + .height(230.dp) + .statusBarsPadding() +// .padding(horizontal = 18.dp, vertical = 12.dp) + .clip(RoundedCornerShape(32.dp)) + .background(SurfaceWarm) + ) { + Canvas(modifier = Modifier.matchParentSize()) { + val lightPatch = Path().apply { + moveTo(0f, size.height * 0.76f) + cubicTo( + size.width * 0.1f, size.height * 0.7f, + size.width * 0.2f, size.height * 0.45f, + size.width * 0.34f, size.height * 0.42f + ) + cubicTo( + size.width * 0.49f, size.height * 0.38f, + size.width * 0.54f, size.height * 0.18f, + size.width * 0.72f, size.height * 0.12f + ) + lineTo(size.width, size.height * 0.12f) + lineTo(size.width, size.height) + lineTo(0f, size.height) + close() + } + + drawPath( + path = lightPatch, + color = BackgroundLight + ) + + val road = Path().apply { + moveTo(size.width * 0.88f, -8f) + cubicTo( + size.width * 0.8f, size.height * 0.06f, + size.width * 0.7f, size.height * 0.14f, + size.width * 0.62f, size.height * 0.28f + ) + cubicTo( + size.width * 0.55f, size.height * 0.4f, + size.width * 0.42f, size.height * 0.52f, + size.width * 0.26f, size.height * 0.58f + ) + cubicTo( + size.width * 0.15f, size.height * 0.62f, + size.width * 0.07f, size.height * 0.68f, + -8f, size.height * 0.8f + ) + } + + drawPath( + path = road, + brush = SolidColor(UrbanBrown), + style = Stroke( + width = size.width * 0.09f, + cap = StrokeCap.Square, + join = StrokeJoin.Round + ) + ) + + drawPath( + path = road, + color = BackgroundLight.copy(alpha = 0.95f), + style = Stroke( + width = size.width * 0.014f, + cap = StrokeCap.Butt, + join = StrokeJoin.Round, + pathEffect = PathEffect.dashPathEffect( + floatArrayOf(size.width * 0.06f, size.width * 0.04f) + ) + ) + ) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.Top + ) { + Box( + modifier = Modifier + .size(42.dp) + .clip(CircleShape) + .background(BackgroundLight.copy(alpha = 0.92f)), + contentAlignment = Alignment.Center + ) { + Text( + text = "GR", + style = MaterialTheme.typography.titleMedium, + color = TextPrimary, + fontWeight = FontWeight.SemiBold + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/user/UserDeleteAccount.kt b/app/src/main/java/com/example/goodroad/ui/user/UserDeleteAccount.kt new file mode 100644 index 0000000..067f2e7 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/user/UserDeleteAccount.kt @@ -0,0 +1,82 @@ +package com.example.goodroad.ui.user + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.goodroad.ui.auth.AuthButton +import com.example.goodroad.ui.auth.AuthStatusText +import com.example.goodroad.ui.auth.PasswordField +import com.example.goodroad.ui.theme.* +import com.example.goodroad.ui.viewmodel.UserViewModel +import kotlinx.coroutines.delay + +@Composable +fun UserDeleteAccountScreen( + viewModel: UserViewModel, + onExit: () -> Unit, + onBack: () -> Unit +) { + var password by remember { mutableStateOf("") } + val isLoading by viewModel.isLoading + val errorMessage by viewModel.errorMessage + + LaunchedEffect(errorMessage) { + if (!errorMessage.isNullOrBlank()) { + delay(5_000) + viewModel.clearMessages() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + + UserDecor() + Spacer(modifier = Modifier.height(16.dp)) + + Text( + "Удаление аккаунта", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary + ) + + Spacer(modifier = Modifier.height(20.dp)) + + PasswordField( + value = password, + onValueChange = { password = it }, + label = "Пароль" + ) + + Spacer(modifier = Modifier.height(8.dp)) + + AuthStatusText(text = errorMessage) + + Spacer(modifier = Modifier.height(20.dp)) + + AuthButton( + text = "Удалить аккаунт", + enabled = !isLoading + ) { + viewModel.deleteUser(password) { + onExit() + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + AuthButton( + text = "Назад в профиль", + backgroundColor = UrbanBrown, + contentColor = WhiteSoft, + onClick = { + viewModel.clearMessages() + onBack() + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/user/UserEditScreen.kt b/app/src/main/java/com/example/goodroad/ui/user/UserEditScreen.kt new file mode 100644 index 0000000..97798c8 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/user/UserEditScreen.kt @@ -0,0 +1,375 @@ +package com.example.goodroad.ui.user + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Photo +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.example.goodroad.ui.auth.* +import com.example.goodroad.ui.common.validation.* +import com.example.goodroad.ui.theme.* +import com.example.goodroad.ui.viewmodel.UserViewModel +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +@Composable +fun UserEditScreen( + userViewModel: UserViewModel, + onBack: () -> Unit, + onLogout: () -> Unit +) { + val context = LocalContext.current + val user = userViewModel.user.value ?: return + + var firstName by remember { mutableStateOf(user.firstName ?: "") } + var lastName by remember { mutableStateOf(user.lastName ?: "") } + var photoUrl by remember { mutableStateOf(user.photoUrl ?: "") } + var selectedPhotoUri by remember { mutableStateOf(null) } + var phone by remember { mutableStateOf("") } + var oldPassword by remember { mutableStateOf("") } + var newPassword by remember { mutableStateOf("") } + var confirmNewPassword by remember { mutableStateOf("") } + + var firstNameWarning by remember { mutableStateOf(null) } + var lastNameWarning by remember { mutableStateOf(null) } + var phoneWarning by remember { mutableStateOf(null) } + var errorText by remember { mutableStateOf(null) } + + val errorMessage by remember { derivedStateOf { userViewModel.errorMessage.value } } + val successMessage by remember { derivedStateOf { userViewModel.successMessage.value } } + val isLoading by remember { derivedStateOf { userViewModel.isLoading.value } } + val finalError = errorMessage ?: errorText + + val hasProfileChanges by remember(firstName, lastName, photoUrl, phone, selectedPhotoUri, user) { + derivedStateOf { + firstName != (user.firstName ?: "") || + lastName != (user.lastName ?: "") || + photoUrl != (user.photoUrl ?: "") || + phone.isNotBlank() || + selectedPhotoUri != null + } + } + + val hasPasswordChanges by remember(oldPassword, newPassword, confirmNewPassword) { + derivedStateOf { + oldPassword.isNotBlank() || newPassword.isNotBlank() || confirmNewPassword.isNotBlank() + } + } + + val canSave by remember(hasProfileChanges, hasPasswordChanges, isLoading) { + derivedStateOf { + (hasProfileChanges || hasPasswordChanges) && !isLoading + } + } + + val photoPickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri -> + if (uri != null) { + selectedPhotoUri = uri + userViewModel.uploadAvatar(context, uri) { uploadedUrl -> + photoUrl = uploadedUrl + } + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp) + ) { + UserDecor() + + Text( + text = "Редактирование профиля", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary + ) + + Spacer(Modifier.height(12.dp)) + + PlainField( + value = firstName, + onValueChange = { value -> + when { + !isAllowedCyrillicInput(value) -> { + firstNameWarning = CYRILLIC_WARNING + } + value != firstName -> { + firstName = value + firstNameWarning = null + } + } + }, + label = "Имя", + icon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = UrbanBrown + ) + }, + warning = firstNameWarning, + maxLength = NAME_MAX_LENGTH + ) + + Spacer(Modifier.height(12.dp)) + + PlainField( + value = lastName, + onValueChange = { value -> + when { + !isAllowedCyrillicInput(value) -> { + lastNameWarning = CYRILLIC_WARNING + } + value != lastName -> { + lastName = value + lastNameWarning = null + } + } + }, + label = "Фамилия", + icon = { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + tint = UrbanBrown + ) + }, + warning = lastNameWarning, + maxLength = NAME_MAX_LENGTH + ) + + Spacer(Modifier.height(12.dp)) + + Spacer(Modifier.height(8.dp)) + + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + when { + selectedPhotoUri != null -> { + AsyncImage( + model = selectedPhotoUri, + contentDescription = null, + modifier = Modifier + .size(120.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } + + photoUrl.isNotBlank() -> { + AsyncImage( + model = photoUrl, + contentDescription = null, + modifier = Modifier + .size(120.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop + ) + } + + else -> { + Surface( + modifier = Modifier.size(120.dp), + shape = CircleShape, + color = WhiteSoft, + tonalElevation = 2.dp + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.Photo, + contentDescription = null, + tint = UrbanBrown, + modifier = Modifier.size(36.dp) + ) + } + } + } + } + } + + Spacer(Modifier.height(16.dp)) + + OutlinedButton( + onClick = { photoPickerLauncher.launch("image/*") }, + modifier = Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = BackgroundLight, + contentColor = UrbanBrown + ), + border = ButtonDefaults.outlinedButtonBorder.copy( + brush = androidx.compose.ui.graphics.SolidColor(BorderWarm) + ) + ) { + Icon( + imageVector = Icons.Default.Photo, + contentDescription = null, + tint = UrbanBrown, + modifier = Modifier.size(22.dp) + ) + Spacer(Modifier.width(12.dp)) + Text( + text = "Выбрать фото профиля", + style = MaterialTheme.typography.titleMedium, + color = UrbanBrown + ) + } + + Spacer(Modifier.height(12.dp)) + + PhoneField( + value = phone, + onValueChange = { value -> + phone = value + phoneWarning = when { + !isAllowedDigitsInput(value) -> PHONE_CHARS_WARNING + value.length > 11 -> PHONE_FORMAT_WARNING + value.isNotEmpty() && value.first() !in listOf('7', '8') -> PHONE_FORMAT_WARNING + else -> null + } + }, + label = "Телефон", + warning = phoneWarning + ) + + Spacer(Modifier.height(4.dp)) + Text( + text = "Оставьте поле пустым, если номер менять не нужно.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = oldPassword, + onValueChange = { oldPassword = it }, + label = "Старый пароль" + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = newPassword, + onValueChange = { newPassword = it }, + label = "Новый пароль" + ) + + Spacer(Modifier.height(12.dp)) + + PasswordField( + value = confirmNewPassword, + onValueChange = { confirmNewPassword = it }, + label = "Подтвердите новый пароль" + ) + + Spacer(Modifier.height(4.dp)) + Text( + text = "Чтобы сменить пароль, заполните старый пароль и дважды введите новый.", + style = MaterialTheme.typography.bodySmall, + color = UrbanBrown + ) + + AuthSuccessText( + text = successMessage, + onTimeout = { + errorText = null + userViewModel.clearMessages() + } + ) + AuthStatusText( + text = finalError, + onTimeout = { + errorText = null + userViewModel.clearMessages() + } + ) + + Spacer(Modifier.height(20.dp)) + + AuthButton( + text = if (isLoading) "Сохраняем..." else "Сохранить", + enabled = canSave + ) { + val firstNameNormalized = normalizeRequiredCyrillic(firstName) + if (firstNameNormalized == null) { + firstNameWarning = CYRILLIC_WARNING + errorText = "Имя должно содержать только кириллицу" + return@AuthButton + } + + val lastNameNormalized = normalizeRequiredCyrillic(lastName) + if (lastNameNormalized == null) { + lastNameWarning = CYRILLIC_WARNING + errorText = "Фамилия должна содержать только кириллицу" + return@AuthButton + } + + val phoneDigits = + phone.takeIf { it.isNotBlank() }?.let { normalizeRequiredRussianPhone(it) } + + if (phone.isNotBlank() && phoneDigits == null) { + phoneWarning = PHONE_FORMAT_WARNING + errorText = "Некорректный телефон" + return@AuthButton + } + + val oldPass = oldPassword.takeIf { it.isNotBlank() } + val newPass = newPassword.takeIf { it.isNotBlank() } + val confirmPass = confirmNewPassword.takeIf { it.isNotBlank() } + + if (!newPass.isNullOrBlank() || !confirmPass.isNullOrBlank() || !oldPass.isNullOrBlank()) { + if (oldPass.isNullOrBlank() || newPass.isNullOrBlank() || confirmPass.isNullOrBlank()) { + errorText = "Для смены пароля заполните все три поля" + return@AuthButton + } + if (newPass != confirmPass) { + errorText = "Новые пароли не совпадают" + return@AuthButton + } + } + + userViewModel.updateUser( + firstName = firstNameNormalized, + lastName = lastNameNormalized, + photoUrl = photoUrl.ifBlank { null }, + phone = phoneDigits?.let { formatPhoneForRequest(it) }, + oldPassword = oldPass, + newPassword = newPass + ) + + oldPassword = "" + newPassword = "" + confirmNewPassword = "" + errorText = null + } + + Spacer(Modifier.height(12.dp)) + + AuthButton( + text = "Назад в профиль", + backgroundColor = UrbanBrown, + contentColor = WhiteSoft, + onClick = onBack + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/user/UserNav.kt b/app/src/main/java/com/example/goodroad/ui/user/UserNav.kt new file mode 100644 index 0000000..20a476c --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/user/UserNav.kt @@ -0,0 +1,129 @@ +package com.example.goodroad.ui.user + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.compose.viewModel +import com.example.goodroad.data.network.ApiClient +import com.example.goodroad.data.review.ReviewCardResp +import com.example.goodroad.data.review.ReviewRepository +import com.example.goodroad.data.user.UserRepository +import com.example.goodroad.ui.maps.MapsNav +import com.example.goodroad.ui.reviews.ReviewDetailsScreen +import com.example.goodroad.ui.reviews.ReviewFormScreen +import com.example.goodroad.ui.reviews.UserReviewsScreen +import com.example.goodroad.ui.viewmodel.ReviewsViewModel +import com.example.goodroad.ui.viewmodel.UserViewModel + +@Composable +fun UserNav(onLogout: () -> Unit) { + val userApi = ApiClient.userApi + val reviewApi = ApiClient.reviewApi + + val userFactory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(UserViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return UserViewModel(UserRepository(userApi)) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } + + val reviewsFactory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ReviewsViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return ReviewsViewModel(ReviewRepository(reviewApi, userApi)) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } + } + + val userViewModel: UserViewModel = viewModel(factory = userFactory) + val reviewsViewModel: ReviewsViewModel = viewModel(factory = reviewsFactory) + + var screen by remember { mutableStateOf("profile") } + var selectedReview by remember { mutableStateOf(null) } + + when (screen) { + "profile" -> UserProfileScreen( + userViewModel = userViewModel, + onEdit = { screen = "edit" }, + onDelete = { screen = "delete" }, + onLogout = onLogout, + onSelectObstacles = { screen = "obstacles" }, + onOpenReviews = { + selectedReview = null + screen = "reviews" + } + ) + + "edit" -> UserEditScreen( + userViewModel = userViewModel, + onBack = { screen = "profile" }, + onLogout = onLogout + ) + + "delete" -> UserDeleteAccountScreen( + viewModel = userViewModel, + onBack = { screen = "profile" }, + onExit = onLogout + ) + + "obstacles" -> MapsNav( + onBackToProfile = { + screen = "profile" + }, + onSaved = {} + ) + + "reviews" -> UserReviewsScreen( + reviewsViewModel = reviewsViewModel, + onBack = { screen = "profile" }, + onAddReview = { + selectedReview = null + screen = "review_form" + }, + onOpenDetails = { + selectedReview = it + screen = "review_details" + }, + onEditReview = { + selectedReview = it + screen = "review_form" + } + ) + + "review_form" -> ReviewFormScreen( + reviewsViewModel = reviewsViewModel, + initialReview = selectedReview, + onBack = { screen = "reviews" }, + onSaved = { + selectedReview = null + screen = "reviews" + } + ) + + "review_details" -> selectedReview?.let { review -> + ReviewDetailsScreen( + review = review, + reviewsViewModel = reviewsViewModel, + onBack = { screen = "reviews" }, + onEdit = { screen = "review_form" }, + onDeleted = { + selectedReview = null + screen = "reviews" + } + ) + } ?: run { + LaunchedEffect(Unit) { + screen = "reviews" + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/user/UserProfileScreen.kt b/app/src/main/java/com/example/goodroad/ui/user/UserProfileScreen.kt new file mode 100644 index 0000000..3b42834 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/user/UserProfileScreen.kt @@ -0,0 +1,155 @@ +package com.example.goodroad.ui.user + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.example.goodroad.ui.auth.AuthButton +import com.example.goodroad.ui.theme.* +import com.example.goodroad.ui.viewmodel.UserViewModel +import android.content.Intent +import com.example.goodroad.MapActivity +import androidx.compose.ui.platform.LocalContext + +@Composable +fun UserProfileScreen( + userViewModel: UserViewModel, + onEdit: () -> Unit, + onDelete: () -> Unit, + onLogout: () -> Unit, + onSelectObstacles: () -> Unit, + onOpenReviews: () -> Unit +) { + val user by userViewModel.user + val isLoading by userViewModel.isLoading + val errorMessage by userViewModel.errorMessage + + LaunchedEffect(userViewModel) { + if (user == null && !userViewModel.isDeleted) { + userViewModel.getCurrentUser() + } + } + + when { + isLoading && user == null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + + user != null -> { + val u = user!! + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + UserDecor() + + Text( + "Профиль", + style = MaterialTheme.typography.headlineLarge, + color = TextPrimary + ) + + Spacer(Modifier.height(20.dp)) + + Card( + colors = CardDefaults.cardColors( + containerColor = UrbanBrown.copy(alpha = 0.08f) + ), + modifier = Modifier.fillMaxWidth() + ) { + Column( + modifier = Modifier.padding(16.dp) + ) { + Text( + text = "${u.firstName ?: ""} ${u.lastName ?: ""}", + fontSize = 22.sp, + fontWeight = FontWeight.Bold, + color = UrbanBrown + ) + + Spacer(Modifier.height(6.dp)) + + Text( + text = "Роль: ${u.role ?: ""}", + fontSize = 16.sp, + color = UrbanBrown.copy(alpha = 0.8f) + ) + } + } + + Spacer(Modifier.height(20.dp)) + + AuthButton( + text = "Выбрать препятствия", + backgroundColor = UrbanBrown, + contentColor = WhiteSoft + ) { + onSelectObstacles() + } + + Spacer(modifier = Modifier.height(10.dp)) + + val context = LocalContext.current + + AuthButton( + text = "Перейти на карту", + backgroundColor = UrbanBrown, + contentColor = WhiteSoft + ) { + val intent = Intent(context, MapActivity::class.java) + context.startActivity(intent) + } + + Spacer(Modifier.height(10.dp)) + + AuthButton( + text = "Мои отзывы", + backgroundColor = SafeGreen, + contentColor = WhiteSoft + ) { + onOpenReviews() + } + + Spacer(Modifier.height(10.dp)) + + AuthButton(text = "Редактировать профиль", onClick = onEdit) + + Spacer(Modifier.height(10.dp)) + + AuthButton(text = "Удалить аккаунт", onClick = onDelete) + + Spacer(Modifier.height(10.dp)) + + AuthButton(text = "Выйти") { + userViewModel.logout { onLogout() } + } + } + } + } + + errorMessage != null -> { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("Ошибка: $errorMessage", color = Color.Red) + } + } + + else -> { + LaunchedEffect(Unit) { + if (userViewModel.isDeleted) onLogout() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/viewmodel/AuthViewModel.kt b/app/src/main/java/com/example/goodroad/ui/viewmodel/AuthViewModel.kt new file mode 100644 index 0000000..94e8e89 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/viewmodel/AuthViewModel.kt @@ -0,0 +1,120 @@ +package com.example.goodroad.ui.viewmodel + +import com.example.goodroad.data.network.ApiClient +import kotlinx.coroutines.launch +import retrofit2.HttpException +import java.io.IOException +import androidx.lifecycle.* +import com.example.goodroad.data.auth.* +class AuthViewModel : ViewModel() { + + private val authRepository = AuthRepository() + + private val _loginResult = MutableLiveData() + val loginResult: LiveData = _loginResult + + private val _error = MutableLiveData() + val error: LiveData = _error + + private val _recoverResult = MutableLiveData() + val recoverResult: LiveData = _recoverResult + + private val _isLoading = MutableLiveData(false) + val isLoading: LiveData = _isLoading + + fun login(phone: String, password: String) { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + + try { + val response = authRepository.loginUser(phone, password) + ApiClient.updateCredentials(phone, password) + _loginResult.value = response + } catch (e: Exception) { + _error.value = mapAuthError(e, AuthAction.LOGIN) + } finally { + _isLoading.value = false + } + } + } + + fun register(firstName: String, lastName: String, phone: String, password: String) { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + + try { + val response = authRepository.registerUser(firstName, lastName, phone, password) + ApiClient.updateCredentials(phone, password) + _loginResult.value = response + } catch (e: Exception) { + _error.value = mapAuthError(e, AuthAction.REGISTER) + } finally { + _isLoading.value = false + } + } + } + + fun recoverPassword(phone: String, firstName: String, lastName: String, newPassword: String) { + viewModelScope.launch { + _isLoading.value = true + _error.value = null + _recoverResult.value = null + + try { + val ok = authRepository.recoverPassword(phone, firstName, lastName, newPassword) + _recoverResult.value = ok + } catch (e: Exception) { + _error.value = mapAuthError(e, AuthAction.RECOVER) + _recoverResult.value = false + } finally { + _isLoading.value = false + } + } + } + + fun clearError() { + _error.value = null + } + + fun clearRecoverResult() { + _recoverResult.value = null + } + + private fun mapAuthError(e: Exception, action: AuthAction): String { + return when (e) { + is HttpException -> when (e.code()) { + 400 -> when (action) { + AuthAction.RECOVER -> "Неверные данные для восстановления" + else -> "Проверьте введённые данные" + } + + 401 -> when (action) { + AuthAction.RECOVER -> "Имя, фамилия или телефон не совпадают" + else -> "Неверный номер телефона или пароль" + } + + 403 -> when (action) { + AuthAction.REGISTER -> "Регистрация запрещена" + else -> "Доступ запрещён" + } + + 404 -> "Пользователь не найден" + 409 -> "Пользователь с таким номером уже существует" + 500 -> "Сервер временно недоступен" + else -> "Ошибка операции" + } + + is IOException -> "Проверьте подключение к интернету" + + else -> "Неизвестная ошибка" + } + } + + private enum class AuthAction { + LOGIN, + REGISTER, + RECOVER + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/ui/viewmodel/MapsViewModel.kt b/app/src/main/java/com/example/goodroad/ui/viewmodel/MapsViewModel.kt new file mode 100644 index 0000000..421ff9d --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/viewmodel/MapsViewModel.kt @@ -0,0 +1,79 @@ +package com.example.goodroad.ui.viewmodel + +import androidx.compose.runtime.* +import androidx.lifecycle.* +import com.example.goodroad.data.obstacle.* +import kotlinx.coroutines.launch +import retrofit2.* +import java.io.* + +class MapsViewModel(private val repository: ObstacleRepository) : ViewModel() { + + var policies = mutableStateOf>(emptyList()) + private set + + var isLoading = mutableStateOf(false) + private set + + var isSaving = mutableStateOf(false) + private set + + var errorMessage = mutableStateOf(null) + private set + + var successMessage = mutableStateOf(null) + private set + + fun loadPolicies() { + viewModelScope.launch { + isLoading.value = true + errorMessage.value = null + + try { + policies.value = repository.getUserObstaclePolicies() + } catch (e: Exception) { + errorMessage.value = mapObstacleError(e) + } finally { + isLoading.value = false + } + } + } + + fun savePolicies(items: List, onSuccess: () -> Unit) { + viewModelScope.launch { + isSaving.value = true + errorMessage.value = null + successMessage.value = null + + try { + val req = ReplaceObstaclePolicyReq(items) + policies.value = repository.replaceUserObstaclePolicies(req) + successMessage.value = "Настройки препятствий сохранены" + onSuccess() + } catch (e: Exception) { + errorMessage.value = mapObstacleError(e) + } finally { + isSaving.value = false + } + } + } + + fun clearMessages() { + errorMessage.value = null + successMessage.value = null + } + + private fun mapObstacleError(e: Exception): String { + return when (e) { + is HttpException -> when (e.code()) { + 400 -> "Проверьте выбранные препятствия и уровень тяжести" + 401 -> "Вы не авторизованы" + 403 -> "Нет прав для выполнения действия" + 500 -> "Сервер временно недоступен" + else -> "Не удалось сохранить настройки препятствий" + } + is IOException -> "Проверьте подключение к интернету" + else -> e.message ?: "Неизвестная ошибка" + } + } +} diff --git a/app/src/main/java/com/example/goodroad/ui/viewmodel/ReviewsViewModel.kt b/app/src/main/java/com/example/goodroad/ui/viewmodel/ReviewsViewModel.kt new file mode 100644 index 0000000..a84f980 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/viewmodel/ReviewsViewModel.kt @@ -0,0 +1,206 @@ +package com.example.goodroad.ui.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.goodroad.data.review.* +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import retrofit2.HttpException +import java.io.File +import java.io.IOException + +class ReviewsViewModel(private val repository: ReviewRepository) : ViewModel() { + + var reviews = mutableStateOf>(emptyList()) + private set + + var points = mutableStateOf(null) + private set + + var isLoading = mutableStateOf(false) + private set + + var isSubmitting = mutableStateOf(false) + private set + + var isPhotoUploading = mutableStateOf(false) + private set + + var errorMessage = mutableStateOf(null) + private set + + var successMessage = mutableStateOf(null) + private set + + fun loadReviews() { + viewModelScope.launch { + isLoading.value = true + errorMessage.value = null + + try { + points.value = repository.getOwnReviewPoints() + reviews.value = repository.getOwnReviews() + } catch (e: Exception) { + errorMessage.value = mapReviewError(e) + } finally { + isLoading.value = false + } + } + } + + fun createReview(req: UpsertReviewReq, onSuccess: () -> Unit) { + viewModelScope.launch { + isSubmitting.value = true + errorMessage.value = null + successMessage.value = null + + try { + repository.createReview(req) + successMessage.value = "Отзыв отправлен на модерацию" + points.value = repository.getOwnReviewPoints() + reviews.value = repository.getOwnReviews() + onSuccess() + } catch (e: Exception) { + errorMessage.value = mapReviewError(e) + } finally { + isSubmitting.value = false + } + } + } + + fun updateReview(reviewId: String, req: UpsertReviewReq, onSuccess: () -> Unit) { + viewModelScope.launch { + isSubmitting.value = true + errorMessage.value = null + successMessage.value = null + + try { + repository.updateReview(reviewId, req) + successMessage.value = "Изменения сохранены. Отзыв снова отправлен на модерацию" + points.value = repository.getOwnReviewPoints() + reviews.value = repository.getOwnReviews() + onSuccess() + } catch (e: Exception) { + errorMessage.value = mapReviewError(e) + } finally { + isSubmitting.value = false + } + } + } + + fun deleteReview(reviewId: String, onSuccess: () -> Unit) { + viewModelScope.launch { + isSubmitting.value = true + errorMessage.value = null + successMessage.value = null + + try { + repository.deleteReview(reviewId) + successMessage.value = "Отзыв удален" + points.value = repository.getOwnReviewPoints() + reviews.value = repository.getOwnReviews() + onSuccess() + } catch (e: Exception) { + errorMessage.value = mapReviewError(e) + } finally { + isSubmitting.value = false + } + } + } + + fun uploadReviewPhotos( + context: Context, + uris: List, + onSuccess: (List) -> Unit + ) { + if (uris.isEmpty()) return + + viewModelScope.launch { + isPhotoUploading.value = true + errorMessage.value = null + successMessage.value = null + + try { + val resolver = context.contentResolver + val uploadedUrls = mutableListOf() + + for (uri in uris) { + val mimeType = resolver.getType(uri) ?: "image/*" + val extension = MimeTypeMap.resolveExtension(mimeType) + val tempFile = File.createTempFile("review_photo", extension, context.cacheDir) + + try { + resolver.openInputStream(uri)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } ?: throw IllegalArgumentException("Не удалось прочитать выбранный файл") + + val requestBody = tempFile.asRequestBody(mimeType.toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData("file", tempFile.name, requestBody) + uploadedUrls += repository.uploadReviewPhoto(part) + } finally { + tempFile.delete() + } + } + + successMessage.value = if (uploadedUrls.size == 1) { + "Фотография добавлена" + } else { + "Фотографии добавлены" + } + onSuccess(uploadedUrls) + } catch (e: Exception) { + errorMessage.value = mapReviewError(e) + } finally { + isPhotoUploading.value = false + } + } + } + + fun clearMessages() { + errorMessage.value = null + successMessage.value = null + } + + fun clearSuccessMessage() { + successMessage.value = null + } + + fun clearErrorMessage() { + errorMessage.value = null + } + + private fun mapReviewError(e: Exception): String { + return when (e) { + is IllegalArgumentException -> e.message ?: "Некорректные данные отзыва" + is HttpException -> when (e.code()) { + 400 -> "Проверьте поля отзыва" + 401 -> "Вы не авторизованы" + 404 -> "Отзыв или препятствие не найдены" + 409 -> "Такой отзыв уже существует" + 413 -> "Файл слишком большой" + 500 -> "Сервер временно недоступен" + else -> "Не удалось выполнить операцию с отзывом" + } + is IOException -> "Проверьте подключение к интернету" + else -> e.message ?: "Неизвестная ошибка" + } + } + + private object MimeTypeMap { + fun resolveExtension(mimeType: String): String { + return when (mimeType) { + "image/jpeg" -> ".jpg" + "image/png" -> ".png" + "image/webp" -> ".webp" + else -> ".tmp" + } + } + } +} diff --git a/app/src/main/java/com/example/goodroad/ui/viewmodel/UserViewModel.kt b/app/src/main/java/com/example/goodroad/ui/viewmodel/UserViewModel.kt new file mode 100644 index 0000000..b8b20fe --- /dev/null +++ b/app/src/main/java/com/example/goodroad/ui/viewmodel/UserViewModel.kt @@ -0,0 +1,219 @@ +package com.example.goodroad.ui.viewmodel + +import android.content.Context +import android.net.Uri +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.goodroad.data.network.ApiClient +import com.example.goodroad.data.user.* +import kotlinx.coroutines.launch +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.asRequestBody +import retrofit2.HttpException +import java.io.File +import java.io.IOException + +class UserViewModel(private val repository: UserRepository) : ViewModel() { + + var user = mutableStateOf(null) + var isLoading = mutableStateOf(false) + var errorMessage = mutableStateOf(null) + var successMessage = mutableStateOf(null) + + var isDeleted = false + private set + + fun getCurrentUser() { + if (isDeleted) return + + viewModelScope.launch { + isLoading.value = true + errorMessage.value = null + + try { + user.value = repository.getCurrentUser() + } catch (e: Exception) { + errorMessage.value = mapUserError(e) + } finally { + isLoading.value = false + } + } + } + + fun updateUser( + firstName: String, + lastName: String, + photoUrl: String? = null, + phone: String? = null, + oldPassword: String? = null, + newPassword: String? = null + ) { + viewModelScope.launch { + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + val current = user.value + val phoneToUpdate = phone?.takeIf { it.isNotBlank() } + val hasPasswordChange = !oldPassword.isNullOrBlank() || !newPassword.isNullOrBlank() + + if (hasPasswordChange && (oldPassword.isNullOrBlank() || newPassword.isNullOrBlank())) { + throw IllegalArgumentException("Для смены пароля заполните оба поля") + } + + val req = UpdateUserReq( + firstName = firstName.takeIf { it != current?.firstName }, + lastName = lastName.takeIf { it != current?.lastName }, + photoUrl = photoUrl.takeIf { it != current?.photoUrl }, + phone = phoneToUpdate + ) + + val hasProfileChanges = req.firstName != null || + req.lastName != null || + req.photoUrl != null || + req.phone != null + + if (!hasProfileChanges && !hasPasswordChange) { + throw IllegalArgumentException("Нет изменений для сохранения") + } + + if (hasProfileChanges) { + user.value = repository.updateCurrentUser(req) + if (req.phone != null) { + ApiClient.updateCredentials(phone = req.phone) + } + } + + if (hasPasswordChange) { + repository.changePassword(oldPassword!!, newPassword!!) + ApiClient.updateCredentials(password = newPassword) + } + + successMessage.value = "Профиль успешно сохранен" + } catch (e: Exception) { + errorMessage.value = mapUserError(e) + } finally { + isLoading.value = false + } + } + } + + fun uploadAvatar(context: Context, uri: Uri, onSuccess: (String) -> Unit) { + viewModelScope.launch { + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + val resolver = context.contentResolver + val mimeType = resolver.getType(uri) ?: "image/*" + val extension = MimeTypeMap.resolveExtension(mimeType) + val tempFile = File.createTempFile("avatar_upload", extension, context.cacheDir) + + resolver.openInputStream(uri)?.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } ?: throw IllegalArgumentException("Не удалось прочитать выбранный файл") + + val requestBody = tempFile.asRequestBody(mimeType.toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData("file", tempFile.name, requestBody) + + val response = repository.uploadAvatar(part) + ?: throw IllegalStateException("Сервер не вернул ссылку на фото") + + onSuccess(response.photoUrl) + tempFile.delete() + } catch (e: Exception) { + errorMessage.value = mapUserError(e) + } finally { + isLoading.value = false + } + } + } + + fun deleteUser(password: String, onSuccess: () -> Unit) { + viewModelScope.launch { + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + repository.deleteCurrentUser(DeleteAccountReq(password)) + ApiClient.clearCredentials() + user.value = null + isDeleted = true + onSuccess() + } catch (e: Exception) { + errorMessage.value = mapDeleteError(e) + } finally { + isLoading.value = false + } + } + } + + fun logout(onSuccess: () -> Unit) { + ApiClient.clearCredentials() + user.value = null + isDeleted = false + errorMessage.value = null + successMessage.value = null + onSuccess() + } + + fun clearSuccessMessage() { + successMessage.value = null + } + + fun clearMessages() { + errorMessage.value = null + successMessage.value = null + } + + private fun mapUserError(e: Exception): String { + return when (e) { + is IllegalArgumentException -> e.message ?: "Некорректные данные" + is HttpException -> when (e.code()) { + 400 -> "Некорректные данные профиля" + 401 -> "Вы не авторизованы" + 403 -> "Нет прав для выполнения действия" + 404 -> "Пользователь не найден" + 409 -> "Телефон уже используется другим пользователем" + 500 -> "Сервер временно недоступен" + else -> "Ошибка операции" + } + is IOException -> "Проверьте подключение к интернету" + else -> e.message ?: "Неизвестная ошибка" + } + } + + private fun mapDeleteError(e: Exception): String { + return when (e) { + is HttpException -> when (e.code()) { + 400 -> "Введите корректный пароль" + 401 -> "Неверный пароль" + 403 -> "Нет прав для удаления аккаунта" + 404 -> "Аккаунт уже удалён или не найден" + 409 -> "Невозможно удалить аккаунт (есть связанные данные)" + 500 -> "Сервер временно недоступен" + else -> "Не удалось удалить аккаунт" + } + is IOException -> "Проверьте подключение к интернету" + else -> e.message ?: "Неизвестная ошибка" + } + } + + private object MimeTypeMap { + fun resolveExtension(mimeType: String): String { + return when (mimeType) { + "image/jpeg" -> ".jpg" + "image/png" -> ".png" + "image/webp" -> ".webp" + else -> ".tmp" + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_button.xml b/app/src/main/res/drawable/rounded_button.xml new file mode 100644 index 0000000..7882a40 --- /dev/null +++ b/app/src/main/res/drawable/rounded_button.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/rounded_edittext.xml b/app/src/main/res/drawable/rounded_edittext.xml new file mode 100644 index 0000000..418a8f2 --- /dev/null +++ b/app/src/main/res/drawable/rounded_edittext.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_map.xml b/app/src/main/res/layout/activity_map.xml new file mode 100644 index 0000000..963334d --- /dev/null +++ b/app/src/main/res/layout/activity_map.xml @@ -0,0 +1,64 @@ + + + + + + + + + +