From db1f9e2b8973fd06b7847c811cf9b64489d24457 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Fri, 22 May 2026 22:56:21 +0300 Subject: [PATCH 1/9] add: HelpScreen and HelpCreateScreen --- .../kotlin-compiler-357793777805091235.salive | 0 app/build.gradle | 10 +- .../help/presentation/HelpViewModel.kt | 79 +++++++ .../modules/help/screens/HelpCreateScreen.kt | 212 ++++++++++++++++++ .../modules/help/screens/HelpScreen.kt | 73 ++++++ .../modules/user/navigation/UserNav.kt | 56 ++++- build.gradle | 3 +- 7 files changed, 424 insertions(+), 9 deletions(-) create mode 100644 .kotlin/sessions/kotlin-compiler-357793777805091235.salive create mode 100644 app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt create mode 100644 app/src/main/java/com/example/goodroad/modules/help/screens/HelpCreateScreen.kt create mode 100644 app/src/main/java/com/example/goodroad/modules/help/screens/HelpScreen.kt diff --git a/.kotlin/sessions/kotlin-compiler-357793777805091235.salive b/.kotlin/sessions/kotlin-compiler-357793777805091235.salive new file mode 100644 index 0000000..e69de29 diff --git a/app/build.gradle b/app/build.gradle index 6f85557..37b77d0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,6 +1,7 @@ plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.compose' } android { @@ -46,10 +47,6 @@ android { buildConfig true } - composeOptions { - kotlinCompilerExtensionVersion '1.5.14' - } - packaging { resources { excludes += '/META-INF/{AL2.0,LGPL2.1}' @@ -72,12 +69,13 @@ dependencies { implementation 'androidx.compose.foundation:foundation' implementation 'androidx.compose.material3:material3' implementation 'androidx.compose.material:material-icons-extended' + implementation 'androidx.compose.runtime:runtime-saveable' + implementation 'androidx.compose.runtime:runtime-livedata' debugImplementation 'androidx.compose.ui:ui-tooling' debugImplementation 'androidx.compose.ui:ui-test-manifest' implementation 'androidx.navigation:navigation-compose:2.8.5' - implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7' implementation 'com.squareup.retrofit2:retrofit:2.11.0' @@ -100,6 +98,4 @@ dependencies { testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - - implementation 'androidx.compose.runtime:runtime-livedata' } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt b/app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt new file mode 100644 index 0000000..594f764 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt @@ -0,0 +1,79 @@ +package com.example.goodroad.modules.help.presentation + +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class HelpViewModel : ViewModel() { + + var isLoading = mutableStateOf(false) + private set + + var errorMessage = mutableStateOf(null) + private set + + var successMessage = mutableStateOf(null) + private set + + fun createRequest( + routeStart: String, + routeEnd: String, + meetingDate: String, + meetingTime: String, + contact: String, + specialNotes: String, + comment: String, + onSuccess: () -> Unit + ) { + + viewModelScope.launch { + + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + + if (routeStart.isBlank()) { + throw IllegalArgumentException("Укажите начало маршрута") + } + + if (routeEnd.isBlank()) { + throw IllegalArgumentException("Укажите конец маршрута") + } + + if (meetingDate.isBlank()) { + throw IllegalArgumentException("Укажите дату встречи") + } + + if (meetingTime.isBlank()) { + throw IllegalArgumentException("Укажите время встречи") + } + + if (contact.isBlank()) { + throw IllegalArgumentException("Укажите контакт для связи") + } + + delay(700) + + successMessage.value = "Заявка отправлена" + onSuccess() + + } catch (e: Exception) { + + errorMessage.value = e.message ?: "Ошибка" + + } finally { + + isLoading.value = false + } + } + } + + fun clearMessages() { + errorMessage.value = null + successMessage.value = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/help/screens/HelpCreateScreen.kt b/app/src/main/java/com/example/goodroad/modules/help/screens/HelpCreateScreen.kt new file mode 100644 index 0000000..bfbbc74 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/help/screens/HelpCreateScreen.kt @@ -0,0 +1,212 @@ +package com.example.goodroad.modules.help.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.goodroad.modules.help.presentation.HelpViewModel +import com.example.goodroad.ui.UserDecor +import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.theme.BackgroundLight + +@Composable +fun HelpCreateScreen( + helpViewModel: HelpViewModel, + onBack: () -> Unit, + onCreated: () -> Unit +) { + + val isLoading by helpViewModel.isLoading + val error by helpViewModel.errorMessage + + var routeStart by rememberSaveable { mutableStateOf("") } + var routeEnd by rememberSaveable { mutableStateOf("") } + var meetingDate by rememberSaveable { mutableStateOf("") } + var meetingTime by rememberSaveable { mutableStateOf("") } + var contact by rememberSaveable { mutableStateOf("") } + var specialNotes by rememberSaveable { mutableStateOf("") } + var comment by rememberSaveable { mutableStateOf("") } + + val scrollState = rememberScrollState() + + fun formatDate(input: String): String { + val digits = input.filter { it.isDigit() }.take(8) + val sb = StringBuilder() + + for (i in digits.indices) { + sb.append(digits[i]) + if (i == 1 || i == 3) sb.append('.') + } + + return sb.toString() + } + + fun formatTime(input: String): String { + val digits = input.filter { it.isDigit() }.take(4) + val sb = StringBuilder() + + for (i in digits.indices) { + sb.append(digits[i]) + if (i == 1) sb.append(':') + } + + return sb.toString() + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(24.dp) + ) { + + UserDecor() + + Text( + "Новая заявка", + style = MaterialTheme.typography.headlineLarge + ) + + Spacer(Modifier.height(20.dp)) + + OutlinedTextField( + value = routeStart, + onValueChange = { routeStart = it }, + label = { Text("Начало маршрута с сопровождением") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + singleLine = true + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = routeEnd, + onValueChange = { routeEnd = it }, + label = { Text("Конец маршртуа с сопровождением") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + singleLine = true + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = meetingDate, + onValueChange = { + val digits = it.filter { ch -> ch.isDigit() }.take(8) + meetingDate = formatDate(digits) + }, + label = { Text("Дата (ДД.ММ.ГГГГ)") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + singleLine = true + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = meetingTime, + onValueChange = { + val digits = it.filter { ch -> ch.isDigit() }.take(4) + meetingTime = formatTime(digits) + }, + label = { Text("Время (ЧЧ:ММ)") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + singleLine = true + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = contact, + onValueChange = { + contact = it.filter { ch -> + ch.isLetterOrDigit() || ch in "+@._-() " + } + }, + label = { Text("Номер телефона") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + singleLine = true + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = contact, + onValueChange = { + contact = it.filter { ch -> + ch.isLetterOrDigit() || ch in "+@._-() " + } + }, + label = { Text("Telegram / ВК / Номер почтового голубя)") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + singleLine = true + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = specialNotes, + onValueChange = { specialNotes = it }, + label = { Text("Особые условия (что важно учитывать)") }, + placeholder = { Text("Например: инвалидная коляска, тихая среда, медленный темп") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + minLines = 2 + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + label = { Text("Комментарий") }, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + minLines = 2 + ) + + if (error != null) { + Spacer(Modifier.height(12.dp)) + Text( + text = error!!, + color = MaterialTheme.colorScheme.error + ) + } + + Spacer(Modifier.height(24.dp)) + + PrimaryButton( + text = if (isLoading) "Отправка..." else "Отправить заявку", + onClick = { + helpViewModel.createRequest( + routeStart = routeStart, + routeEnd = routeEnd, + meetingDate = meetingDate, + meetingTime = meetingTime, + contact = contact, + specialNotes = specialNotes, + comment = comment + ) { + onCreated() + } + } + ) + + Spacer(Modifier.height(40.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/help/screens/HelpScreen.kt b/app/src/main/java/com/example/goodroad/modules/help/screens/HelpScreen.kt new file mode 100644 index 0000000..c0be9a9 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/help/screens/HelpScreen.kt @@ -0,0 +1,73 @@ +package com.example.goodroad.modules.help.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.goodroad.modules.help.presentation.HelpViewModel +import com.example.goodroad.ui.UserDecor +import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.theme.* + +@Composable +fun HelpScreen( + helpViewModel: HelpViewModel, + onCreateRequest: () -> Unit +) { + + 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)) + + Card( + colors = CardDefaults.cardColors( + containerColor = + UrbanBrown.copy(alpha = 0.08f) + ) + ) { + + Column( + modifier = Modifier.padding(18.dp) + ) { + + Text( + "Нужна помощь в сопровождении маршрута?" + ) + + Spacer( + Modifier.height(8.dp) + ) + + Text( + "Здесь можно оставить заявку для волонтёра" + ) + } + } + + Spacer(Modifier.weight(1f)) + + PrimaryButton( + text = "Оставить заявку", + onClick = onCreateRequest + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt index 66a8345..8a18a96 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt @@ -26,10 +26,16 @@ import com.example.goodroad.modules.user.presentation.UserViewModel import com.example.goodroad.modules.user.screens.UserEditScreen import com.example.goodroad.ui.user.UserDeleteAccountScreen import com.example.goodroad.ui.user.UserProfileScreen +import androidx.compose.material.icons.filled.VolunteerActivism + +import com.example.goodroad.modules.help.presentation.HelpViewModel +import com.example.goodroad.modules.help.screens.HelpScreen +import com.example.goodroad.modules.help.screens.HelpCreateScreen enum class BottomTab { MAP, REVIEWS, + HELP, PROFILE } @@ -39,7 +45,8 @@ enum class OverlayScreen { DELETE_PROFILE, REVIEW_FORM, REVIEW_DETAILS, - OBSTACLES + OBSTACLES, + HELP_CREATE } @Composable @@ -73,10 +80,20 @@ fun UserNav( } } + // ✅ NEW: HELP VM FACTORY + val helpFactory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return HelpViewModel() as T + } + } + val userViewModel: UserViewModel = viewModel(factory = userFactory) val reviewsViewModel: ReviewsViewModel = viewModel(factory = reviewsFactory) val mapsViewModel: MapsViewModel = viewModel(factory = mapsFactory) + // ✅ NEW + val helpViewModel: HelpViewModel = viewModel(factory = helpFactory) + var currentTab by rememberSaveable { mutableStateOf(BottomTab.MAP) } var overlayScreen by remember { mutableStateOf(OverlayScreen.NONE) } var selectedReview by remember { mutableStateOf(null) } @@ -105,6 +122,20 @@ fun UserNav( label = { Text("Отзывы") } ) + NavigationBarItem( + selected = currentTab == BottomTab.HELP, + onClick = { + currentTab = BottomTab.HELP + overlayScreen = OverlayScreen.NONE + }, + icon = { + Icon(Icons.Default.VolunteerActivism, null) + }, + label = { + Text("Помощь") + } + ) + NavigationBarItem( selected = currentTab == BottomTab.PROFILE, onClick = { @@ -150,6 +181,16 @@ fun UserNav( ) } + BottomTab.HELP -> { + + HelpScreen( + helpViewModel = helpViewModel, + onCreateRequest = { + overlayScreen = OverlayScreen.HELP_CREATE + } + ) + } + BottomTab.PROFILE -> { UserProfileScreen( userViewModel = userViewModel, @@ -222,6 +263,19 @@ fun UserNav( ) } + OverlayScreen.HELP_CREATE -> { + + HelpCreateScreen( + helpViewModel = helpViewModel, + onBack = { + overlayScreen = OverlayScreen.NONE + }, + onCreated = { + overlayScreen = OverlayScreen.NONE + } + ) + } + OverlayScreen.NONE -> Unit } } diff --git a/build.gradle b/build.gradle index 009b975..fbebe14 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,5 @@ plugins { id 'com.android.application' version '8.7.3' apply false - id 'org.jetbrains.kotlin.android' version '1.9.24' apply false + id 'org.jetbrains.kotlin.android' version "2.1.20" apply false + id 'org.jetbrains.kotlin.plugin.compose' version '2.1.20' apply false } From f1523384058161987b628cf6234b37b3890a53b2 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Sat, 23 May 2026 00:41:53 +0300 Subject: [PATCH 2/9] add: UserHelpRequestScreen --- .../help/presentation/HelpViewModel.kt | 101 ++++++++--- .../modules/help/screens/HelpCreateScreen.kt | 12 -- .../modules/help/screens/HelpScreen.kt | 31 ++-- .../help/screens/UserHelpRequestsScreen.kt | 163 ++++++++++++++++++ .../modules/user/navigation/UserNav.kt | 31 ++-- 5 files changed, 277 insertions(+), 61 deletions(-) create mode 100644 app/src/main/java/com/example/goodroad/modules/help/screens/UserHelpRequestsScreen.kt diff --git a/app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt b/app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt index 594f764..79bd817 100644 --- a/app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt +++ b/app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt @@ -1,13 +1,33 @@ package com.example.goodroad.modules.help.presentation +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import java.util.UUID class HelpViewModel : ViewModel() { + enum class RequestStatus { + PENDING, + APPROVED, + REJECTED, + COMPLETED + } + + data class HelpRequest( + val id: String, + val routeStart: String, + val routeEnd: String, + val dateTime: String, + val contact: String, + val specialNotes: String, + val comment: String, + val status: RequestStatus = RequestStatus.PENDING + ) + var isLoading = mutableStateOf(false) private set @@ -17,6 +37,45 @@ class HelpViewModel : ViewModel() { var successMessage = mutableStateOf(null) private set + val requests = mutableStateListOf() + + init { + requests.addAll( + listOf( + HelpRequest( + id = "1", + routeStart = "Дом", + routeEnd = "Поликлиника №3", + dateTime = "24.05.2026 14:30", + contact = "+7 999 123 45 67", + specialNotes = "Лифт обязателен, избегать лестниц", + comment = "Сопровождение пожилого человека", + status = RequestStatus.PENDING + ), + HelpRequest( + id = "2", + routeStart = "Метро", + routeEnd = "Городская больница", + dateTime = "25.05.2026 10:00", + contact = "@telegram_user", + specialNotes = "Только ровные дороги", + comment = "", + status = RequestStatus.APPROVED + ), + HelpRequest( + id = "3", + routeStart = "Дом", + routeEnd = "Магазин", + dateTime = "20.05.2026 12:00", + contact = "email@example.com", + specialNotes = "Без шума и толпы", + comment = "Покупка продуктов", + status = RequestStatus.COMPLETED + ) + ) + ) + } + fun createRequest( routeStart: String, routeEnd: String, @@ -36,42 +95,42 @@ class HelpViewModel : ViewModel() { try { - if (routeStart.isBlank()) { - throw IllegalArgumentException("Укажите начало маршрута") - } - - if (routeEnd.isBlank()) { - throw IllegalArgumentException("Укажите конец маршрута") - } + if (routeStart.isBlank()) throw IllegalArgumentException("Укажите начало маршрута") + if (routeEnd.isBlank()) throw IllegalArgumentException("Укажите конец маршрута") + if (meetingDate.isBlank()) throw IllegalArgumentException("Укажите дату встречи") + if (meetingTime.isBlank()) throw IllegalArgumentException("Укажите время встречи") + if (contact.isBlank()) throw IllegalArgumentException("Укажите контакт") - if (meetingDate.isBlank()) { - throw IllegalArgumentException("Укажите дату встречи") - } + delay(600) - if (meetingTime.isBlank()) { - throw IllegalArgumentException("Укажите время встречи") - } + val request = HelpRequest( + id = UUID.randomUUID().toString(), + routeStart = routeStart, + routeEnd = routeEnd, + dateTime = "$meetingDate $meetingTime", + contact = contact, + specialNotes = specialNotes, + comment = comment, + status = RequestStatus.PENDING + ) - if (contact.isBlank()) { - throw IllegalArgumentException("Укажите контакт для связи") - } - - delay(700) + requests.add(request) successMessage.value = "Заявка отправлена" onSuccess() } catch (e: Exception) { - errorMessage.value = e.message ?: "Ошибка" - } finally { - isLoading.value = false } } } + fun deleteRequest(id: String) { + requests.removeAll { it.id == id } + } + fun clearMessages() { errorMessage.value = null successMessage.value = null diff --git a/app/src/main/java/com/example/goodroad/modules/help/screens/HelpCreateScreen.kt b/app/src/main/java/com/example/goodroad/modules/help/screens/HelpCreateScreen.kt index bfbbc74..123242a 100644 --- a/app/src/main/java/com/example/goodroad/modules/help/screens/HelpCreateScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/help/screens/HelpCreateScreen.kt @@ -158,18 +158,6 @@ fun HelpCreateScreen( Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = specialNotes, - onValueChange = { specialNotes = it }, - label = { Text("Особые условия (что важно учитывать)") }, - placeholder = { Text("Например: инвалидная коляска, тихая среда, медленный темп") }, - modifier = Modifier.fillMaxWidth(), - shape = MaterialTheme.shapes.large, - minLines = 2 - ) - - Spacer(Modifier.height(12.dp)) - OutlinedTextField( value = comment, onValueChange = { comment = it }, diff --git a/app/src/main/java/com/example/goodroad/modules/help/screens/HelpScreen.kt b/app/src/main/java/com/example/goodroad/modules/help/screens/HelpScreen.kt index c0be9a9..89b62cc 100644 --- a/app/src/main/java/com/example/goodroad/modules/help/screens/HelpScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/help/screens/HelpScreen.kt @@ -9,11 +9,13 @@ import com.example.goodroad.modules.help.presentation.HelpViewModel import com.example.goodroad.ui.UserDecor import com.example.goodroad.ui.buttons.PrimaryButton import com.example.goodroad.ui.theme.* +import androidx.compose.ui.text.font.FontWeight @Composable fun HelpScreen( helpViewModel: HelpViewModel, - onCreateRequest: () -> Unit + onCreateRequest: () -> Unit, + onMyRequests: () -> Unit ) { Surface( @@ -31,33 +33,33 @@ fun HelpScreen( Text( text = "Помощь волонтёров", - style = MaterialTheme.typography.headlineLarge, - color = TextPrimary + style = MaterialTheme.typography.headlineLarge ) Spacer(Modifier.height(20.dp)) Card( colors = CardDefaults.cardColors( - containerColor = - UrbanBrown.copy(alpha = 0.08f) + containerColor = UrbanBrown.copy(alpha = 0.06f) ) ) { Column( - modifier = Modifier.padding(18.dp) + modifier = Modifier.padding(16.dp) ) { Text( - "Нужна помощь в сопровождении маршрута?" + text = "Нуждаетесь в сопровождении?", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = UrbanBrown ) - Spacer( - Modifier.height(8.dp) - ) + Spacer(Modifier.height(6.dp)) Text( - "Здесь можно оставить заявку для волонтёра" + text = "Оставьте заявку для волонтёра, и вам помогут с маршрутом, передвижением или сопровождением.", + style = MaterialTheme.typography.bodyLarge ) } } @@ -68,6 +70,13 @@ fun HelpScreen( text = "Оставить заявку", onClick = onCreateRequest ) + + Spacer(Modifier.height(12.dp)) + + PrimaryButton( + text = "Мои заявки", + onClick = onMyRequests + ) } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/help/screens/UserHelpRequestsScreen.kt b/app/src/main/java/com/example/goodroad/modules/help/screens/UserHelpRequestsScreen.kt new file mode 100644 index 0000000..26f4f35 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/help/screens/UserHelpRequestsScreen.kt @@ -0,0 +1,163 @@ +package com.example.goodroad.modules.help.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.goodroad.modules.help.presentation.HelpViewModel +import com.example.goodroad.ui.UserDecor +import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.theme.* +import androidx.compose.ui.text.font.FontWeight + +@Composable +fun UserRequestsScreen( + helpViewModel: HelpViewModel +) { + + val requests = helpViewModel.requests + + var deleteId by remember { mutableStateOf(null) } + + fun statusText(status: HelpViewModel.RequestStatus): String { + return when (status) { + HelpViewModel.RequestStatus.PENDING -> "В обработке" + HelpViewModel.RequestStatus.APPROVED -> "Одобрено" + HelpViewModel.RequestStatus.REJECTED -> "Отклонено" + HelpViewModel.RequestStatus.COMPLETED -> "Выполнено" + } + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + + Column( + Modifier + .fillMaxSize() + .padding(24.dp) + ) { + + UserDecor() + + Text( + "Мои заявки", + style = MaterialTheme.typography.headlineLarge + ) + + Spacer(Modifier.height(16.dp)) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f) + ) { + + items(requests, key = { it.id }) { req -> + + OutlinedCard( + border = androidx.compose.foundation.BorderStroke( + 2.dp, + UrbanBrown.copy(alpha = 0.4f) + ), + colors = CardDefaults.outlinedCardColors( + containerColor = BackgroundLight + ), + modifier = Modifier.fillMaxWidth() + ) { + + Column(Modifier.padding(16.dp)) { + + Text( + "Маршрут", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text( + "${req.routeStart} ➜ ${req.routeEnd}", + style = MaterialTheme.typography.bodyLarge + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "Дата", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text(req.dateTime) + + Spacer(Modifier.height(8.dp)) + + Text( + "Контакт", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text(req.contact) + + Spacer(Modifier.height(8.dp)) + + Text( + "Особенности", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text(req.specialNotes) + + Spacer(Modifier.height(8.dp)) + + Text( + "Комментарий", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text(req.comment) + + Spacer(Modifier.height(10.dp)) + + Text( + "Статус", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text(statusText(req.status)) + + Spacer(Modifier.height(12.dp)) + + PrimaryButton( + text = "Удалить", + onClick = { deleteId = req.id } + ) + } + } + } + } + } + } + + if (deleteId != null) { + + AlertDialog( + onDismissRequest = { deleteId = null }, + title = { Text("Удаление") }, + text = { Text("Удалить заявку?") }, + confirmButton = { + TextButton(onClick = { + helpViewModel.deleteRequest(deleteId!!) + deleteId = null + }) { + Text("Да") + } + }, + dismissButton = { + TextButton(onClick = { deleteId = null }) { + Text("Нет") + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt index 8a18a96..efe881f 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt @@ -8,7 +8,6 @@ import androidx.compose.material.icons.filled.Star import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController @@ -27,10 +26,11 @@ import com.example.goodroad.modules.user.screens.UserEditScreen import com.example.goodroad.ui.user.UserDeleteAccountScreen import com.example.goodroad.ui.user.UserProfileScreen import androidx.compose.material.icons.filled.VolunteerActivism - +import com.example.goodroad.modules.help.screens.UserRequestsScreen import com.example.goodroad.modules.help.presentation.HelpViewModel import com.example.goodroad.modules.help.screens.HelpScreen import com.example.goodroad.modules.help.screens.HelpCreateScreen +import androidx.compose.ui.Modifier enum class BottomTab { MAP, @@ -46,7 +46,8 @@ enum class OverlayScreen { REVIEW_FORM, REVIEW_DETAILS, OBSTACLES, - HELP_CREATE + HELP_CREATE, + HELP_MY_REQUESTS } @Composable @@ -80,7 +81,6 @@ fun UserNav( } } - // ✅ NEW: HELP VM FACTORY val helpFactory = object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return HelpViewModel() as T @@ -90,8 +90,6 @@ fun UserNav( val userViewModel: UserViewModel = viewModel(factory = userFactory) val reviewsViewModel: ReviewsViewModel = viewModel(factory = reviewsFactory) val mapsViewModel: MapsViewModel = viewModel(factory = mapsFactory) - - // ✅ NEW val helpViewModel: HelpViewModel = viewModel(factory = helpFactory) var currentTab by rememberSaveable { mutableStateOf(BottomTab.MAP) } @@ -182,11 +180,13 @@ fun UserNav( } BottomTab.HELP -> { - HelpScreen( helpViewModel = helpViewModel, onCreateRequest = { overlayScreen = OverlayScreen.HELP_CREATE + }, + onMyRequests = { + overlayScreen = OverlayScreen.HELP_MY_REQUESTS } ) } @@ -242,9 +242,7 @@ fun UserNav( review = review, reviewsViewModel = reviewsViewModel, onBack = { overlayScreen = OverlayScreen.NONE }, - onEdit = { - overlayScreen = OverlayScreen.REVIEW_FORM - }, + onEdit = { overlayScreen = OverlayScreen.REVIEW_FORM }, onDeleted = { selectedReview = null overlayScreen = OverlayScreen.NONE @@ -264,18 +262,17 @@ fun UserNav( } OverlayScreen.HELP_CREATE -> { - HelpCreateScreen( helpViewModel = helpViewModel, - onBack = { - overlayScreen = OverlayScreen.NONE - }, - onCreated = { - overlayScreen = OverlayScreen.NONE - } + onBack = { overlayScreen = OverlayScreen.NONE }, + onCreated = { overlayScreen = OverlayScreen.NONE } ) } + OverlayScreen.HELP_MY_REQUESTS -> { + UserRequestsScreen(helpViewModel = helpViewModel) + } + OverlayScreen.NONE -> Unit } } From 1f7c7a9f0699b136df50e24a424c55b7bdcf77a5 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Sat, 23 May 2026 01:31:00 +0300 Subject: [PATCH 3/9] add: VolunteerApplicationFormScreen --- ...otlin-compiler-7588119888143996546.salive} | 0 .../modules/user/navigation/UserNav.kt | 26 +++- .../modules/user/screens/UserProfileScreen.kt | 22 +-- .../screens/VolunteerApplicationFormScreen.kt | 137 ++++++++++++++++++ 4 files changed, 169 insertions(+), 16 deletions(-) rename .kotlin/sessions/{kotlin-compiler-357793777805091235.salive => kotlin-compiler-7588119888143996546.salive} (100%) create mode 100644 app/src/main/java/com/example/goodroad/modules/user/screens/VolunteerApplicationFormScreen.kt diff --git a/.kotlin/sessions/kotlin-compiler-357793777805091235.salive b/.kotlin/sessions/kotlin-compiler-7588119888143996546.salive similarity index 100% rename from .kotlin/sessions/kotlin-compiler-357793777805091235.salive rename to .kotlin/sessions/kotlin-compiler-7588119888143996546.salive diff --git a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt index efe881f..798b65a 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt @@ -5,14 +5,20 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Map import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.VolunteerActivism import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.example.goodroad.data.network.ApiClient import com.example.goodroad.data.obstacle.ObstacleRepository +import com.example.goodroad.modules.help.presentation.HelpViewModel +import com.example.goodroad.modules.help.screens.HelpCreateScreen +import com.example.goodroad.modules.help.screens.HelpScreen +import com.example.goodroad.modules.help.screens.UserRequestsScreen import com.example.goodroad.modules.maps.presentation.MapsViewModel import com.example.goodroad.modules.maps.screens.MapRouteScreen import com.example.goodroad.modules.maps.screens.ObstacleSelectScreen @@ -25,12 +31,7 @@ import com.example.goodroad.modules.user.presentation.UserViewModel import com.example.goodroad.modules.user.screens.UserEditScreen import com.example.goodroad.ui.user.UserDeleteAccountScreen import com.example.goodroad.ui.user.UserProfileScreen -import androidx.compose.material.icons.filled.VolunteerActivism -import com.example.goodroad.modules.help.screens.UserRequestsScreen -import com.example.goodroad.modules.help.presentation.HelpViewModel -import com.example.goodroad.modules.help.screens.HelpScreen -import com.example.goodroad.modules.help.screens.HelpCreateScreen -import androidx.compose.ui.Modifier +import com.example.goodroad.ui.user.screens.VolunteerApplicationFormScreen enum class BottomTab { MAP, @@ -47,7 +48,8 @@ enum class OverlayScreen { REVIEW_DETAILS, OBSTACLES, HELP_CREATE, - HELP_MY_REQUESTS + HELP_MY_REQUESTS, + VOLUNTEER_APPLICATION } @Composable @@ -199,6 +201,9 @@ fun UserNav( onLogout = onLogout, onSelectObstacles = { overlayScreen = OverlayScreen.OBSTACLES + }, + onBecomeVolunteer = { + overlayScreen = OverlayScreen.VOLUNTEER_APPLICATION } ) } @@ -273,6 +278,13 @@ fun UserNav( UserRequestsScreen(helpViewModel = helpViewModel) } + OverlayScreen.VOLUNTEER_APPLICATION -> { + VolunteerApplicationFormScreen( + onBack = { overlayScreen = OverlayScreen.NONE }, + onSubmitted = { overlayScreen = OverlayScreen.NONE } + ) + } + OverlayScreen.NONE -> Unit } } diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt index 927c6ac..2ba95bd 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/UserProfileScreen.kt @@ -1,17 +1,17 @@ package com.example.goodroad.ui.user import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import coil.compose.AsyncImage import com.example.goodroad.modules.user.presentation.UserViewModel import com.example.goodroad.ui.UserDecor @@ -24,7 +24,8 @@ fun UserProfileScreen( onEdit: () -> Unit, onDelete: () -> Unit, onLogout: () -> Unit, - onSelectObstacles: () -> Unit + onSelectObstacles: () -> Unit, + onBecomeVolunteer: () -> Unit = {} ) { val user by userViewModel.user val isLoading by userViewModel.isLoading @@ -91,7 +92,6 @@ fun UserProfileScreen( .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { - Column( modifier = Modifier.weight(1f) ) { @@ -112,7 +112,6 @@ fun UserProfileScreen( } if (!u.photoUrl.isNullOrBlank()) { - AsyncImage( model = u.photoUrl, contentDescription = "Фото профиля", @@ -121,9 +120,7 @@ fun UserProfileScreen( .clip(CircleShape), contentScale = ContentScale.Crop ) - } else { - Surface( modifier = Modifier.size(90.dp), shape = CircleShape, @@ -148,7 +145,6 @@ fun UserProfileScreen( verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth() ) { - PrimaryButton( text = "Выбрать препятствия", backgroundColor = UrbanBrown, @@ -157,6 +153,14 @@ fun UserProfileScreen( onSelectObstacles() } + PrimaryButton( + text = "Стать волонтёром", + backgroundColor = UrbanBrown, + contentColor = WhiteSoft + ) { + onBecomeVolunteer() + } + PrimaryButton( text = "Редактировать профиль", onClick = onEdit diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/VolunteerApplicationFormScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/VolunteerApplicationFormScreen.kt new file mode 100644 index 0000000..c04d8c8 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/user/screens/VolunteerApplicationFormScreen.kt @@ -0,0 +1,137 @@ +package com.example.goodroad.ui.user.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.example.goodroad.ui.UserDecor +import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.UrbanBrown + +@Composable +fun VolunteerApplicationFormScreen( + onBack: () -> Unit, + onSubmitted: () -> Unit +) { + var motivation by rememberSaveable { mutableStateOf("") } + var dobroLink by rememberSaveable { mutableStateOf("") } + var contacts by rememberSaveable { mutableStateOf("") } + var certificates by rememberSaveable { mutableStateOf("") } + + var error by rememberSaveable { mutableStateOf(null) } + + val scrollState = rememberScrollState() + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(24.dp) + ) { + + UserDecor() + + Text( + text = "Заявка на волонтёрство", + style = MaterialTheme.typography.headlineLarge, + color = Color.Black + ) + + Spacer(Modifier.height(20.dp)) + + Card( + colors = CardDefaults.cardColors( + containerColor = UrbanBrown.copy(alpha = 0.06f) + ) + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = "Заполните заявку и мы свяжемся с вами в течение недели", + style = MaterialTheme.typography.bodyLarge, + color = UrbanBrown + ) + } + } + + Spacer(Modifier.height(16.dp)) + + error?.let { + Text(text = it, color = MaterialTheme.colorScheme.error) + Spacer(Modifier.height(8.dp)) + } + + OutlinedTextField( + value = motivation, + onValueChange = { + motivation = it + error = null + }, + label = { Text("Мотивация") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3 + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = dobroLink, + onValueChange = { + dobroLink = it + error = null + }, + label = { Text("Ссылка на dobro.ru") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = contacts, + onValueChange = { + contacts = it + error = null + }, + label = { Text("Контакты (телефон / telegram / ВК)") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = certificates, + onValueChange = { + certificates = it + error = null + }, + label = { Text("Сертификаты, что я молодец!!") }, + modifier = Modifier.fillMaxWidth(), + minLines = 3 + ) + + Spacer(Modifier.height(24.dp)) + + PrimaryButton( + text = "Отправить заявку", + onClick = { + if (motivation.isBlank() || dobroLink.isBlank() || contacts.isBlank()) { + error = "Заполните обязательные поля" + return@PrimaryButton + } + onSubmitted() + } + ) + } + } +} \ No newline at end of file From 3cbd3c99d22f09e511ff2d4e719e064847de48b6 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Sun, 24 May 2026 19:14:05 +0300 Subject: [PATCH 4/9] feat: linked frontend logic to backend in HelpRequestCreateScreen and UserHelpRequestsScreen --- ...tlin-compiler-12438213939652759706.salive} | 0 .../goodroad/data/network/ApiClient.kt | 5 + .../help/presentation/HelpViewModel.kt | 138 ---------- .../help/screens/UserHelpRequestsScreen.kt | 163 ------------ .../modules/user/navigation/UserNav.kt | 27 +- .../modules/volunteer/data/VolunteerApi.kt | 81 ++++++ .../modules/volunteer/data/VolunteerModels.kt | 140 +++++++++++ .../volunteer/data/VolunteerRepository.kt | 96 +++++++ .../presentation/VolunteerViewModel.kt | 225 +++++++++++++++++ .../screens/HelpRequestCreateScreen.kt} | 71 ++++-- .../screens/UserHelpRequestsScreen.kt | 236 ++++++++++++++++++ .../screens/VolunteerScreen.kt} | 8 +- 12 files changed, 855 insertions(+), 335 deletions(-) rename .kotlin/sessions/{kotlin-compiler-7588119888143996546.salive => kotlin-compiler-12438213939652759706.salive} (100%) delete mode 100644 app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt delete mode 100644 app/src/main/java/com/example/goodroad/modules/help/screens/UserHelpRequestsScreen.kt create mode 100644 app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerApi.kt create mode 100644 app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerModels.kt create mode 100644 app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerRepository.kt create mode 100644 app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt rename app/src/main/java/com/example/goodroad/modules/{help/screens/HelpCreateScreen.kt => volunteer/screens/HelpRequestCreateScreen.kt} (76%) create mode 100644 app/src/main/java/com/example/goodroad/modules/volunteer/screens/UserHelpRequestsScreen.kt rename app/src/main/java/com/example/goodroad/modules/{help/screens/HelpScreen.kt => volunteer/screens/VolunteerScreen.kt} (92%) diff --git a/.kotlin/sessions/kotlin-compiler-7588119888143996546.salive b/.kotlin/sessions/kotlin-compiler-12438213939652759706.salive similarity index 100% rename from .kotlin/sessions/kotlin-compiler-7588119888143996546.salive rename to .kotlin/sessions/kotlin-compiler-12438213939652759706.salive 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 index 0a845fd..fef3973 100644 --- a/app/src/main/java/com/example/goodroad/data/network/ApiClient.kt +++ b/app/src/main/java/com/example/goodroad/data/network/ApiClient.kt @@ -15,6 +15,7 @@ import com.example.goodroad.data.network.GoodRoadApi import com.example.goodroad.modules.auth.data.AuthApi import com.example.goodroad.modules.review.data.ReviewApi import com.example.goodroad.modules.user.data.UserApi +import com.example.goodroad.modules.volunteer.data.VolunteerApi object ApiClient { @@ -96,5 +97,9 @@ object ApiClient { val routeApi: GoodRoadApi by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { retrofit().create(GoodRoadApi::class.java) } + + val volunteerApi: VolunteerApi by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + retrofit().create(VolunteerApi::class.java) + } } diff --git a/app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt b/app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt deleted file mode 100644 index 79bd817..0000000 --- a/app/src/main/java/com/example/goodroad/modules/help/presentation/HelpViewModel.kt +++ /dev/null @@ -1,138 +0,0 @@ -package com.example.goodroad.modules.help.presentation - -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import java.util.UUID - -class HelpViewModel : ViewModel() { - - enum class RequestStatus { - PENDING, - APPROVED, - REJECTED, - COMPLETED - } - - data class HelpRequest( - val id: String, - val routeStart: String, - val routeEnd: String, - val dateTime: String, - val contact: String, - val specialNotes: String, - val comment: String, - val status: RequestStatus = RequestStatus.PENDING - ) - - var isLoading = mutableStateOf(false) - private set - - var errorMessage = mutableStateOf(null) - private set - - var successMessage = mutableStateOf(null) - private set - - val requests = mutableStateListOf() - - init { - requests.addAll( - listOf( - HelpRequest( - id = "1", - routeStart = "Дом", - routeEnd = "Поликлиника №3", - dateTime = "24.05.2026 14:30", - contact = "+7 999 123 45 67", - specialNotes = "Лифт обязателен, избегать лестниц", - comment = "Сопровождение пожилого человека", - status = RequestStatus.PENDING - ), - HelpRequest( - id = "2", - routeStart = "Метро", - routeEnd = "Городская больница", - dateTime = "25.05.2026 10:00", - contact = "@telegram_user", - specialNotes = "Только ровные дороги", - comment = "", - status = RequestStatus.APPROVED - ), - HelpRequest( - id = "3", - routeStart = "Дом", - routeEnd = "Магазин", - dateTime = "20.05.2026 12:00", - contact = "email@example.com", - specialNotes = "Без шума и толпы", - comment = "Покупка продуктов", - status = RequestStatus.COMPLETED - ) - ) - ) - } - - fun createRequest( - routeStart: String, - routeEnd: String, - meetingDate: String, - meetingTime: String, - contact: String, - specialNotes: String, - comment: String, - onSuccess: () -> Unit - ) { - - viewModelScope.launch { - - isLoading.value = true - errorMessage.value = null - successMessage.value = null - - try { - - if (routeStart.isBlank()) throw IllegalArgumentException("Укажите начало маршрута") - if (routeEnd.isBlank()) throw IllegalArgumentException("Укажите конец маршрута") - if (meetingDate.isBlank()) throw IllegalArgumentException("Укажите дату встречи") - if (meetingTime.isBlank()) throw IllegalArgumentException("Укажите время встречи") - if (contact.isBlank()) throw IllegalArgumentException("Укажите контакт") - - delay(600) - - val request = HelpRequest( - id = UUID.randomUUID().toString(), - routeStart = routeStart, - routeEnd = routeEnd, - dateTime = "$meetingDate $meetingTime", - contact = contact, - specialNotes = specialNotes, - comment = comment, - status = RequestStatus.PENDING - ) - - requests.add(request) - - successMessage.value = "Заявка отправлена" - onSuccess() - - } catch (e: Exception) { - errorMessage.value = e.message ?: "Ошибка" - } finally { - isLoading.value = false - } - } - } - - fun deleteRequest(id: String) { - requests.removeAll { it.id == id } - } - - fun clearMessages() { - errorMessage.value = null - successMessage.value = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/help/screens/UserHelpRequestsScreen.kt b/app/src/main/java/com/example/goodroad/modules/help/screens/UserHelpRequestsScreen.kt deleted file mode 100644 index 26f4f35..0000000 --- a/app/src/main/java/com/example/goodroad/modules/help/screens/UserHelpRequestsScreen.kt +++ /dev/null @@ -1,163 +0,0 @@ -package com.example.goodroad.modules.help.screens - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.example.goodroad.modules.help.presentation.HelpViewModel -import com.example.goodroad.ui.UserDecor -import com.example.goodroad.ui.buttons.PrimaryButton -import com.example.goodroad.ui.theme.* -import androidx.compose.ui.text.font.FontWeight - -@Composable -fun UserRequestsScreen( - helpViewModel: HelpViewModel -) { - - val requests = helpViewModel.requests - - var deleteId by remember { mutableStateOf(null) } - - fun statusText(status: HelpViewModel.RequestStatus): String { - return when (status) { - HelpViewModel.RequestStatus.PENDING -> "В обработке" - HelpViewModel.RequestStatus.APPROVED -> "Одобрено" - HelpViewModel.RequestStatus.REJECTED -> "Отклонено" - HelpViewModel.RequestStatus.COMPLETED -> "Выполнено" - } - } - - Surface( - modifier = Modifier.fillMaxSize(), - color = BackgroundLight - ) { - - Column( - Modifier - .fillMaxSize() - .padding(24.dp) - ) { - - UserDecor() - - Text( - "Мои заявки", - style = MaterialTheme.typography.headlineLarge - ) - - Spacer(Modifier.height(16.dp)) - - LazyColumn( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.weight(1f) - ) { - - items(requests, key = { it.id }) { req -> - - OutlinedCard( - border = androidx.compose.foundation.BorderStroke( - 2.dp, - UrbanBrown.copy(alpha = 0.4f) - ), - colors = CardDefaults.outlinedCardColors( - containerColor = BackgroundLight - ), - modifier = Modifier.fillMaxWidth() - ) { - - Column(Modifier.padding(16.dp)) { - - Text( - "Маршрут", - color = UrbanBrown, - fontWeight = FontWeight.SemiBold - ) - Text( - "${req.routeStart} ➜ ${req.routeEnd}", - style = MaterialTheme.typography.bodyLarge - ) - - Spacer(Modifier.height(8.dp)) - - Text( - "Дата", - color = UrbanBrown, - fontWeight = FontWeight.SemiBold - ) - Text(req.dateTime) - - Spacer(Modifier.height(8.dp)) - - Text( - "Контакт", - color = UrbanBrown, - fontWeight = FontWeight.SemiBold - ) - Text(req.contact) - - Spacer(Modifier.height(8.dp)) - - Text( - "Особенности", - color = UrbanBrown, - fontWeight = FontWeight.SemiBold - ) - Text(req.specialNotes) - - Spacer(Modifier.height(8.dp)) - - Text( - "Комментарий", - color = UrbanBrown, - fontWeight = FontWeight.SemiBold - ) - Text(req.comment) - - Spacer(Modifier.height(10.dp)) - - Text( - "Статус", - color = UrbanBrown, - fontWeight = FontWeight.SemiBold - ) - Text(statusText(req.status)) - - Spacer(Modifier.height(12.dp)) - - PrimaryButton( - text = "Удалить", - onClick = { deleteId = req.id } - ) - } - } - } - } - } - } - - if (deleteId != null) { - - AlertDialog( - onDismissRequest = { deleteId = null }, - title = { Text("Удаление") }, - text = { Text("Удалить заявку?") }, - confirmButton = { - TextButton(onClick = { - helpViewModel.deleteRequest(deleteId!!) - deleteId = null - }) { - Text("Да") - } - }, - dismissButton = { - TextButton(onClick = { deleteId = null }) { - Text("Нет") - } - } - ) - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt index 798b65a..bc21c90 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt @@ -15,10 +15,10 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavHostController import com.example.goodroad.data.network.ApiClient import com.example.goodroad.data.obstacle.ObstacleRepository -import com.example.goodroad.modules.help.presentation.HelpViewModel -import com.example.goodroad.modules.help.screens.HelpCreateScreen -import com.example.goodroad.modules.help.screens.HelpScreen -import com.example.goodroad.modules.help.screens.UserRequestsScreen +import com.example.goodroad.modules.volunteer.presentation.VolunteerViewModel +import com.example.goodroad.modules.volunteer.screens.HelpRequestCreateScreen +import com.example.goodroad.modules.volunteer.screens.VolunteerScreen +import com.example.goodroad.modules.volunteer.screens.UserHelpRequestsScreen import com.example.goodroad.modules.maps.presentation.MapsViewModel import com.example.goodroad.modules.maps.screens.MapRouteScreen import com.example.goodroad.modules.maps.screens.ObstacleSelectScreen @@ -32,6 +32,7 @@ import com.example.goodroad.modules.user.screens.UserEditScreen import com.example.goodroad.ui.user.UserDeleteAccountScreen import com.example.goodroad.ui.user.UserProfileScreen import com.example.goodroad.ui.user.screens.VolunteerApplicationFormScreen +import com.example.goodroad.modules.volunteer.data.VolunteerRepository enum class BottomTab { MAP, @@ -60,6 +61,7 @@ fun UserNav( val userApi = ApiClient.userApi val reviewApi = ApiClient.reviewApi val obstacleApi = ApiClient.obstacleApi + val volunteerApi = ApiClient.volunteerApi val userFactory = object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { @@ -84,15 +86,20 @@ fun UserNav( } val helpFactory = object : ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - return HelpViewModel() as T + override fun create( + modelClass: Class + ): T { + + return VolunteerViewModel( + VolunteerRepository(volunteerApi) + ) as T } } val userViewModel: UserViewModel = viewModel(factory = userFactory) val reviewsViewModel: ReviewsViewModel = viewModel(factory = reviewsFactory) val mapsViewModel: MapsViewModel = viewModel(factory = mapsFactory) - val helpViewModel: HelpViewModel = viewModel(factory = helpFactory) + val helpViewModel: VolunteerViewModel = viewModel(factory = helpFactory) var currentTab by rememberSaveable { mutableStateOf(BottomTab.MAP) } var overlayScreen by remember { mutableStateOf(OverlayScreen.NONE) } @@ -182,7 +189,7 @@ fun UserNav( } BottomTab.HELP -> { - HelpScreen( + VolunteerScreen( helpViewModel = helpViewModel, onCreateRequest = { overlayScreen = OverlayScreen.HELP_CREATE @@ -267,7 +274,7 @@ fun UserNav( } OverlayScreen.HELP_CREATE -> { - HelpCreateScreen( + HelpRequestCreateScreen( helpViewModel = helpViewModel, onBack = { overlayScreen = OverlayScreen.NONE }, onCreated = { overlayScreen = OverlayScreen.NONE } @@ -275,7 +282,7 @@ fun UserNav( } OverlayScreen.HELP_MY_REQUESTS -> { - UserRequestsScreen(helpViewModel = helpViewModel) + UserHelpRequestsScreen(viewModel = helpViewModel) } OverlayScreen.VOLUNTEER_APPLICATION -> { diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerApi.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerApi.kt new file mode 100644 index 0000000..4dfda0f --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerApi.kt @@ -0,0 +1,81 @@ +package com.example.goodroad.modules.volunteer.data + +import com.example.goodroad.modules.volunteer.data.models.* +import okhttp3.MultipartBody +import retrofit2.http.* + +interface VolunteerApi { + + @GET("volunteer/menu") + suspend fun getMenu(): VolunteerMenuRespDto + + @POST("volunteer/applications") + suspend fun createApplication( + @Body req: CreateVolunteerApplicationReqDto + ): VolunteerApplicationRespDto + + @Multipart + @POST("volunteer/applications/photos") + suspend fun uploadCertificate( + @Part file: MultipartBody.Part + ): PhotoUploadRespDto + + @GET("volunteer/requests/own") + suspend fun listOwnRequests(): List + + @POST("volunteer/requests") + suspend fun createHelpRequest( + @Body req: HelpRequestCreateReqDto + ): HelpRequestRespDto + + @GET("volunteer/requests/available") + suspend fun listAvailableRequests( + @Query("latitude") latitude: Double? = null, + @Query("longitude") longitude: Double? = null + ): List + + @GET("volunteer/requests/my-wards") + suspend fun listMyWards(): List + + @GET("volunteer/requests/{id}") + suspend fun getHelpRequest( + @Path("id") id: String + ): HelpRequestRespDto + + @POST("volunteer/requests/{id}/accept") + suspend fun acceptRequest( + @Path("id") id: String + ): HelpRequestRespDto + + @POST("volunteer/requests/{id}/withdraw") + suspend fun withdrawResponse( + @Path("id") id: String + ): HelpRequestRespDto + + @POST("volunteer/requests/{id}/cancel") + suspend fun cancelOwnRequest( + @Path("id") id: String + ): HelpRequestRespDto + + @DELETE("volunteer/requests/{id}") + suspend fun deleteOwnRequest( + @Path("id") id: String + ) + + @POST("volunteer/requests/{id}/route") + suspend fun setWalkRoute( + @Path("id") id: String, + @Body req: WalkRouteReqDto + ): HelpRequestRespDto + + @POST("volunteer/requests/{id}/start") + suspend fun startWalk( + @Path("id") id: String, + @Body req: WalkRouteReqDto? = null + ): HelpRequestRespDto + + @POST("volunteer/requests/{id}/finish") + suspend fun finishWalk( + @Path("id") id: String + ): HelpRequestRespDto +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerModels.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerModels.kt new file mode 100644 index 0000000..4dc2b03 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerModels.kt @@ -0,0 +1,140 @@ +package com.example.goodroad.modules.volunteer.data.models + +data class VolunteerMenuRespDto( + val volunteer: Boolean, + val applicationStatus: String?, + val rejectReason: String? +) + +data class CreateVolunteerApplicationReqDto( + val dobroUrl: String, + val phone: String, + val socialNickname: String?, + val certificatePhotoUrls: List? +) + +data class PhotoUploadRespDto( + val photoUrl: String +) + +data class VolunteerApplicationRespDto( + val id: String?, + val applicantId: String, + val applicantName: String, + val dobroUrl: String, + val phone: String, + val socialNickname: String?, + val certificatePhotoUrls: List?, + val status: String, + val moderatorComment: String?, + val createdAt: String?, + val moderatedAt: String? +) + +data class RejectApplicationReqDto( + val reason: String +) + +data class HelpRequestCreateReqDto( + val fromAddress: String, + val toAddress: String, + val date: String, + val time: String, + val phone: String, + val socialNickname: String?, + val comment: String +) + +data class HelpRequestRespDto( + val id: String?, + val requesterId: String, + val volunteerId: String?, + val fromAddress: String, + val toAddress: String, + val date: String, + val time: String, + val phone: String?, + val socialNickname: String?, + val comment: String?, + val status: String, + val contactsVisible: Boolean, + val canStart: Boolean, + val started: Boolean, + val completed: Boolean, + val createdAt: String? +) + +data class RoutePointReqDto( + val latitude: Double, + val longitude: Double +) + +data class WalkRouteReqDto( + val points: String? = null, + val routePoints: List? = null +) + +/** + * То, что удобно показывать на экране "Мои заявки". + * Поля оставлены близкими к твоему текущему UI, чтобы меньше менять экран. + */ +data class HelpRequestItem( + val id: String, + val routeStart: String, + val routeEnd: String, + val dateTime: String, + val contact: String, + val specialNotes: String, + val comment: String, + val status: RequestStatus +) + +enum class RequestStatus { + OPEN, + ACCEPTED, + CANCELLED, + COMPLETED, + UNKNOWN +} + +fun String?.toRequestStatus(): RequestStatus = when (this?.uppercase()) { + "OPEN" -> RequestStatus.OPEN + "ACCEPTED" -> RequestStatus.ACCEPTED + "CANCELLED" -> RequestStatus.CANCELLED + "COMPLETED" -> RequestStatus.COMPLETED + else -> RequestStatus.UNKNOWN +} + +fun HelpRequestRespDto.toUi(): HelpRequestItem { + val requestId = id ?: "" + return HelpRequestItem( + id = requestId, + routeStart = fromAddress, + routeEnd = toAddress, + dateTime = "$date $time", + contact = phone ?: socialNickname.orEmpty(), + specialNotes = socialNickname.orEmpty(), + comment = comment.orEmpty(), + status = status.toRequestStatus() + ) +} + +fun CreateHelpRequestReqDto.toCreateDto() = HelpRequestCreateReqDto( + fromAddress = fromAddress, + toAddress = toAddress, + date = date, + time = time, + phone = phone, + socialNickname = socialNickname, + comment = comment +) + +data class CreateHelpRequestReqDto( + val fromAddress: String, + val toAddress: String, + val date: String, + val time: String, + val phone: String, + val socialNickname: String?, + val comment: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerRepository.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerRepository.kt new file mode 100644 index 0000000..9262d22 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerRepository.kt @@ -0,0 +1,96 @@ +package com.example.goodroad.modules.volunteer.data + +import com.example.goodroad.modules.volunteer.data.models.* +import okhttp3.MultipartBody + +class VolunteerRepository( + private val api: VolunteerApi +) { + + suspend fun getMenu() = api.getMenu() + + suspend fun createApplication( + dobroUrl: String, + phone: String, + socialNickname: String?, + certificatePhotoUrls: List? + ) = api.createApplication( + CreateVolunteerApplicationReqDto( + dobroUrl = dobroUrl, + phone = phone, + socialNickname = socialNickname, + certificatePhotoUrls = certificatePhotoUrls + ) + ) + + suspend fun uploadCertificate(file: MultipartBody.Part) = + api.uploadCertificate(file) + + suspend fun loadOwnRequests(): List = + api.listOwnRequests().map { it.toUi() } + + suspend fun createHelpRequest( + fromAddress: String, + toAddress: String, + date: String, + time: String, + phone: String, + socialNickname: String?, + comment: String + ): HelpRequestItem { + val created = api.createHelpRequest( + HelpRequestCreateReqDto( + fromAddress = fromAddress, + toAddress = toAddress, + date = date, + time = time, + phone = phone, + socialNickname = socialNickname, + comment = comment + ) + ) + return created.toUi() + } + + suspend fun loadAvailableRequests( + latitude: Double? = null, + longitude: Double? = null + ): List = + api.listAvailableRequests(latitude, longitude).map { it.toUi() } + + suspend fun loadMyWards(): List = + api.listMyWards().map { it.toUi() } + + suspend fun getRequest(id: String): HelpRequestItem = + api.getHelpRequest(id).toUi() + + suspend fun acceptRequest(id: String): HelpRequestItem = + api.acceptRequest(id).toUi() + + suspend fun withdrawResponse(id: String): HelpRequestItem = + api.withdrawResponse(id).toUi() + + suspend fun cancelOwnRequest(id: String): HelpRequestItem = + api.cancelOwnRequest(id).toUi() + + suspend fun deleteOwnRequest(id: String) { + api.deleteOwnRequest(id) + } + + suspend fun setWalkRoute( + id: String, + points: String? = null, + routePoints: List? = null + ): HelpRequestItem = + api.setWalkRoute(id, WalkRouteReqDto(points = points, routePoints = routePoints)).toUi() + + suspend fun startWalk( + id: String, + points: String? = null, + routePoints: List? = null + ): HelpRequestItem = + api.startWalk(id, WalkRouteReqDto(points = points, routePoints = routePoints)).toUi() + + suspend fun finishWalk(id: String): HelpRequestItem = + api.finishWalk(id).toUi() +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt new file mode 100644 index 0000000..f0c9354 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt @@ -0,0 +1,225 @@ +package com.example.goodroad.modules.volunteer.presentation + +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.goodroad.modules.volunteer.data.VolunteerRepository +import com.example.goodroad.modules.volunteer.data.models.HelpRequestItem +import com.example.goodroad.modules.volunteer.data.models.RequestStatus as ApiRequestStatus +import kotlinx.coroutines.launch + +class VolunteerViewModel( + private val repository: VolunteerRepository +) : ViewModel() { + + enum class RequestStatus { + PENDING, + APPROVED, + REJECTED, + OPEN, + ACCEPTED, + CANCELLED, + COMPLETED, + UNKNOWN + } + + data class HelpRequest( + val id: String, + val routeStart: String, + val routeEnd: String, + val dateTime: String, + val contact: String, + val specialNotes: String, + val comment: String, + val status: RequestStatus = RequestStatus.OPEN + ) + + var isLoading = mutableStateOf(false) + private set + + var errorMessage = mutableStateOf(null) + private set + + var successMessage = mutableStateOf(null) + private set + + val requests = mutableStateListOf() + + init { + loadOwnRequests() + } + + fun loadOwnRequests() { + viewModelScope.launch { + + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + + val loaded = repository.loadOwnRequests() + + requests.clear() + requests.addAll( + loaded.map { it.toUiModel() } + ) + + } catch (e: Exception) { + + errorMessage.value = + e.message ?: "Ошибка загрузки заявок" + + } finally { + + isLoading.value = false + } + } + } + + fun refreshOwnRequests() { + loadOwnRequests() + } + + fun createRequest( + routeStart: String, + routeEnd: String, + meetingDate: String, + meetingTime: String, + contact: String, + specialNotes: String, + comment: String, + onSuccess: () -> Unit + ) { + + viewModelScope.launch { + + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + + if (routeStart.isBlank()) + throw IllegalArgumentException("Укажите начало маршрута") + + if (routeEnd.isBlank()) + throw IllegalArgumentException("Укажите конец маршрута") + + if (meetingDate.isBlank()) + throw IllegalArgumentException("Укажите дату встречи") + + if (meetingTime.isBlank()) + throw IllegalArgumentException("Укажите время встречи") + + if (contact.isBlank()) + throw IllegalArgumentException("Укажите контакт") + + if (comment.isBlank()) + throw IllegalArgumentException("Укажите комментарий") + + val created = repository.createHelpRequest( + fromAddress = routeStart, + toAddress = routeEnd, + date = meetingDate.replace(".", "-"), + time = meetingTime, + phone = contact, + socialNickname = specialNotes.takeIf { + it.isNotBlank() + }, + comment = comment + ) + + requests.add( + 0, + created.toUiModel() + ) + + successMessage.value = "Заявка отправлена" + + onSuccess() + + } catch (e: Exception) { + + errorMessage.value = + e.message ?: "Ошибка" + + } finally { + + isLoading.value = false + } + } + } + + fun deleteRequest(id: String) { + + viewModelScope.launch { + + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + + repository.deleteOwnRequest(id) + + requests.removeAll { + it.id == id + } + + successMessage.value = + "Заявка удалена" + + } catch (e: Exception) { + + errorMessage.value = + e.message ?: "Не удалось удалить заявку" + + } finally { + + isLoading.value = false + } + } + } + + fun clearMessages() { + errorMessage.value = null + successMessage.value = null + } + + private fun HelpRequestItem.toUiModel(): HelpRequest { + + return HelpRequest( + id = id, + routeStart = routeStart, + routeEnd = routeEnd, + dateTime = dateTime, + contact = contact, + specialNotes = specialNotes.ifBlank { "—" }, + comment = comment.ifBlank { "—" }, + status = status.toUiStatus() + ) + } + + private fun ApiRequestStatus.toUiStatus(): RequestStatus { + + return when (this) { + + ApiRequestStatus.OPEN -> + RequestStatus.OPEN + + ApiRequestStatus.ACCEPTED -> + RequestStatus.ACCEPTED + + ApiRequestStatus.CANCELLED -> + RequestStatus.CANCELLED + + ApiRequestStatus.COMPLETED -> + RequestStatus.COMPLETED + + ApiRequestStatus.UNKNOWN -> + RequestStatus.UNKNOWN + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/help/screens/HelpCreateScreen.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt similarity index 76% rename from app/src/main/java/com/example/goodroad/modules/help/screens/HelpCreateScreen.kt rename to app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt index 123242a..5e084e3 100644 --- a/app/src/main/java/com/example/goodroad/modules/help/screens/HelpCreateScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpRequestCreateScreen.kt @@ -1,4 +1,4 @@ -package com.example.goodroad.modules.help.screens +package com.example.goodroad.modules.volunteer.screens import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -8,20 +8,21 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.example.goodroad.modules.help.presentation.HelpViewModel +import com.example.goodroad.modules.volunteer.presentation.VolunteerViewModel import com.example.goodroad.ui.UserDecor import com.example.goodroad.ui.buttons.PrimaryButton import com.example.goodroad.ui.theme.BackgroundLight @Composable -fun HelpCreateScreen( - helpViewModel: HelpViewModel, +fun HelpRequestCreateScreen( + helpViewModel: VolunteerViewModel, onBack: () -> Unit, onCreated: () -> Unit ) { val isLoading by helpViewModel.isLoading val error by helpViewModel.errorMessage + val success by helpViewModel.successMessage var routeStart by rememberSaveable { mutableStateOf("") } var routeEnd by rememberSaveable { mutableStateOf("") } @@ -72,7 +73,7 @@ fun HelpCreateScreen( UserDecor() Text( - "Новая заявка", + text = "Новая заявка", style = MaterialTheme.typography.headlineLarge ) @@ -81,7 +82,9 @@ fun HelpCreateScreen( OutlinedTextField( value = routeStart, onValueChange = { routeStart = it }, - label = { Text("Начало маршрута с сопровождением") }, + label = { + Text("Начало маршрута с сопровождением") + }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, singleLine = true @@ -92,7 +95,9 @@ fun HelpCreateScreen( OutlinedTextField( value = routeEnd, onValueChange = { routeEnd = it }, - label = { Text("Конец маршртуа с сопровождением") }, + label = { + Text("Конец маршрута с сопровождением") + }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, singleLine = true @@ -106,7 +111,9 @@ fun HelpCreateScreen( val digits = it.filter { ch -> ch.isDigit() }.take(8) meetingDate = formatDate(digits) }, - label = { Text("Дата (ДД.ММ.ГГГГ)") }, + label = { + Text("Дата (ДД.ММ.ГГГГ)") + }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, singleLine = true @@ -120,7 +127,9 @@ fun HelpCreateScreen( val digits = it.filter { ch -> ch.isDigit() }.take(4) meetingTime = formatTime(digits) }, - label = { Text("Время (ЧЧ:ММ)") }, + label = { + Text("Время (ЧЧ:ММ)") + }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, singleLine = true @@ -135,7 +144,9 @@ fun HelpCreateScreen( ch.isLetterOrDigit() || ch in "+@._-() " } }, - label = { Text("Номер телефона") }, + label = { + Text("Номер телефона") + }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, singleLine = true @@ -144,13 +155,13 @@ fun HelpCreateScreen( Spacer(Modifier.height(12.dp)) OutlinedTextField( - value = contact, + value = specialNotes, onValueChange = { - contact = it.filter { ch -> - ch.isLetterOrDigit() || ch in "+@._-() " - } + specialNotes = it + }, + label = { + Text("Telegram / ВК / доп. контакт") }, - label = { Text("Telegram / ВК / Номер почтового голубя)") }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, singleLine = true @@ -160,26 +171,47 @@ fun HelpCreateScreen( OutlinedTextField( value = comment, - onValueChange = { comment = it }, - label = { Text("Комментарий") }, + onValueChange = { + comment = it + }, + label = { + Text("Комментарий") + }, modifier = Modifier.fillMaxWidth(), shape = MaterialTheme.shapes.large, minLines = 2 ) if (error != null) { + Spacer(Modifier.height(12.dp)) + Text( text = error!!, color = MaterialTheme.colorScheme.error ) } + if (success != null) { + + Spacer(Modifier.height(12.dp)) + + Text( + text = success!!, + color = MaterialTheme.colorScheme.primary + ) + } + Spacer(Modifier.height(24.dp)) PrimaryButton( - text = if (isLoading) "Отправка..." else "Отправить заявку", + text = if (isLoading) + "Отправка..." + else + "Отправить заявку", + onClick = { + helpViewModel.createRequest( routeStart = routeStart, routeEnd = routeEnd, @@ -189,12 +221,11 @@ fun HelpCreateScreen( specialNotes = specialNotes, comment = comment ) { + helpViewModel.clearMessages() onCreated() } } ) - - Spacer(Modifier.height(40.dp)) } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/UserHelpRequestsScreen.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/UserHelpRequestsScreen.kt new file mode 100644 index 0000000..167ad0d --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/UserHelpRequestsScreen.kt @@ -0,0 +1,236 @@ +package com.example.goodroad.modules.volunteer.screens + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +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.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.example.goodroad.modules.volunteer.presentation.VolunteerViewModel +import com.example.goodroad.ui.UserDecor +import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.UrbanBrown + +@Composable +fun UserHelpRequestsScreen( + viewModel: VolunteerViewModel +) { + val requests = viewModel.requests + val isLoading by viewModel.isLoading + val errorMessage by viewModel.errorMessage + val successMessage by viewModel.successMessage + + var deleteTarget by remember { mutableStateOf(null) } + + fun statusText(status: VolunteerViewModel.RequestStatus): String { + return when (status) { + VolunteerViewModel.RequestStatus.PENDING -> "В обработке" + VolunteerViewModel.RequestStatus.APPROVED -> "Одобрено" + VolunteerViewModel.RequestStatus.REJECTED -> "Отклонено" + VolunteerViewModel.RequestStatus.OPEN -> "Открыта" + VolunteerViewModel.RequestStatus.ACCEPTED -> "Принята" + VolunteerViewModel.RequestStatus.CANCELLED -> "Отменена" + VolunteerViewModel.RequestStatus.COMPLETED -> "Выполнена" + VolunteerViewModel.RequestStatus.UNKNOWN -> "Неизвестно" + } + } + + fun actionLabel(status: VolunteerViewModel.RequestStatus): String { + return when (status) { + VolunteerViewModel.RequestStatus.ACCEPTED -> "Отменить" + VolunteerViewModel.RequestStatus.COMPLETED -> "" + else -> "Удалить" + } + } + + LaunchedEffect(Unit) { + viewModel.loadOwnRequests() + } + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + UserDecor() + + Text( + text = "Мои заявки", + style = MaterialTheme.typography.headlineLarge + ) + + if (errorMessage != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = errorMessage!!, + color = MaterialTheme.colorScheme.error + ) + } + + if (successMessage != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = successMessage!!, + color = MaterialTheme.colorScheme.primary + ) + } + + Spacer(Modifier.height(16.dp)) + + if (isLoading && requests.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() + } + } else if (requests.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text("Пока нет заявок") + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f) + ) { + items(requests, key = { it.id }) { req -> + OutlinedCard( + border = BorderStroke(2.dp, UrbanBrown.copy(alpha = 0.4f)), + colors = CardDefaults.outlinedCardColors( + containerColor = BackgroundLight + ), + modifier = Modifier.fillMaxWidth() + ) { + Column(Modifier.padding(16.dp)) { + Text( + text = "Маршрут", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text("${req.routeStart} ➜ ${req.routeEnd}") + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Дата", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text(req.dateTime) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Контакт", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text(req.contact) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Особенности", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text(req.specialNotes) + + Spacer(Modifier.height(8.dp)) + + Text( + text = "Комментарий", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text(req.comment) + + Spacer(Modifier.height(10.dp)) + + Text( + text = "Статус", + color = UrbanBrown, + fontWeight = FontWeight.SemiBold + ) + Text(statusText(req.status)) + + if (req.status != VolunteerViewModel.RequestStatus.COMPLETED) { + Spacer(Modifier.height(12.dp)) + + PrimaryButton( + text = actionLabel(req.status), + onClick = { deleteTarget = req } + ) + } + } + } + } + } + } + } + } + + if (deleteTarget != null) { + val target = deleteTarget!! + + AlertDialog( + onDismissRequest = { deleteTarget = null }, + title = { Text("Подтверждение") }, + text = { + Text( + if (target.status == VolunteerViewModel.RequestStatus.ACCEPTED) { + "Отменить эту заявку?" + } else { + "Удалить эту заявку?" + } + ) + }, + confirmButton = { + TextButton(onClick = { + viewModel.deleteRequest(target.id) + deleteTarget = null + }) { + Text("Да") + } + }, + dismissButton = { + TextButton(onClick = { deleteTarget = null }) { + Text("Нет") + } + } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/help/screens/HelpScreen.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerScreen.kt similarity index 92% rename from app/src/main/java/com/example/goodroad/modules/help/screens/HelpScreen.kt rename to app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerScreen.kt index 89b62cc..36c5c3a 100644 --- a/app/src/main/java/com/example/goodroad/modules/help/screens/HelpScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerScreen.kt @@ -1,19 +1,19 @@ -package com.example.goodroad.modules.help.screens +package com.example.goodroad.modules.volunteer.screens import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.example.goodroad.modules.help.presentation.HelpViewModel import com.example.goodroad.ui.UserDecor import com.example.goodroad.ui.buttons.PrimaryButton import com.example.goodroad.ui.theme.* import androidx.compose.ui.text.font.FontWeight +import com.example.goodroad.modules.volunteer.presentation.VolunteerViewModel @Composable -fun HelpScreen( - helpViewModel: HelpViewModel, +fun VolunteerScreen( + helpViewModel: VolunteerViewModel, onCreateRequest: () -> Unit, onMyRequests: () -> Unit ) { From 58b61feb59efe49541ad5eb58ea28b3b1f08ebae Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Sun, 24 May 2026 20:38:56 +0300 Subject: [PATCH 5/9] feat: linked VolunteerApplicationFormScreen with backend --- .../modules/user/navigation/UserNav.kt | 3 +- .../screens/VolunteerApplicationFormScreen.kt | 137 ----------- .../volunteer/data/VolunteerRepository.kt | 2 +- .../presentation/VolunteerViewModel.kt | 169 ++++++++------ .../{VolunteerScreen.kt => HelpScreen.kt} | 0 .../screens/VolunteerApplicationFormScreen.kt | 214 ++++++++++++++++++ 6 files changed, 314 insertions(+), 211 deletions(-) delete mode 100644 app/src/main/java/com/example/goodroad/modules/user/screens/VolunteerApplicationFormScreen.kt rename app/src/main/java/com/example/goodroad/modules/volunteer/screens/{VolunteerScreen.kt => HelpScreen.kt} (100%) create mode 100644 app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt diff --git a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt index bc21c90..85162fd 100644 --- a/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt +++ b/app/src/main/java/com/example/goodroad/modules/user/navigation/UserNav.kt @@ -31,7 +31,7 @@ import com.example.goodroad.modules.user.presentation.UserViewModel import com.example.goodroad.modules.user.screens.UserEditScreen import com.example.goodroad.ui.user.UserDeleteAccountScreen import com.example.goodroad.ui.user.UserProfileScreen -import com.example.goodroad.ui.user.screens.VolunteerApplicationFormScreen +import com.example.goodroad.ui.volunteer.screens.VolunteerApplicationFormScreen import com.example.goodroad.modules.volunteer.data.VolunteerRepository enum class BottomTab { @@ -287,6 +287,7 @@ fun UserNav( OverlayScreen.VOLUNTEER_APPLICATION -> { VolunteerApplicationFormScreen( + viewModel = helpViewModel, onBack = { overlayScreen = OverlayScreen.NONE }, onSubmitted = { overlayScreen = OverlayScreen.NONE } ) diff --git a/app/src/main/java/com/example/goodroad/modules/user/screens/VolunteerApplicationFormScreen.kt b/app/src/main/java/com/example/goodroad/modules/user/screens/VolunteerApplicationFormScreen.kt deleted file mode 100644 index c04d8c8..0000000 --- a/app/src/main/java/com/example/goodroad/modules/user/screens/VolunteerApplicationFormScreen.kt +++ /dev/null @@ -1,137 +0,0 @@ -package com.example.goodroad.ui.user.screens - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.unit.dp -import com.example.goodroad.ui.UserDecor -import com.example.goodroad.ui.buttons.PrimaryButton -import com.example.goodroad.ui.theme.BackgroundLight -import com.example.goodroad.ui.theme.UrbanBrown - -@Composable -fun VolunteerApplicationFormScreen( - onBack: () -> Unit, - onSubmitted: () -> Unit -) { - var motivation by rememberSaveable { mutableStateOf("") } - var dobroLink by rememberSaveable { mutableStateOf("") } - var contacts by rememberSaveable { mutableStateOf("") } - var certificates by rememberSaveable { mutableStateOf("") } - - var error by rememberSaveable { mutableStateOf(null) } - - val scrollState = rememberScrollState() - - Surface( - modifier = Modifier.fillMaxSize(), - color = BackgroundLight - ) { - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - .padding(24.dp) - ) { - - UserDecor() - - Text( - text = "Заявка на волонтёрство", - style = MaterialTheme.typography.headlineLarge, - color = Color.Black - ) - - Spacer(Modifier.height(20.dp)) - - Card( - colors = CardDefaults.cardColors( - containerColor = UrbanBrown.copy(alpha = 0.06f) - ) - ) { - Column(Modifier.padding(16.dp)) { - Text( - text = "Заполните заявку и мы свяжемся с вами в течение недели", - style = MaterialTheme.typography.bodyLarge, - color = UrbanBrown - ) - } - } - - Spacer(Modifier.height(16.dp)) - - error?.let { - Text(text = it, color = MaterialTheme.colorScheme.error) - Spacer(Modifier.height(8.dp)) - } - - OutlinedTextField( - value = motivation, - onValueChange = { - motivation = it - error = null - }, - label = { Text("Мотивация") }, - modifier = Modifier.fillMaxWidth(), - minLines = 3 - ) - - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( - value = dobroLink, - onValueChange = { - dobroLink = it - error = null - }, - label = { Text("Ссылка на dobro.ru") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( - value = contacts, - onValueChange = { - contacts = it - error = null - }, - label = { Text("Контакты (телефон / telegram / ВК)") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true - ) - - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( - value = certificates, - onValueChange = { - certificates = it - error = null - }, - label = { Text("Сертификаты, что я молодец!!") }, - modifier = Modifier.fillMaxWidth(), - minLines = 3 - ) - - Spacer(Modifier.height(24.dp)) - - PrimaryButton( - text = "Отправить заявку", - onClick = { - if (motivation.isBlank() || dobroLink.isBlank() || contacts.isBlank()) { - error = "Заполните обязательные поля" - return@PrimaryButton - } - onSubmitted() - } - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerRepository.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerRepository.kt index 9262d22..f486ea8 100644 --- a/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerRepository.kt +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/data/VolunteerRepository.kt @@ -13,7 +13,7 @@ class VolunteerRepository( dobroUrl: String, phone: String, socialNickname: String?, - certificatePhotoUrls: List? + certificatePhotoUrls: List = emptyList() ) = api.createApplication( CreateVolunteerApplicationReqDto( dobroUrl = dobroUrl, diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt index f0c9354..41d1e97 100644 --- a/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt @@ -1,5 +1,7 @@ package com.example.goodroad.modules.volunteer.presentation +import android.content.Context +import android.net.Uri import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel @@ -8,12 +10,17 @@ import com.example.goodroad.modules.volunteer.data.VolunteerRepository import com.example.goodroad.modules.volunteer.data.models.HelpRequestItem import com.example.goodroad.modules.volunteer.data.models.RequestStatus as ApiRequestStatus 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 VolunteerViewModel( private val repository: VolunteerRepository ) : ViewModel() { - - enum class RequestStatus { + enum class RequestStatus { PENDING, APPROVED, REJECTED, @@ -52,27 +59,19 @@ class VolunteerViewModel( fun loadOwnRequests() { viewModelScope.launch { - isLoading.value = true errorMessage.value = null successMessage.value = null try { - val loaded = repository.loadOwnRequests() requests.clear() - requests.addAll( - loaded.map { it.toUiModel() } - ) + requests.addAll(loaded.map { it.toUiModel() }) } catch (e: Exception) { - - errorMessage.value = - e.message ?: "Ошибка загрузки заявок" - + errorMessage.value = e.message ?: "Ошибка загрузки заявок" } finally { - isLoading.value = false } } @@ -92,32 +91,18 @@ class VolunteerViewModel( comment: String, onSuccess: () -> Unit ) { - viewModelScope.launch { - isLoading.value = true errorMessage.value = null successMessage.value = null try { - - if (routeStart.isBlank()) - throw IllegalArgumentException("Укажите начало маршрута") - - if (routeEnd.isBlank()) - throw IllegalArgumentException("Укажите конец маршрута") - - if (meetingDate.isBlank()) - throw IllegalArgumentException("Укажите дату встречи") - - if (meetingTime.isBlank()) - throw IllegalArgumentException("Укажите время встречи") - - if (contact.isBlank()) - throw IllegalArgumentException("Укажите контакт") - - if (comment.isBlank()) - throw IllegalArgumentException("Укажите комментарий") + if (routeStart.isBlank()) throw IllegalArgumentException("Укажите начало маршрута") + if (routeEnd.isBlank()) throw IllegalArgumentException("Укажите конец маршрута") + if (meetingDate.isBlank()) throw IllegalArgumentException("Укажите дату") + if (meetingTime.isBlank()) throw IllegalArgumentException("Укажите время") + if (contact.isBlank()) throw IllegalArgumentException("Укажите контакт") + if (comment.isBlank()) throw IllegalArgumentException("Укажите комментарий") val created = repository.createHelpRequest( fromAddress = routeStart, @@ -125,59 +110,111 @@ class VolunteerViewModel( date = meetingDate.replace(".", "-"), time = meetingTime, phone = contact, - socialNickname = specialNotes.takeIf { - it.isNotBlank() - }, + socialNickname = specialNotes.takeIf { it.isNotBlank() }, comment = comment ) - requests.add( - 0, - created.toUiModel() - ) + requests.add(0, created.toUiModel()) successMessage.value = "Заявка отправлена" - onSuccess() } catch (e: Exception) { - - errorMessage.value = - e.message ?: "Ошибка" - + errorMessage.value = e.message ?: "Ошибка" } finally { - isLoading.value = false } } } - fun deleteRequest(id: String) { - + fun submitVolunteerApplication( + context: Context, + dobroUrl: String, + phone: String, + socialNickname: String?, + uris: List, + onSuccess: () -> Unit + ) { viewModelScope.launch { isLoading.value = true errorMessage.value = null successMessage.value = null + val uploadedUrls = mutableListOf() + val tempFiles = mutableListOf() + try { - repository.deleteOwnRequest(id) + if (dobroUrl.isBlank()) + throw IllegalArgumentException("Укажите ссылку на dobro.ru") + + if (!dobroUrl.startsWith("http")) + throw IllegalArgumentException("Неверная ссылка") + + if (phone.isBlank()) + throw IllegalArgumentException("Укажите телефон") + + uris.forEach { uri -> + + val file = File.createTempFile("cert", ".tmp", context.cacheDir) + tempFiles.add(file) - requests.removeAll { - it.id == id + context.contentResolver.openInputStream(uri)?.use { input -> + file.outputStream().use { output -> + input.copyTo(output) + } + } + + val mime = context.contentResolver.getType(uri) ?: "image/*" + + val body = file.asRequestBody(mime.toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData( + "file", + file.name, + body + ) + + val response = repository.uploadCertificate(part) + ?: throw IllegalStateException("Ошибка загрузки файла") + + uploadedUrls.add(response.photoUrl) } - successMessage.value = - "Заявка удалена" + repository.createApplication( + dobroUrl = dobroUrl.trim(), + phone = phone.trim(), + socialNickname = socialNickname?.trim()?.ifBlank { null }, + certificatePhotoUrls = uploadedUrls + ) + + successMessage.value = "Заявка на волонтёрство отправлена" + onSuccess() } catch (e: Exception) { + errorMessage.value = e.message ?: "Ошибка отправки заявки" + } finally { + tempFiles.forEach { it.delete() } + isLoading.value = false + } + } + } - errorMessage.value = - e.message ?: "Не удалось удалить заявку" - } finally { + fun deleteRequest(id: String) { + viewModelScope.launch { + isLoading.value = true + errorMessage.value = null + successMessage.value = null + + try { + repository.deleteOwnRequest(id) + requests.removeAll { it.id == id } + successMessage.value = "Заявка удалена" + } catch (e: Exception) { + errorMessage.value = e.message ?: "Ошибка удаления" + } finally { isLoading.value = false } } @@ -189,7 +226,6 @@ class VolunteerViewModel( } private fun HelpRequestItem.toUiModel(): HelpRequest { - return HelpRequest( id = id, routeStart = routeStart, @@ -203,23 +239,12 @@ class VolunteerViewModel( } private fun ApiRequestStatus.toUiStatus(): RequestStatus { - return when (this) { - - ApiRequestStatus.OPEN -> - RequestStatus.OPEN - - ApiRequestStatus.ACCEPTED -> - RequestStatus.ACCEPTED - - ApiRequestStatus.CANCELLED -> - RequestStatus.CANCELLED - - ApiRequestStatus.COMPLETED -> - RequestStatus.COMPLETED - - ApiRequestStatus.UNKNOWN -> - RequestStatus.UNKNOWN + ApiRequestStatus.OPEN -> RequestStatus.OPEN + ApiRequestStatus.ACCEPTED -> RequestStatus.ACCEPTED + ApiRequestStatus.CANCELLED -> RequestStatus.CANCELLED + ApiRequestStatus.COMPLETED -> RequestStatus.COMPLETED + ApiRequestStatus.UNKNOWN -> RequestStatus.UNKNOWN } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerScreen.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpScreen.kt similarity index 100% rename from app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerScreen.kt rename to app/src/main/java/com/example/goodroad/modules/volunteer/screens/HelpScreen.kt diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt new file mode 100644 index 0000000..1da0eed --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt @@ -0,0 +1,214 @@ +package com.example.goodroad.ui.volunteer.screens + +import android.content.Context +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +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.rememberAsyncImagePainter +import com.example.goodroad.modules.volunteer.presentation.VolunteerViewModel +import com.example.goodroad.ui.UserDecor +import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.UrbanBrown + +@Composable +fun VolunteerApplicationFormScreen( + viewModel: VolunteerViewModel, + onBack: () -> Unit, + onSubmitted: () -> Unit +) { + val context = LocalContext.current + + var dobroUrl by rememberSaveable { mutableStateOf("") } + var phone by rememberSaveable { mutableStateOf("") } + var socialNickname by rememberSaveable { mutableStateOf("") } + + val selectedUris = remember { mutableStateListOf() } + + var isSubmitted by rememberSaveable { mutableStateOf(false) } + + val isLoading by viewModel.isLoading + val error by viewModel.errorMessage + val success by viewModel.successMessage + + val scrollState = rememberScrollState() + + val picker = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris -> + uris.forEach { selectedUris.add(it) } + } + + if (isSubmitted) { + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + verticalArrangement = Arrangement.Center + ) { + + Text( + text = "Заявка отправлена", + style = MaterialTheme.typography.headlineLarge, + color = UrbanBrown + ) + + Spacer(Modifier.height(12.dp)) + + Text("Мы рассмотрим её и свяжемся с вами") + + Spacer(Modifier.height(24.dp)) + + PrimaryButton( + text = "Понятно", + onClick = onSubmitted + ) + } + } + + } else { + + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + + Column( + modifier = Modifier + .verticalScroll(scrollState) + .padding(24.dp) + ) { + + UserDecor() + + Text( + "Заявка на волонтёрство", + style = MaterialTheme.typography.headlineLarge + ) + + Spacer(Modifier.height(16.dp)) + + if (error != null) Text(error!!, color = MaterialTheme.colorScheme.error) + if (success != null) Text(success!!, color = MaterialTheme.colorScheme.primary) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = dobroUrl, + onValueChange = { + dobroUrl = it + viewModel.clearMessages() + }, + label = { Text("Dobro.ru URL") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = phone, + onValueChange = { + phone = it + viewModel.clearMessages() + }, + label = { Text("Телефон") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = socialNickname, + onValueChange = { + socialNickname = it + viewModel.clearMessages() + }, + label = { Text("Telegram / VK ник") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + + Button(onClick = { picker.launch("image/*") }) { + Text("Добавить сертификаты") + } + + Spacer(Modifier.height(12.dp)) + + if (selectedUris.isNotEmpty()) { + + Text("Сертификаты:", style = MaterialTheme.typography.titleMedium) + + Spacer(Modifier.height(8.dp)) + + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(selectedUris) { uri -> + + Box { + + Image( + painter = rememberAsyncImagePainter(uri), + contentDescription = null, + modifier = Modifier + .size(90.dp) + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop + ) + + TextButton( + onClick = { selectedUris.remove(uri) }, + modifier = Modifier.padding(4.dp) + ) { + Text("✕") + } + } + } + } + } + + Spacer(Modifier.height(24.dp)) + + PrimaryButton( + text = if (isLoading) "Отправка..." else "Отправить", + onClick = { + + if (dobroUrl.isBlank() || phone.isBlank()) return@PrimaryButton + + viewModel.submitVolunteerApplication( + context = context, + dobroUrl = dobroUrl, + phone = phone, + socialNickname = socialNickname.ifBlank { null }, + uris = selectedUris, + onSuccess = { + isSubmitted = true + } + ) + } + ) + } + } + } +} \ No newline at end of file From a63928349e77ab81e5941d25e69d1d85f7ce540f Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Sun, 24 May 2026 22:16:02 +0300 Subject: [PATCH 6/9] feat: linked VolunteerApplicationFormScreen with backend --- ...kotlin-compiler-3065407352660487343.salive | 0 .../presentation/VolunteerViewModel.kt | 23 +++- .../screens/VolunteerApplicationFormScreen.kt | 101 ++++++++++++++++-- 3 files changed, 114 insertions(+), 10 deletions(-) create mode 100644 .kotlin/sessions/kotlin-compiler-3065407352660487343.salive diff --git a/.kotlin/sessions/kotlin-compiler-3065407352660487343.salive b/.kotlin/sessions/kotlin-compiler-3065407352660487343.salive new file mode 100644 index 0000000..e69de29 diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt index 41d1e97..b044dc9 100644 --- a/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt @@ -192,8 +192,27 @@ class VolunteerViewModel( onSuccess() } catch (e: Exception) { - errorMessage.value = e.message ?: "Ошибка отправки заявки" - } finally { + + val message = when (e) { + + is HttpException -> { + when (e.code()) { + 400 -> "Ошибка в заполненных данных" + 403 -> "Нет прав для выполнения действия" + 404 -> "Объект не найден" + 409 -> "Заявка уже существует или уже отправлена" + else -> "Ошибка сервера (${e.code()})" + } + } + + is IOException -> "Ошибка сети. Проверьте интернет" + + else -> e.message ?: "Ошибка отправки заявки" + } + + errorMessage.value = message + } + finally { tempFiles.forEach { it.delete() } isLoading.value = false } diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt index 1da0eed..3fc802f 100644 --- a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt @@ -67,6 +67,8 @@ fun VolunteerApplicationFormScreen( verticalArrangement = Arrangement.Center ) { + UserDecor(); + Text( text = "Заявка отправлена", style = MaterialTheme.typography.headlineLarge, @@ -75,7 +77,10 @@ fun VolunteerApplicationFormScreen( Spacer(Modifier.height(12.dp)) - Text("Мы рассмотрим её и свяжемся с вами") + Text( + text = "Мы рассмотрим её и свяжемся с вами в течение недели!", + style = MaterialTheme.typography.titleLarge + ) Spacer(Modifier.height(24.dp)) @@ -108,8 +113,14 @@ fun VolunteerApplicationFormScreen( Spacer(Modifier.height(16.dp)) - if (error != null) Text(error!!, color = MaterialTheme.colorScheme.error) - if (success != null) Text(success!!, color = MaterialTheme.colorScheme.primary) + ErrorBlock(error) + + if (success != null) { + Text( + success!!, + color = MaterialTheme.colorScheme.primary + ) + } Spacer(Modifier.height(12.dp)) @@ -157,13 +168,11 @@ fun VolunteerApplicationFormScreen( if (selectedUris.isNotEmpty()) { - Text("Сертификаты:", style = MaterialTheme.typography.titleMedium) + Text("Сертификаты:") Spacer(Modifier.height(8.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(selectedUris) { uri -> Box { @@ -194,7 +203,26 @@ fun VolunteerApplicationFormScreen( text = if (isLoading) "Отправка..." else "Отправить", onClick = { - if (dobroUrl.isBlank() || phone.isBlank()) return@PrimaryButton + if (dobroUrl.isBlank() || phone.isBlank()) { + viewModel.errorMessage.value = "Заполните все обязательные поля" + return@PrimaryButton + } + + val phoneRegex = Regex("^\\+?[0-9]{11}$") + + if (!phoneRegex.matches(phone.trim())) { + viewModel.errorMessage.value = "Некорректный номер телефона" + return@PrimaryButton + } + + val nickname = socialNickname.trim() + + val nicknameRegex = Regex("^[a-zA-Z0-9_.@]{3,32}$") + + if (nickname.isNotBlank() && !nicknameRegex.matches(nickname)) { + viewModel.errorMessage.value = "Ник должен содержать только латиницу, цифры, _ или ." + return@PrimaryButton + } viewModel.submitVolunteerApplication( context = context, @@ -211,4 +239,61 @@ fun VolunteerApplicationFormScreen( } } } +} + +@Composable +private fun ErrorBlock(error: String?) { + if (error.isNullOrBlank()) return + + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + text = error, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(12.dp) + ) + } + + Spacer(Modifier.height(8.dp)) +} + +fun Throwable.toUserMessage(): String { + + val msg = this.message ?: return "Неизвестная ошибка" + + return when { + + msg.contains("DOBRO_URL_INVALID") -> + "Некорректная ссылка на dobro.ru" + + msg.contains("PHONE_INVALID") -> + "Некорректный номер телефона" + + msg.contains("CERTIFICATE_URL_INVALID") -> + "Ошибка в ссылке сертификата" + + msg.contains("APPLICATION_ALREADY_PENDING") -> + "Заявка уже отправлена и находится на рассмотрении" + + msg.contains("ALREADY_VOLUNTEER") -> + "Вы уже зарегистрированы как волонтёр" + + msg.contains("403") -> + "Нет прав для выполнения действия" + + msg.contains("404") -> + "Объект не найден" + + msg.contains("409") -> + "Конфликт данных (заявка уже существует)" + + msg.contains("400") -> + "Ошибка в заполненных данных" + + else -> + "Ошибка сервера. Попробуйте позже" + } } \ No newline at end of file From 1959e9ce3b4e03238ca2cd0ff1c74b7ed8a0d961 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Tue, 2 Jun 2026 17:38:17 +0300 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20fix=20button=20=D0=BF=D0=BE=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D0=BD=D0=BE=20in=20lower=20part=20of=20VolunteerAp?= =?UTF-8?q?plocationFormScreen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...tlin-compiler-15721739225087925811.salive} | 0 .../presentation/VolunteerViewModel.kt | 97 ++--- .../screens/VolunteerApplicationFormScreen.kt | 383 +++++++++--------- 3 files changed, 239 insertions(+), 241 deletions(-) rename .kotlin/sessions/{kotlin-compiler-3065407352660487343.salive => kotlin-compiler-15721739225087925811.salive} (100%) diff --git a/.kotlin/sessions/kotlin-compiler-3065407352660487343.salive b/.kotlin/sessions/kotlin-compiler-15721739225087925811.salive similarity index 100% rename from .kotlin/sessions/kotlin-compiler-3065407352660487343.salive rename to .kotlin/sessions/kotlin-compiler-15721739225087925811.salive diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt index b044dc9..a94a5df 100644 --- a/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/presentation/VolunteerViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope import com.example.goodroad.modules.volunteer.data.VolunteerRepository import com.example.goodroad.modules.volunteer.data.models.HelpRequestItem import com.example.goodroad.modules.volunteer.data.models.RequestStatus as ApiRequestStatus +import com.example.goodroad.modules.volunteer.data.models.VolunteerMenuRespDto import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody @@ -20,7 +21,8 @@ import java.io.IOException class VolunteerViewModel( private val repository: VolunteerRepository ) : ViewModel() { - enum class RequestStatus { + + enum class RequestStatus { PENDING, APPROVED, REJECTED, @@ -42,6 +44,15 @@ class VolunteerViewModel( val status: RequestStatus = RequestStatus.OPEN ) + data class VolunteerMenu( + val isVolunteer: Boolean, + val applicationStatus: String?, + val rejectReason: String? + ) + + var volunteerMenu = mutableStateOf(null) + private set + var isLoading = mutableStateOf(false) private set @@ -55,6 +66,26 @@ class VolunteerViewModel( init { loadOwnRequests() + loadVolunteerMenu() + } + + fun loadVolunteerMenu() { + viewModelScope.launch { + try { + val resp = repository.getMenu() + volunteerMenu.value = VolunteerMenu( + isVolunteer = resp.volunteer, + applicationStatus = resp.applicationStatus, + rejectReason = resp.rejectReason + ) + } catch (e: Exception) { + errorMessage.value = e.message ?: "Ошибка загрузки статуса заявки" + } + } + } + + fun clearVolunteerMenu() { + volunteerMenu.value = null } fun loadOwnRequests() { @@ -65,10 +96,8 @@ class VolunteerViewModel( try { val loaded = repository.loadOwnRequests() - requests.clear() requests.addAll(loaded.map { it.toUiModel() }) - } catch (e: Exception) { errorMessage.value = e.message ?: "Ошибка загрузки заявок" } finally { @@ -77,9 +106,7 @@ class VolunteerViewModel( } } - fun refreshOwnRequests() { - loadOwnRequests() - } + fun refreshOwnRequests() = loadOwnRequests() fun createRequest( routeStart: String, @@ -97,13 +124,6 @@ class VolunteerViewModel( successMessage.value = null try { - if (routeStart.isBlank()) throw IllegalArgumentException("Укажите начало маршрута") - if (routeEnd.isBlank()) throw IllegalArgumentException("Укажите конец маршрута") - if (meetingDate.isBlank()) throw IllegalArgumentException("Укажите дату") - if (meetingTime.isBlank()) throw IllegalArgumentException("Укажите время") - if (contact.isBlank()) throw IllegalArgumentException("Укажите контакт") - if (comment.isBlank()) throw IllegalArgumentException("Укажите комментарий") - val created = repository.createHelpRequest( fromAddress = routeStart, toAddress = routeEnd, @@ -115,10 +135,8 @@ class VolunteerViewModel( ) requests.add(0, created.toUiModel()) - successMessage.value = "Заявка отправлена" onSuccess() - } catch (e: Exception) { errorMessage.value = e.message ?: "Ошибка" } finally { @@ -136,7 +154,6 @@ class VolunteerViewModel( onSuccess: () -> Unit ) { viewModelScope.launch { - isLoading.value = true errorMessage.value = null successMessage.value = null @@ -145,18 +162,19 @@ class VolunteerViewModel( val tempFiles = mutableListOf() try { - - if (dobroUrl.isBlank()) + if (dobroUrl.isBlank()) { throw IllegalArgumentException("Укажите ссылку на dobro.ru") + } - if (!dobroUrl.startsWith("http")) + if (!dobroUrl.startsWith("http")) { throw IllegalArgumentException("Неверная ссылка") + } - if (phone.isBlank()) + if (phone.isBlank()) { throw IllegalArgumentException("Укажите телефон") + } uris.forEach { uri -> - val file = File.createTempFile("cert", ".tmp", context.cacheDir) tempFiles.add(file) @@ -167,17 +185,10 @@ class VolunteerViewModel( } val mime = context.contentResolver.getType(uri) ?: "image/*" - val body = file.asRequestBody(mime.toMediaTypeOrNull()) - val part = MultipartBody.Part.createFormData( - "file", - file.name, - body - ) + val part = MultipartBody.Part.createFormData("file", file.name, body) val response = repository.uploadCertificate(part) - ?: throw IllegalStateException("Ошибка загрузки файла") - uploadedUrls.add(response.photoUrl) } @@ -189,37 +200,28 @@ class VolunteerViewModel( ) successMessage.value = "Заявка на волонтёрство отправлена" + loadVolunteerMenu() onSuccess() - } catch (e: Exception) { - - val message = when (e) { - - is HttpException -> { - when (e.code()) { - 400 -> "Ошибка в заполненных данных" - 403 -> "Нет прав для выполнения действия" - 404 -> "Объект не найден" - 409 -> "Заявка уже существует или уже отправлена" - else -> "Ошибка сервера (${e.code()})" - } + errorMessage.value = when (e) { + is HttpException -> when (e.code()) { + 400 -> "Ошибка в заполненных данных" + 403 -> "Нет прав для выполнения действия" + 404 -> "Объект не найден" + 409 -> "Заявка уже отправлена или уже существует" + else -> "Ошибка сервера (${e.code()})" } is IOException -> "Ошибка сети. Проверьте интернет" - else -> e.message ?: "Ошибка отправки заявки" } - - errorMessage.value = message - } - finally { + } finally { tempFiles.forEach { it.delete() } isLoading.value = false } } } - fun deleteRequest(id: String) { viewModelScope.launch { isLoading.value = true @@ -230,7 +232,6 @@ class VolunteerViewModel( repository.deleteOwnRequest(id) requests.removeAll { it.id == id } successMessage.value = "Заявка удалена" - } catch (e: Exception) { errorMessage.value = e.message ?: "Ошибка удаления" } finally { diff --git a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt index 3fc802f..ca3a0cd 100644 --- a/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/volunteer/screens/VolunteerApplicationFormScreen.kt @@ -5,15 +5,35 @@ import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale @@ -39,14 +59,15 @@ fun VolunteerApplicationFormScreen( var socialNickname by rememberSaveable { mutableStateOf("") } val selectedUris = remember { mutableStateListOf() } - - var isSubmitted by rememberSaveable { mutableStateOf(false) } + val scrollState = rememberScrollState() val isLoading by viewModel.isLoading val error by viewModel.errorMessage val success by viewModel.successMessage - val scrollState = rememberScrollState() + LaunchedEffect(Unit) { + viewModel.loadVolunteerMenu() + } val picker = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetMultipleContents() @@ -54,189 +75,203 @@ fun VolunteerApplicationFormScreen( uris.forEach { selectedUris.add(it) } } - if (isSubmitted) { + val menu = viewModel.volunteerMenu.value + val applicationStatus = menu?.applicationStatus + val rejectReason = menu?.rejectReason + if (menu != null && applicationStatus != null) { Surface( modifier = Modifier.fillMaxSize(), color = BackgroundLight ) { - Column( + Box( modifier = Modifier .fillMaxSize() - .padding(24.dp), - verticalArrangement = Arrangement.Center - ) { - - UserDecor(); - - Text( - text = "Заявка отправлена", - style = MaterialTheme.typography.headlineLarge, - color = UrbanBrown - ) - - Spacer(Modifier.height(12.dp)) - - Text( - text = "Мы рассмотрим её и свяжемся с вами в течение недели!", - style = MaterialTheme.typography.titleLarge - ) - - Spacer(Modifier.height(24.dp)) - - PrimaryButton( - text = "Понятно", - onClick = onSubmitted - ) - } - } - - } else { - - Surface( - modifier = Modifier.fillMaxSize(), - color = BackgroundLight - ) { - - Column( - modifier = Modifier - .verticalScroll(scrollState) .padding(24.dp) ) { + Column( + modifier = Modifier.fillMaxSize() + ) { + UserDecor() - UserDecor() - - Text( - "Заявка на волонтёрство", - style = MaterialTheme.typography.headlineLarge - ) - - Spacer(Modifier.height(16.dp)) - - ErrorBlock(error) - - if (success != null) { Text( - success!!, - color = MaterialTheme.colorScheme.primary + text = when (applicationStatus) { + "PENDING" -> "Ваша заявка на рассмотрении" + "APPROVED" -> "Вы уже волонтёр!" + "REJECTED" -> "Заявка отклонена" + else -> "Статус заявки" + }, + style = MaterialTheme.typography.headlineLarge, + color = UrbanBrown ) - } - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(12.dp)) - OutlinedTextField( - value = dobroUrl, - onValueChange = { - dobroUrl = it - viewModel.clearMessages() - }, - label = { Text("Dobro.ru URL") }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( - value = phone, - onValueChange = { - phone = it - viewModel.clearMessages() - }, - label = { Text("Телефон") }, - modifier = Modifier.fillMaxWidth() - ) - - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( - value = socialNickname, - onValueChange = { - socialNickname = it - viewModel.clearMessages() - }, - label = { Text("Telegram / VK ник") }, - modifier = Modifier.fillMaxWidth() - ) + Text( + text = when (applicationStatus) { + "PENDING" -> "Мы рассмотрим её и свяжемся с вами в течение недели!" + "APPROVED" -> "У вас уже есть доступ к списку заявок на помощь" + "REJECTED" -> "Причина отказа: ${rejectReason ?: "не указана"}" + else -> "Текущий статус: $applicationStatus" + }, + style = MaterialTheme.typography.titleLarge + ) - Spacer(Modifier.height(16.dp)) + Spacer(Modifier.weight(1f)) - Button(onClick = { picker.launch("image/*") }) { - Text("Добавить сертификаты") + if (applicationStatus == "REJECTED") { + PrimaryButton( + text = "Подать заявку заново", + onClick = { + viewModel.volunteerMenu.value = null + } + ) + } else { + PrimaryButton( + text = "Понятно", + onClick = onSubmitted + ) + } } + } + } + return + } - Spacer(Modifier.height(12.dp)) - - if (selectedUris.isNotEmpty()) { + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .verticalScroll(scrollState) + .padding(24.dp) + ) { + UserDecor() - Text("Сертификаты:") + Text( + text = "Заявка на волонтёрство", + style = MaterialTheme.typography.headlineLarge + ) - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(16.dp)) - LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(selectedUris) { uri -> + ErrorBlock(error) - Box { + if (success != null) { + Text( + text = success!!, + color = MaterialTheme.colorScheme.primary + ) + Spacer(Modifier.height(8.dp)) + } - Image( - painter = rememberAsyncImagePainter(uri), - contentDescription = null, - modifier = Modifier - .size(90.dp) - .clip(RoundedCornerShape(12.dp)), - contentScale = ContentScale.Crop - ) + OutlinedTextField( + value = dobroUrl, + onValueChange = { + dobroUrl = it + viewModel.clearMessages() + }, + label = { Text("Dobro.ru URL") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = phone, + onValueChange = { + phone = it + viewModel.clearMessages() + }, + label = { Text("Телефон") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = socialNickname, + onValueChange = { + socialNickname = it + viewModel.clearMessages() + }, + label = { Text("Telegram / VK ник") }, + modifier = Modifier.fillMaxWidth() + ) + + Spacer(Modifier.height(16.dp)) + + Button(onClick = { picker.launch("image/*") }) { + Text("Добавить сертификаты") + } - TextButton( - onClick = { selectedUris.remove(uri) }, - modifier = Modifier.padding(4.dp) - ) { - Text("✕") - } + Spacer(Modifier.height(12.dp)) + + if (selectedUris.isNotEmpty()) { + Text("Сертификаты:") + Spacer(Modifier.height(8.dp)) + + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(selectedUris) { uri -> + Box { + Image( + painter = rememberAsyncImagePainter(uri), + contentDescription = null, + modifier = Modifier + .height(90.dp) + .clip(RoundedCornerShape(12.dp)), + contentScale = ContentScale.Crop + ) + + TextButton( + onClick = { selectedUris.remove(uri) }, + modifier = Modifier.padding(4.dp) + ) { + Text("✕") } } } } + } - Spacer(Modifier.height(24.dp)) - - PrimaryButton( - text = if (isLoading) "Отправка..." else "Отправить", - onClick = { - - if (dobroUrl.isBlank() || phone.isBlank()) { - viewModel.errorMessage.value = "Заполните все обязательные поля" - return@PrimaryButton - } - - val phoneRegex = Regex("^\\+?[0-9]{11}$") + Spacer(Modifier.height(24.dp)) - if (!phoneRegex.matches(phone.trim())) { - viewModel.errorMessage.value = "Некорректный номер телефона" - return@PrimaryButton - } + PrimaryButton( + text = if (isLoading) "Отправка..." else "Отправить", + onClick = { + if (dobroUrl.isBlank() || phone.isBlank()) { + viewModel.errorMessage.value = "Заполните все обязательные поля" + return@PrimaryButton + } - val nickname = socialNickname.trim() + val phoneDigits = phone.filter { it.isDigit() } + if (phoneDigits.length != 11) { + viewModel.errorMessage.value = "Номер должен содержать ровно 11 цифр" + return@PrimaryButton + } - val nicknameRegex = Regex("^[a-zA-Z0-9_.@]{3,32}$") + val nickname = socialNickname.trim().removePrefix("@") + val nicknameRegex = Regex("^[a-zA-Z0-9_.]{3,32}$") + if (nickname.isNotBlank() && !nicknameRegex.matches(nickname)) { + viewModel.errorMessage.value = + "Ник должен содержать только латиницу, цифры, _ или ." + return@PrimaryButton + } - if (nickname.isNotBlank() && !nicknameRegex.matches(nickname)) { - viewModel.errorMessage.value = "Ник должен содержать только латиницу, цифры, _ или ." - return@PrimaryButton + viewModel.submitVolunteerApplication( + context = context, + dobroUrl = dobroUrl, + phone = phone, + socialNickname = nickname.ifBlank { null }, + uris = selectedUris, + onSuccess = { + viewModel.loadVolunteerMenu() + onSubmitted() } - - viewModel.submitVolunteerApplication( - context = context, - dobroUrl = dobroUrl, - phone = phone, - socialNickname = socialNickname.ifBlank { null }, - uris = selectedUris, - onSuccess = { - isSubmitted = true - } - ) - } - ) - } + ) + } + ) } } } @@ -258,42 +293,4 @@ private fun ErrorBlock(error: String?) { } Spacer(Modifier.height(8.dp)) -} - -fun Throwable.toUserMessage(): String { - - val msg = this.message ?: return "Неизвестная ошибка" - - return when { - - msg.contains("DOBRO_URL_INVALID") -> - "Некорректная ссылка на dobro.ru" - - msg.contains("PHONE_INVALID") -> - "Некорректный номер телефона" - - msg.contains("CERTIFICATE_URL_INVALID") -> - "Ошибка в ссылке сертификата" - - msg.contains("APPLICATION_ALREADY_PENDING") -> - "Заявка уже отправлена и находится на рассмотрении" - - msg.contains("ALREADY_VOLUNTEER") -> - "Вы уже зарегистрированы как волонтёр" - - msg.contains("403") -> - "Нет прав для выполнения действия" - - msg.contains("404") -> - "Объект не найден" - - msg.contains("409") -> - "Конфликт данных (заявка уже существует)" - - msg.contains("400") -> - "Ошибка в заполненных данных" - - else -> - "Ошибка сервера. Попробуйте позже" - } } \ No newline at end of file From 8737a976fdcac319e48795205cb3919caab061d2 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Tue, 2 Jun 2026 18:06:34 +0300 Subject: [PATCH 8/9] add: VolunteerManagementScreen and linked it with AdminModerator and Moderator --- .../modules/auth/navigation/AuthApp.kt | 26 ++++++++++ .../screens/ModeratorAdminProfileScreen.kt | 11 +++++ .../screens/ModeratorProfileScreen.kt | 11 +++++ .../screens/VolunteerManagementScreen.kt | 48 +++++++++++++++++++ 4 files changed, 96 insertions(+) create mode 100644 app/src/main/java/com/example/goodroad/modules/moderator/screens/VolunteerManagementScreen.kt diff --git a/app/src/main/java/com/example/goodroad/modules/auth/navigation/AuthApp.kt b/app/src/main/java/com/example/goodroad/modules/auth/navigation/AuthApp.kt index 2c11bdd..2317dcc 100644 --- a/app/src/main/java/com/example/goodroad/modules/auth/navigation/AuthApp.kt +++ b/app/src/main/java/com/example/goodroad/modules/auth/navigation/AuthApp.kt @@ -24,6 +24,7 @@ import com.example.goodroad.modules.moderationReview.presentation.ReviewModerati import com.example.goodroad.modules.moderator.screens.AdminProfileScreen import com.example.goodroad.modules.moderator.screens.ModeratorProfileScreen import com.example.goodroad.modules.moderator.screens.ModeratorsManagementScreen +import com.example.goodroad.modules.moderator.screens.VolunteerManagementScreen import com.example.goodroad.modules.moderationReview.screens.ReviewModerationScreen @Composable @@ -38,6 +39,7 @@ fun AuthApp( navController = navController, startDestination = LOGIN_ROUTE ) { + composable(LOGIN_ROUTE) { LoginScreen( onLoginSuccess = { resp -> @@ -95,6 +97,7 @@ fun AuthApp( } composable("admin_home") { + val userViewModel: UserViewModel = viewModel(factory = object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return UserViewModel(UserRepository(ApiClient.userApi)) as T @@ -105,6 +108,9 @@ fun AuthApp( userViewModel = userViewModel, onModerators = { navController.navigate("moderators") }, onReviews = { navController.navigate("review_moderation") }, + onVolunteers = { + navController.navigate("admin_volunteers") + }, onLogout = { navController.navigate(LOGIN_ROUTE) { popUpTo("admin_home") { inclusive = true } @@ -113,7 +119,17 @@ fun AuthApp( ) } + composable("admin_volunteers") { + + VolunteerManagementScreen( + onBack = { + navController.popBackStack() + } + ) + } + composable("moderator_home") { + val userViewModel: UserViewModel = viewModel(factory = object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return UserViewModel(UserRepository(ApiClient.userApi)) as T @@ -123,6 +139,7 @@ fun AuthApp( ModeratorProfileScreen( userViewModel = userViewModel, onReviews = { navController.navigate("review_moderation") }, + onVolunteers = { navController.navigate("volunteers") }, onLogout = { navController.navigate(LOGIN_ROUTE) { popUpTo("moderator_home") { inclusive = true } @@ -132,6 +149,7 @@ fun AuthApp( } composable("moderators") { + val moderatorViewModel: ModeratorViewModel = viewModel(factory = object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return ModeratorViewModel(ModeratorRepository()) as T @@ -144,8 +162,16 @@ fun AuthApp( ) } + composable("volunteers") { + VolunteerManagementScreen( + onBack = { navController.popBackStack() } + ) + } + composable("review_moderation") { + val moderationRepository = ModerationReviewRepository(ApiClient.moderationReviewApi) + val moderationViewModel: ReviewModerationViewModel = viewModel(factory = object : ViewModelProvider.Factory { override fun create(modelClass: Class): T { return ReviewModerationViewModel(moderationRepository) as T diff --git a/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorAdminProfileScreen.kt b/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorAdminProfileScreen.kt index dcf19bc..e25afa8 100644 --- a/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorAdminProfileScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorAdminProfileScreen.kt @@ -20,6 +20,7 @@ fun AdminProfileScreen( userViewModel: UserViewModel, onModerators: () -> Unit, onReviews: () -> Unit, + onVolunteers: () -> Unit, onLogout: () -> Unit ) { @@ -109,6 +110,16 @@ fun AdminProfileScreen( Spacer(Modifier.height(12.dp)) + PrimaryButton( + text = "Волонтёры", + backgroundColor = UrbanBrown, + contentColor = WhiteSoft + ) { + onVolunteers() + } + + Spacer(Modifier.height(12.dp)) + PrimaryButton( text = "Отзывы", backgroundColor = UrbanBrown, diff --git a/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorProfileScreen.kt b/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorProfileScreen.kt index 3cd4e7b..5eb27a9 100644 --- a/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorProfileScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/moderator/screens/ModeratorProfileScreen.kt @@ -18,6 +18,7 @@ import com.example.goodroad.ui.UserDecor fun ModeratorProfileScreen( userViewModel: UserViewModel, onReviews: () -> Unit, + onVolunteers: () -> Unit, onLogout: () -> Unit ) { @@ -94,6 +95,16 @@ fun ModeratorProfileScreen( Spacer(Modifier.height(30.dp)) + PrimaryButton( + text = "Волонтёры", + backgroundColor = UrbanBrown, + contentColor = WhiteSoft + ) { + onVolunteers() + } + + Spacer(Modifier.height(12.dp)) + PrimaryButton( text = "Отзывы", backgroundColor = UrbanBrown, diff --git a/app/src/main/java/com/example/goodroad/modules/moderator/screens/VolunteerManagementScreen.kt b/app/src/main/java/com/example/goodroad/modules/moderator/screens/VolunteerManagementScreen.kt new file mode 100644 index 0000000..7b0779d --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/moderator/screens/VolunteerManagementScreen.kt @@ -0,0 +1,48 @@ +package com.example.goodroad.modules.moderator.screens + +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.example.goodroad.ui.theme.BackgroundLight +import com.example.goodroad.ui.theme.UrbanBrown +import com.example.goodroad.ui.UserDecor +import com.example.goodroad.ui.buttons.PrimaryButton + +@Composable +fun VolunteerManagementScreen( + onBack: () -> Unit +) { + Surface( + modifier = Modifier.fillMaxSize(), + color = BackgroundLight + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(24.dp) + ) { + UserDecor() + + Spacer(Modifier.height(20.dp)) + + Text( + text = "Волонтёры", + style = MaterialTheme.typography.headlineLarge, + color = UrbanBrown + ) + + Spacer(Modifier.height(24.dp)) + + Text("Здесь будет список волонтёров") + + Spacer(Modifier.weight(1f)) + + PrimaryButton( + text = "Назад в профиль", + onClick = onBack + ) + } + } +} \ No newline at end of file From eb6602741f8bc7defbb3ba6532a63c9b952f2025 Mon Sep 17 00:00:00 2001 From: VictoriaGrudtsyna <148629595+VictoriaGrudtsyna@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:13:32 +0300 Subject: [PATCH 9/9] add: VolunteerAplicationCard on VolunteerModerationScreen --- .../goodroad/data/network/ApiClient.kt | 6 +- .../modules/auth/navigation/AuthApp.kt | 29 ++- .../moderator/data/VolunteerModerationApi.kt | 21 ++ .../data/VolunteerModerationModels.kt | 19 ++ .../data/VolunteerModerationRepository.kt | 29 +++ .../VolunteerModerationViewModel.kt | 56 +++++ .../screens/VolunteerManagementScreen.kt | 210 +++++++++++++++++- 7 files changed, 357 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationApi.kt create mode 100644 app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationModels.kt create mode 100644 app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationRepository.kt create mode 100644 app/src/main/java/com/example/goodroad/modules/moderator/presentation/VolunteerModerationViewModel.kt 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 index fef3973..022f399 100644 --- a/app/src/main/java/com/example/goodroad/data/network/ApiClient.kt +++ b/app/src/main/java/com/example/goodroad/data/network/ApiClient.kt @@ -11,11 +11,11 @@ import retrofit2.* import retrofit2.converter.gson.* import java.time.Instant import java.util.concurrent.* -import com.example.goodroad.data.network.GoodRoadApi import com.example.goodroad.modules.auth.data.AuthApi import com.example.goodroad.modules.review.data.ReviewApi import com.example.goodroad.modules.user.data.UserApi import com.example.goodroad.modules.volunteer.data.VolunteerApi +import com.example.goodroad.modules.moderator.data.VolunteerModerationApi object ApiClient { @@ -101,5 +101,9 @@ object ApiClient { val volunteerApi: VolunteerApi by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { retrofit().create(VolunteerApi::class.java) } + + val volunteerModerationApi: VolunteerModerationApi by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { + retrofit().create(VolunteerModerationApi::class.java) + } } diff --git a/app/src/main/java/com/example/goodroad/modules/auth/navigation/AuthApp.kt b/app/src/main/java/com/example/goodroad/modules/auth/navigation/AuthApp.kt index 2317dcc..75bd341 100644 --- a/app/src/main/java/com/example/goodroad/modules/auth/navigation/AuthApp.kt +++ b/app/src/main/java/com/example/goodroad/modules/auth/navigation/AuthApp.kt @@ -26,6 +26,8 @@ import com.example.goodroad.modules.moderator.screens.ModeratorProfileScreen import com.example.goodroad.modules.moderator.screens.ModeratorsManagementScreen import com.example.goodroad.modules.moderator.screens.VolunteerManagementScreen import com.example.goodroad.modules.moderationReview.screens.ReviewModerationScreen +import com.example.goodroad.modules.moderator.data.VolunteerModerationRepository +import com.example.goodroad.modules.moderator.presentation.VolunteerModerationViewModel @Composable fun AuthApp( @@ -121,11 +123,20 @@ fun AuthApp( composable("admin_volunteers") { - VolunteerManagementScreen( - onBack = { - navController.popBackStack() + val volunteerModerationViewModel: VolunteerModerationViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return VolunteerModerationViewModel( + VolunteerModerationRepository() + ) as T + } } ) + + VolunteerManagementScreen( + viewModel = volunteerModerationViewModel, + onBack = { navController.popBackStack() } + ) } composable("moderator_home") { @@ -163,7 +174,19 @@ fun AuthApp( } composable("volunteers") { + + val volunteerModerationViewModel: VolunteerModerationViewModel = viewModel( + factory = object : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + return VolunteerModerationViewModel( + VolunteerModerationRepository() + ) as T + } + } + ) + VolunteerManagementScreen( + viewModel = volunteerModerationViewModel, onBack = { navController.popBackStack() } ) } diff --git a/app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationApi.kt b/app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationApi.kt new file mode 100644 index 0000000..e2da200 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationApi.kt @@ -0,0 +1,21 @@ +package com.example.goodroad.modules.moderator.data + +import retrofit2.Response +import retrofit2.http.* + +interface VolunteerModerationApi { + + @GET("volunteer/moderation/applications/pending") + suspend fun getPendingApplications(): Response> + + @POST("volunteer/moderation/applications/{id}/approve") + suspend fun approve( + @Path("id") id: String + ): Response + + @POST("volunteer/moderation/applications/{id}/reject") + suspend fun reject( + @Path("id") id: String, + @Body req: RejectReq + ): Response +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationModels.kt b/app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationModels.kt new file mode 100644 index 0000000..0eb1cb1 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationModels.kt @@ -0,0 +1,19 @@ +package com.example.goodroad.modules.moderator.data + +data class VolunteerApplicationResp( + val id: String, + val applicantId: String, + val applicantName: String, + val dobroUrl: String?, + val phone: String, + val socialNickname: String?, + val certificatePhotoUrls: List = emptyList(), + val status: String, + val moderatorComment: String?, + val createdAt: String?, + val moderatedAt: String? +) + +data class RejectReq( + val reason: String +) \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationRepository.kt b/app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationRepository.kt new file mode 100644 index 0000000..842d997 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/moderator/data/VolunteerModerationRepository.kt @@ -0,0 +1,29 @@ +package com.example.goodroad.modules.moderator.data + +import com.example.goodroad.data.network.ApiClient +import retrofit2.HttpException + +class VolunteerModerationRepository { + + private val api = ApiClient.volunteerModerationApi + + suspend fun getPendingApplications(): List { + val response = api.getPendingApplications() + + if (response.isSuccessful) { + return response.body().orEmpty() + } + + throw HttpException(response) + } + + suspend fun approve(id: String) { + val response = api.approve(id) + if (!response.isSuccessful) throw HttpException(response) + } + + suspend fun reject(id: String, reason: String) { + val response = api.reject(id, RejectReq(reason)) + if (!response.isSuccessful) throw HttpException(response) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/moderator/presentation/VolunteerModerationViewModel.kt b/app/src/main/java/com/example/goodroad/modules/moderator/presentation/VolunteerModerationViewModel.kt new file mode 100644 index 0000000..699eb60 --- /dev/null +++ b/app/src/main/java/com/example/goodroad/modules/moderator/presentation/VolunteerModerationViewModel.kt @@ -0,0 +1,56 @@ +package com.example.goodroad.modules.moderator.presentation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.example.goodroad.modules.moderator.data.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +class VolunteerModerationViewModel( + private val repo: VolunteerModerationRepository +) : ViewModel() { + + private val _applications = MutableStateFlow>(emptyList()) + val applications: StateFlow> = _applications + + private val _loading = MutableStateFlow(false) + val loading: StateFlow = _loading + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error + + fun load() { + viewModelScope.launch { + _loading.value = true + try { + _applications.value = repo.getPendingApplications() + } catch (e: Exception) { + _error.value = e.message ?: "Ошибка загрузки" + } finally { + _loading.value = false + } + } + } + + fun approve(id: String) { + viewModelScope.launch { + try { + repo.approve(id) + load() + } catch (e: Exception) { + _error.value = e.message ?: "Ошибка approve" + } + } + } + + fun reject(id: String, reason: String) { + viewModelScope.launch { + try { + repo.reject(id, reason) + load() + } catch (e: Exception) { + _error.value = e.message ?: "Ошибка reject" + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/goodroad/modules/moderator/screens/VolunteerManagementScreen.kt b/app/src/main/java/com/example/goodroad/modules/moderator/screens/VolunteerManagementScreen.kt index 7b0779d..2bb69e0 100644 --- a/app/src/main/java/com/example/goodroad/modules/moderator/screens/VolunteerManagementScreen.kt +++ b/app/src/main/java/com/example/goodroad/modules/moderator/screens/VolunteerManagementScreen.kt @@ -1,19 +1,43 @@ package com.example.goodroad.modules.moderator.screens +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* -import androidx.compose.runtime.Composable +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp -import com.example.goodroad.ui.theme.BackgroundLight -import com.example.goodroad.ui.theme.UrbanBrown +import com.example.goodroad.modules.moderator.presentation.VolunteerModerationViewModel +import com.example.goodroad.modules.moderator.data.VolunteerApplicationResp import com.example.goodroad.ui.UserDecor import com.example.goodroad.ui.buttons.PrimaryButton +import com.example.goodroad.ui.theme.* @Composable fun VolunteerManagementScreen( + viewModel: VolunteerModerationViewModel, onBack: () -> Unit ) { + val apps by viewModel.applications.collectAsState() + val loading by viewModel.loading.collectAsState() + val error by viewModel.error.collectAsState() + + var rejectId by remember { mutableStateOf(null) } + var rejectReason by remember { mutableStateOf(TextFieldValue("")) } + + LaunchedEffect(Unit) { + viewModel.load() + } + Surface( modifier = Modifier.fillMaxSize(), color = BackgroundLight @@ -23,21 +47,79 @@ fun VolunteerManagementScreen( .fillMaxSize() .padding(24.dp) ) { + UserDecor() - Spacer(Modifier.height(20.dp)) + Spacer(Modifier.height(16.dp)) Text( - text = "Волонтёры", + text = "Заявки волонтёров", style = MaterialTheme.typography.headlineLarge, - color = UrbanBrown + color = TextPrimary ) - Spacer(Modifier.height(24.dp)) + Spacer(Modifier.height(16.dp)) + + when { + loading -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator(color = UrbanBrown) + } + } + + error != null -> { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = error ?: "Ошибка", + color = MaterialTheme.colorScheme.error + ) + } + } + + else -> { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { - Text("Здесь будет список волонтёров") + if (apps.isEmpty()) { + item { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Text("Нет заявок") + } + } + } else { + items(apps, key = { it.id }) { app -> + VolunteerApplicationCard( + app = app, + onApprove = { viewModel.approve(app.id) }, + onReject = { + rejectId = app.id + rejectReason = TextFieldValue("") + } + ) + } + } + } + } + } - Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(12.dp)) PrimaryButton( text = "Назад в профиль", @@ -45,4 +127,114 @@ fun VolunteerManagementScreen( ) } } + + if (rejectId != null) { + AlertDialog( + onDismissRequest = { rejectId = null }, + title = { Text("Причина отказа") }, + text = { + OutlinedTextField( + value = rejectReason, + onValueChange = { rejectReason = it }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text("Введите причину отказа") } + ) + }, + confirmButton = { + Button( + onClick = { + viewModel.reject(rejectId!!, rejectReason.text) + rejectId = null + } + ) { + Text("Отправить") + } + }, + dismissButton = { + TextButton(onClick = { rejectId = null }) { + Text("Отмена") + } + } + ) + } +} + +@Composable +private fun VolunteerApplicationCard( + app: VolunteerApplicationResp, + onApprove: () -> Unit, + onReject: () -> Unit +) { + val context = LocalContext.current + + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = SurfaceWarm), + border = BorderStroke(1.dp, BorderWarm) + ) { + Column( + modifier = Modifier.padding(20.dp) + ) { + + Text( + text = app.applicantName, + style = MaterialTheme.typography.titleLarge, + color = TextPrimary + ) + + Spacer(Modifier.height(12.dp)) + + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + + Text( + text = "Телефон: ${app.phone}", + style = MaterialTheme.typography.bodyLarge + ) + + Text( + text = "Социальный профиль: ${app.socialNickname ?: "не указан"}", + style = MaterialTheme.typography.bodyLarge + ) + + if (!app.dobroUrl.isNullOrBlank()) { + Text( + text = "Открыть профиль Dobro.ru", + style = MaterialTheme.typography.titleMedium, + color = InclusiveViolet, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.clickable { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(app.dobroUrl)) + context.startActivity(intent) + } + ) + } else { + Text( + text = "Dobro.ru: —", + style = MaterialTheme.typography.bodyMedium + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + Button( + onClick = onApprove, + colors = ButtonDefaults.buttonColors(containerColor = SafeGreen) + ) { + Text("Одобрить") + } + + Button( + onClick = onReject, + colors = ButtonDefaults.buttonColors(containerColor = AlertRed) + ) { + Text("Отклонить") + } + } + } + } } \ No newline at end of file