day2_commit_1_xml_з123123213

This commit is contained in:
Terebov_Maksim 2025-02-19 18:59:50 +03:00
parent 3da4b99cc0
commit 8535cf6370
15 changed files with 126 additions and 243 deletions

122
README.md
View File

@ -1,121 +1 @@
[![Android Studio version](https://img.shields.io/endpoint?url=https%3A%2F%2Fsicampus.ru%2Fgitea%2Fcore%2Fdocs%2Fraw%2Fbranch%2Fmain%2Fandroid-studio-label.json)](https://sicampus.ru/gitea/core/docs/src/branch/main/how-upload-project.md)
# НТО 2024. II отборочный этап. Командные задани — клиентская часть
## 📖 Предыстория
В компании S контроль доступа в офис осуществляется с помощью СКУД (системы контроля управления доступом). На данный момент у каждого сотрудника компании есть карта-пропуск с NFC меткой. А у каждой входной двери есть считыватель с обеих сторон. При поднесении карты к считывателю, дверь открывается, а информация о времени входа или выхода сотрудника фиксируется в базе данных.
Администрации компании S требуется мобильное приложение, как для рядовых сотрудников, так и для администрации с возможностью просмотра посещений и работой электронного пропуска как временной замены обычного (при помощи сканировании QR кода, который находится на считывателе). Поскольку в приложении есть возможность использовать телефон как пропуск - то к данному приложению повышенные требования к безопасности всех данных, находящихся внутри него.
## 📋 Системные требования
| **Параметр** | **Требование** |
|-----------------------------|---------------------------------------|
| **Минимальная версия Android** | 9.0 (API 28) |
| **Целевая версия Android** | 14 (API 34) |
| **Поддерживаемые устройства** | смартфоны, планшеты |
| **Ориентация экранов** | портретная |
| **Языки** | русский, английский |
| **Разрешения** | доступ к интернету, камера (при необходимости) |
## 📱 Техническое задание
Требуется разработать нативное мобильное приложение, которое будет содержать следующие экраны.
### 1. Экран авторизации
> Данный экран должен быть показан при первом входе в приложение, а также в ситуациях, когда пользователь не зарегистрировался в приложении.
#### Элементы, которые должны присутствовать на экране:
- Поле ввода (`id/username`), в котором пользователю необходимо ввести свой логин.
- Кнопка входа (`id/login`), по нажатию на которую пользователь авторизуется в системе.
- По умолчанию скрытое (`GONE`) текстовое поле с ошибкой (`id/error`).
#### Требования к компонентам:
1. В пустом поле ввода должна отображаться подсказка, что требуется ввести пользователю.
2. Если хотя бы одно из условий ниже соблюдено - кнопка должна быть неактивной:
- Поле ввода пустое.
- Количество символов менее 3х.
- Логин начинается с цифры.
- Логин содержит символы, отличные от латинского алфавита и цифр.
3. Поле ввода и кнопку должно быть видно при раскрытии клавиатуры.
4. - При нажатии на кнопку входа необходимо проверить, что данный пользователь существует с помощью запроса `api/<LOGIN>/auth` (подробное описание представлено в техническом задании серверной части).
5. В случае отсутствия логина или любой другой неполадки - необходимо вывести ошибку, пока пользователь не изменит текстовое поле или повторно не нажмёт на кнопку.
6. После нажатия на кнопку - логин должен быть сохранён и при следующем открытии приложения экран авторизации не должен быть показан.
7. После нажатия на кнопку - при нажатии стрелки назад - экран авторизации не должен быть показан повторно.
8. Экран авторизации показывается только в случае, если пользователь неавторизован.
### 2. Главный экран
> Данный экран содержит общую информацию о пользователе:
>- ФИО
>- Фото
>- Должность
>- Время последнего входа
#### Элементы, которые должны присутствовать на экране:
- Текстовое поле (`id/fullname`), в котором написано имя пользователя.
- Изображение (`id/photo`), на котором отображено фото пользователя.
- Текстовое поле (`id/position`), в котором написана должность пользователя.
- Текстовое поле (`id/lastEntry`), в котором написана дата и время последнего входа пользователя.
- Кнопка (`id/logout`) для выхода пользователя из аккаунта.
- Кнопка (`id/refresh`) для обновления данных.
- Кнопка (`id/scan`) для сканирования QR кода.
- По умолчанию скрытое текстовое поле с ошибкой (`id/error`).
#### Требования к компонентам:
- В случае любой ошибки необходимо скрыть все элементы, кроме текстового поля с ошибкой и кнопки обновления данных.
- Для получения данных необходимо использовать сетевой запрос `/api/<LOGIN>/info`.
- Формат даты и времени последнего входа пользователя: `yyyy-MM-dd HH:mm` (например: 2024-02-31 08:31). Время необходимо отображать с сервера, без поправок на часовой пояс или локальное смещение.
- При нажатии на кнопку выход все данные (если есть) пользователя должны быть очищены, а приложение должно открыть экран авторизации.
- При нажатии кнопки сканирования необходимо открыть экран сканирования QR кода.
- При нажатии на кнопку обновления данных - необходимо повторно вызывать сетевой запрос для получения актуальных данных.
### 3. Экран сканирования QR-кода
> Данный экран позволяет отсканировать код на турникете и войти с помощью смартфона. В данном случае данный экран будет уже написан и представлен dам в готовом виде в заготовке. Вам необходимо только подписаться на его результат с помощью **Result API** и обработать считанные данные из QR кода. **Данный экран нельзя модифицировать. Он поставляется как есть.**
### 4. Экран с результатом сканирования QR кода
> На данном экране необходимо вывести успешность или неуспешность входа с помощью метода QR кода.
#### Элементы, которые должны присутствовать на экране:
- Текстовое поле (`id/result`), где содержится текст об успешности или неуспешности входа.
- Кнопка (`id/close`) для закрытия данного экрана.
#### Требования к компонентам:
- В случае, если результат пришёл пустым или со статусом “Отмена” - необходимо вывести пользователю текст:
*"Вход был отменён/Operation was cancelled"*
- В случае, если данные пришли, то необходимо их отправить на сервер с запросом `api/<LOGIN>/open`, добавив данные из результата и получить ответ.
- Если сервер ответил успешно - то отображаем текст:
*"Успешно/Success"*
- Если сервер ответил любой ошибкой - то отображаем текст:
*"Что-то пошло не так/Something wrong"*
- Кнопка закрытия всегда открывает главный экран.
## 🛠 Решение
Необходимо загрузить свое решение в систему [по ссылке](https://innovationcampus.ru/lms/mod/quiz/view.php?id=2149).
Отметим, что работу необходимо осуществлять в представленных проектах-заготовках (шаблонах).
## ✅ Особенности оценивания
Оценивание происходит с помощью автоматической системы тестирования, которая в автоматическом режиме находит элементы и взаимодействует с ними (именно для этого у каждого элемента указан уникальный идентификатор, по которому будет производится поиск). Каждый тест происходит с чистой установки приложения.
В случае тестирования сервера на него поочередно отправляются команды, описанные в API и ожидаются определенные корректные ответы.
Сервер и приложение тестируются независимо.
Figma: https://www.figma.com/design/v9YlfUjxz6ChHS5mNWSQPN/TheDevs_Final?node-id=2-3&t=Hnk1mGCVo7joisAC-1

View File

@ -1,9 +1,9 @@
plugins {
kotlinAndroid
androidApplication
jetbrainsKotlinSerialization version Version.Kotlin.language
kotlinAnnotationProcessor
id("com.google.dagger.hilt.android").version("2.51.1")
id("com.android.application")
id("kotlin-android")
id("kotlin-kapt") // Добавлено для KAPT
id("dagger.hilt.android.plugin") // Используйте этот синтаксис для Hilt
id("org.jetbrains.kotlin.plugin.serialization") version Version.Kotlin.language // Убедитесь, что версия актуальна
}
val packageName = "ru.myitschool.work"
@ -22,9 +22,9 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildFeatures.viewBinding = true
buildFeatures {
viewBinding = true
}
compileOptions {
sourceCompatibility = Version.Kotlin.javaSource
@ -37,34 +37,41 @@ android {
}
dependencies {
implementation ("com.squareup.retrofit2:retrofit:2.9.0")
implementation ("com.squareup.retrofit2:converter-gson:2.9.0")
implementation ("com.squareup.okhttp3:okhttp:4.9.0")
implementation ("com.github.bumptech.glide:glide:4.15.1")
kapt ("com.github.bumptech.glide:compiler:4.15.1")
// Retrofit and OkHttp
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0")
// Glide
implementation("com.github.bumptech.glide:glide:4.15.1")
kapt("com.github.bumptech.glide:compiler:4.15.1")
// AndroidX Libraries
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.10.0")
implementation("androidx.activity:activity:1.10.0")
implementation("androidx.constraintlayout:constraintlayout:2.1.4")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.5.1")
defaultLibrary()
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.0")
implementation(Dependencies.AndroidX.activity)
implementation(Dependencies.AndroidX.fragment)
implementation(Dependencies.AndroidX.constraintLayout)
// Hilt dependencies
implementation("com.google.dagger:hilt-android:2.51.1")
kapt("com.google.dagger:hilt-android-compiler:2.51.1")
// Navigation
implementation(Dependencies.AndroidX.Navigation.fragment)
implementation(Dependencies.AndroidX.Navigation.navigationUi)
implementation(Dependencies.Retrofit.library)
implementation(Dependencies.Retrofit.gsonConverter)
implementation("com.squareup.picasso:picasso:2.8")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
// DataStore
implementation("androidx.datastore:datastore-preferences:1.1.1")
// ML Kit
implementation("com.google.mlkit:barcode-scanning:17.3.0")
// CameraX
val cameraX = "1.3.4"
implementation("androidx.camera:camera-core:$cameraX")
implementation("androidx.camera:camera-camera2:$cameraX")
@ -72,11 +79,13 @@ dependencies {
implementation("androidx.camera:camera-view:$cameraX")
implementation("androidx.camera:camera-mlkit-vision:1.4.0-rc04")
val hilt = "2.51.1"
implementation("com.google.dagger:hilt-android:$hilt")
kapt("com.google.dagger:hilt-android-compiler:$hilt")
// Picasso
implementation("com.squareup.picasso:picasso:2.8")
// Kotlin Serialization
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.1")
}
kapt {
correctErrorTypes = true
}
}

View File

@ -4,10 +4,9 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import ru.myitschool.work.api.ApiService
import ru.myitschool.work.core.Constants
import javax.inject.Singleton
@Module
@ -16,9 +15,26 @@ object ApiModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit {
fun provideOkHttpClient(authInterceptor: AuthInterceptor): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(authInterceptor) // Добавляем интерсептор
.build()
}
@Provides
@Singleton
fun provideAuthInterceptor(): AuthInterceptor {
val username = "pivanov" // Замените на ваш логин
val password = "password123" // Замените на ваш пароль
return AuthInterceptor(username, password)
}
@Provides
@Singleton
fun provideRetrofit(client: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl(Constants.SERVER_ADDRESS)
.baseUrl("http://10.6.66.110:8080/") // Убедитесь, что URL корректен
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
}

View File

@ -1,19 +1,23 @@
package ru.myitschool.work.api
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface ApiService {
// Метод для аутентификации
@POST("/api/auth") // Изменено на POST и путь к аутентификации
@GET("/api/auth")
suspend fun authenticate(
@Body payload: AuthRequest // Передаем объект с логином и паролем
): Response<UserAuthResponse> // Возвращаем UserAuthResponse
@Query("login") login: String,
@Query("password") password: String
): Response<String> // Измените ResponseBody на String // Возвращаем ResponseBody вместо String
// Другие методы...
@GET("/api/{login}/info") // Получение информации о пользователе
suspend fun getUserInfo(@Path("login") login: String): Response<Map<String, Any>>
@ -30,17 +34,6 @@ interface ApiService {
suspend fun getAllWorkers(): Response<List<EmployeeData>>
}
// Модель данных для запроса аутентификации
data class AuthRequest(
val login: String,
val password: String // Поле для пароля
)
// Модель данных для ответа аутентификации
data class UserAuthResponse(
val role: String // Добавляем поле для роли
)
// Модель данных для информации о сотруднике
data class EmployeeData(
val name: String,

View File

@ -1,5 +1,5 @@
package ru.myitschool.work.core
// БЕРИТЕ И ИЗМЕНЯЙТЕ ХОСТ ТОЛЬКО ЗДЕСЬ И НЕ БЕРИТЕ ИЗ ДРУГИХ МЕСТ. ФАЙЛ ПЕРЕМЕЩАТЬ НЕЛЬЗЯ
object Constants {
const val SERVER_ADDRESS = "const val SERVER_ADDRESS = \"http://localhost:8080\"\n"
const val SERVER_ADDRESS = "http://10.6.66.110:8080"
}

View File

@ -1,4 +1,4 @@
package ru.myitschool.work.ui.admin
package ru.myitschool.work.ui.Main
import android.os.Bundle
import android.view.View

View File

@ -1,5 +1,6 @@
package ru.myitschool.work.ui.login
import ru.myitschool.work.api.ApiService
import android.os.Bundle
import android.text.Editable
import android.text.InputType
@ -15,7 +16,6 @@ import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint
import ru.myitschool.work.R
import ru.myitschool.work.SessionManager
import ru.myitschool.work.api.UserAuthResponse
import ru.myitschool.work.databinding.FragmentLoginBinding
import ru.myitschool.work.utils.collectWhenStarted
import ru.myitschool.work.utils.visibleOrGone
@ -40,7 +40,7 @@ class LoginFragment : Fragment(R.layout.fragment_login) {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
super.onViewCreated(view, savedInstanceState) // Убедитесь, что этот вызов находится здесь
_binding = FragmentLoginBinding.bind(view)
setupUI()
@ -85,32 +85,23 @@ class LoginFragment : Fragment(R.layout.fragment_login) {
}
private fun subscribe() {
viewModel.state.collectWhenStarted(this) { state ->
binding.loading.visibleOrGone(false)
lifecycleScope.launch {
viewModel.state.collect { state ->
binding.loading.visibleOrGone(false)
try {
if (state.maintenance) {
showMaintenanceDialog() // Показываем диалог о техработах
} else if (state.error != null) {
binding.error.text = state.error
binding.error.visibility = View.VISIBLE
} else if (state.success) {
binding.error.visibility = View.GONE
authPreferences.saveLoginState(true)
authPreferences.saveLogin(binding.username.text.toString()) // Сохраняем логин
// Сохраняем роль пользователя в SessionManager
val userAuthResponse: UserAuthResponse? = state.userAuthResponse
userAuthResponse?.let {
SessionManager.userRole = it.role // Сохраняем роль
}
Toast.makeText(context, "Авторизация прошла успешно", Toast.LENGTH_SHORT).show()
navigateToMainScreen()
navigateToMainScreen() // Перенаправление на следующий экран
} else if (state.error != null) {
binding.error.text = state.error
binding.error.visibility = View.VISIBLE
}
} catch (e: Exception) {
Log.e("LoginFragment", "Ошибка при обработке состояния", e)
Toast.makeText(context, "Произошла ошибка. Пожалуйста, попробуйте снова.", Toast.LENGTH_SHORT).show()
}
}
}
@ -119,7 +110,7 @@ class LoginFragment : Fragment(R.layout.fragment_login) {
AlertDialog.Builder(requireContext())
.setTitle("Технические работы")
.setMessage("Проводятся техработы, пожалуйста, подождите.")
.setPositiveButton("ОК") { dialog, _ -> dialog.dismiss() }
.setPositiveButton("ОК ") { dialog, _ -> dialog.dismiss() }
.setCancelable(false)
.show()
}

View File

@ -1,53 +1,41 @@
package ru.myitschool.work.ui.login
import android.content.Context
import ru.myitschool.work.api.ApiService
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import okhttp3.Credentials
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import ru.myitschool.work.SessionManager
import ru.myitschool.work.api.ApiService
import ru.myitschool.work.api.AuthRequest
import ru.myitschool.work.api.UserAuthResponse
import ru.myitschool.work.core.Constants
import javax.inject.Inject
@HiltViewModel
class LoginViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val apiService: ApiService
) : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state = _state.asStateFlow()
private val apiService: ApiService by lazy {
Retrofit.Builder()
.baseUrl(Constants.SERVER_ADDRESS)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> get() = _state
fun authenticate(username: String, password: String) {
if (isValidUsername(username)) {
viewModelScope.launch {
try {
val payload = AuthRequest(username, password) // Создаем объект запроса
val response = apiService.authenticate(payload) // Вызываем метод аутентификации
if (response.isSuccessful) {
val userAuthResponse = response.body() // Получаем ответ с ролью пользователя
userAuthResponse?.let {
SessionManager.userLogin = username // Сохраняем логин
SessionManager.userRole = it.role // Сохраняем роль
val response = apiService.authenticate(username, password)
Log.d("LoginViewModel", "Response code: ${response.code()}")
// Проверяем код ответа
when (response.code()) {
200 -> {
_state.value = LoginState(success = true) // Успешная авторизация
}
400 -> {
_state.value = LoginState(error = "Ошибка авторизации: Неверные учетные данные.")
}
else -> {
_state.value = LoginState(error = "Ошибка авторизации: ${response.message()}")
}
_state.value = LoginState(success = true, userAuthResponse = userAuthResponse)
} else {
_state.value = LoginState(error = "Ошибка авторизации")
}
} catch (e: Exception) {
e.printStackTrace()
@ -60,13 +48,13 @@ class LoginViewModel @Inject constructor(
}
private fun isValidUsername(username: String): Boolean {
return username.length >= 3 && !username.first().isDigit() && username.all { it.isLetterOrDigit() }
return username.isNotEmpty() // Пример проверки логина
}
}
data class LoginState(
val success: Boolean = false,
val error: String? = null,
val maintenance: Boolean = false,
val userAuthResponse: UserAuthResponse? = null // Добавляем поле для хранения информации о роли
)
}
// Состояние аутентификации
data class LoginState(
val success: Boolean = false, // Успешность аутентификации
val error: String? = null, // Сообщение об ошибке
val maintenance: Boolean = false // Состояние техобслуживания
)

View File

@ -11,10 +11,10 @@
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Введите логин"
android:hint="@string/username_hint"
android:inputType="text"
android:padding="12dp"
android:background="#F0F0F0"
android:background="@drawable/ic_android_black_24dp"
android:layout_marginBottom="16dp"/>
<EditText
@ -24,17 +24,18 @@
android:hint="Введите пароль"
android:inputType="textPassword"
android:padding="12dp"
android:background="#F0F0F0"
android:background="@drawable/ic_android_black_24dp"
android:layout_marginBottom="16dp"/>
<Button
android:id="@+id/login"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Войти"
android:text="@string/login_button"
android:backgroundTint="@color/colorPrimary"
android:textColor="@android:color/white"
android:padding="12dp"
app:cornerRadius="16dp"
android:layout_marginBottom="16dp"/>
<TextView

View File

@ -12,7 +12,7 @@
android:id="@+id/fullname"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Имя Фамилия"
android:text="@string/fullname_label"
android:textSize="18sp"
android:layout_marginBottom="5dp"
android:visibility="gone" />
@ -32,7 +32,7 @@
android:id="@+id/position"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Должность"
android:text="@string/position_label"
android:layout_marginBottom="5dp"
android:visibility="gone" />
@ -71,7 +71,7 @@
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:visibility="visible" />
android:visibility="gone" />
<!-- Кнопки -->
<Button
@ -98,7 +98,8 @@
android:id="@+id/admin_panel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Admin Panel"
android:text="@string/admin_panel"
android:backgroundTint="@color/colorPrimary"
android:layout_marginTop="16dp"
android:visibility="gone" />

View File

@ -30,6 +30,7 @@
android:contentDescription="@string/close_button"
android:src="@drawable/ic_close"
app:elevation="0dp"
android:backgroundTint="@color/colorPrimary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -11,7 +11,7 @@
android:id="@+id/result"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Результат"
android:text="@string/result"
android:textSize="18sp"
android:gravity="center"
android:padding="16dp" />
@ -20,7 +20,9 @@
android:id="@+id/close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Закрыть"
android:text="@string/close_button"
android:backgroundTint="@color/colorPrimary"
android:textColor="@android:color/white"
android:padding="12dp"
android:layout_marginTop="24dp" />

View File

@ -32,4 +32,6 @@
<string name="qr_scan_success">Successfully</string>
<string name="qr_scan_failure">Something went wrong.</string>
<string name="qr_scan_cancelled">Operation was cancelled</string>
<string name="close_button">Close</string>
<string name="result">Result</string>
</resources>

View File

@ -32,8 +32,7 @@
<string name="qr_scan_success">Успешно</string>
<string name="qr_scan_failure">Что-то пошло не так</string>
<string name="qr_scan_cancelled">Вход был отменён / Operation was cancelled</string>
<!-- TODO: Remove or change this placeholder text -->
<string name="hello_blank_fragment">Hello blank fragment</string>
<string name="close_button">Закрыть</string>
<string name="result">Результат</string>
<string name="admin_panel" translatable="false">Admin Panel</string>
</resources>

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="close_button" translatable="false">Close</string>
</resources>