From 6b2538c7f8ff92134c9a28aabdd55b0dd41ce544 Mon Sep 17 00:00:00 2001 From: geniy Date: Tue, 18 Feb 2025 18:26:08 +0300 Subject: [PATCH] first commit --- .gitignore | 10 + .gitmodules | 6 + README.md | 121 +++++++++ app/.gitignore | 1 + app/build.gradle.kts | 72 ++++++ app/proguard-rules.pro | 21 ++ app/src/main/AndroidManifest.xml | 33 +++ app/src/main/java/ru/myitschool/work/App.kt | 7 + .../java/ru/myitschool/work/core/Constants.kt | 5 + .../ru/myitschool/work/data/dto/Code.java | 20 ++ .../ru/myitschool/work/data/dto/Employee.java | 34 +++ .../work/data/network/RetrofitFactory.java | 31 +++ .../work/data/network/api/EmployeeApi.java | 20 ++ .../data/repository/InfoRepositoryImpl.java | 43 ++++ .../data/repository/LoginRepositoryImpl.java | 42 ++++ .../data/repository/OpenRepositoryImpl.java | 43 ++++ .../work/domain/entities/Status.java | 32 +++ .../work/domain/info/InfoRepository.java | 12 + .../work/domain/info/InfoUseCase.java | 20 ++ .../work/domain/login/AuthUseCase.java | 25 ++ .../work/domain/login/LoginRepository.java | 11 + .../work/domain/open/OpenRepository.java | 12 + .../work/domain/open/OpenUseCase.java | 20 ++ .../ru/myitschool/work/ui/RootActivity.kt | 15 ++ .../work/ui/employee/EmployeeFragment.java | 139 +++++++++++ .../work/ui/employee/EmployeeViewModel.java | 30 +++ .../work/ui/login/LoginFragment.java | 108 ++++++++ .../work/ui/login/LoginViewModel.java | 47 ++++ .../work/ui/qr/result/ResultFragment.java | 82 ++++++ .../work/ui/qr/result/ResultViewModel.java | 27 ++ .../work/ui/qr/scan/QrScanDestination.kt | 30 +++ .../work/ui/qr/scan/QrScanFragment.kt | 139 +++++++++++ .../work/ui/qr/scan/QrScanViewModel.kt | 93 +++++++ .../myitschool/work/utils/FlowExtensions.kt | 10 + .../work/utils/FragmentExtesions.kt | 18 ++ .../work/utils/TextChangedListener.kt | 12 + .../myitschool/work/utils/ViewExtensions.kt | 7 + app/src/main/res/drawable/ic_close.xml | 5 + .../res/drawable/ic_launcher_background.xml | 170 +++++++++++++ .../res/drawable/ic_launcher_foreground.xml | 30 +++ app/src/main/res/drawable/ic_logout.xml | 5 + app/src/main/res/drawable/ic_no_img.xml | 5 + app/src/main/res/drawable/ic_qr_code.xml | 25 ++ app/src/main/res/drawable/ic_refresh.xml | 5 + app/src/main/res/layout/activity_root.xml | 8 + app/src/main/res/layout/fragment_employee.xml | 58 +++++ app/src/main/res/layout/fragment_login.xml | 26 ++ app/src/main/res/layout/fragment_qr_scan.xml | 35 +++ app/src/main/res/layout/fragment_result.xml | 20 ++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes app/src/main/res/navigation/nav_graph.xml | 47 ++++ app/src/main/res/values-ru/strings.xml | 7 + app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/ids.xml | 4 + app/src/main/res/values/strings.xml | 6 + app/src/main/res/values/strings_qr.xml | 4 + app/src/main/res/values/themes.xml | 16 ++ app/src/main/res/xml/backup_rules.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 19 ++ .../main/res/xml/network_security_config.xml | 4 + build.gradle.kts | 7 + buildSrc/.gitignore | 2 + buildSrc/build.gradle.kts | 8 + buildSrc/src/main/java/Dependencies.kt | 220 ++++++++++++++++ .../main/java/DependencyHandlerExtensions.kt | 40 +++ buildSrc/src/main/java/Plugin.kt | 67 +++++ buildSrc/src/main/java/Version.kt | 42 ++++ gradle.properties | 21 ++ gradle/README.md | 11 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59203 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 234 ++++++++++++++++++ gradlew.bat | 89 +++++++ settings.gradle.kts | 17 ++ 85 files changed, 2696 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 README.md create mode 100644 app/.gitignore create mode 100644 app/build.gradle.kts create mode 100644 app/proguard-rules.pro create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/ru/myitschool/work/App.kt create mode 100644 app/src/main/java/ru/myitschool/work/core/Constants.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/dto/Code.java create mode 100644 app/src/main/java/ru/myitschool/work/data/dto/Employee.java create mode 100644 app/src/main/java/ru/myitschool/work/data/network/RetrofitFactory.java create mode 100644 app/src/main/java/ru/myitschool/work/data/network/api/EmployeeApi.java create mode 100644 app/src/main/java/ru/myitschool/work/data/repository/InfoRepositoryImpl.java create mode 100644 app/src/main/java/ru/myitschool/work/data/repository/LoginRepositoryImpl.java create mode 100644 app/src/main/java/ru/myitschool/work/data/repository/OpenRepositoryImpl.java create mode 100644 app/src/main/java/ru/myitschool/work/domain/entities/Status.java create mode 100644 app/src/main/java/ru/myitschool/work/domain/info/InfoRepository.java create mode 100644 app/src/main/java/ru/myitschool/work/domain/info/InfoUseCase.java create mode 100644 app/src/main/java/ru/myitschool/work/domain/login/AuthUseCase.java create mode 100644 app/src/main/java/ru/myitschool/work/domain/login/LoginRepository.java create mode 100644 app/src/main/java/ru/myitschool/work/domain/open/OpenRepository.java create mode 100644 app/src/main/java/ru/myitschool/work/domain/open/OpenUseCase.java create mode 100644 app/src/main/java/ru/myitschool/work/ui/RootActivity.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/employee/EmployeeFragment.java create mode 100644 app/src/main/java/ru/myitschool/work/ui/employee/EmployeeViewModel.java create mode 100644 app/src/main/java/ru/myitschool/work/ui/login/LoginFragment.java create mode 100644 app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.java create mode 100644 app/src/main/java/ru/myitschool/work/ui/qr/result/ResultFragment.java create mode 100644 app/src/main/java/ru/myitschool/work/ui/qr/result/ResultViewModel.java create mode 100644 app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanDestination.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanFragment.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanViewModel.kt create mode 100644 app/src/main/java/ru/myitschool/work/utils/FlowExtensions.kt create mode 100644 app/src/main/java/ru/myitschool/work/utils/FragmentExtesions.kt create mode 100644 app/src/main/java/ru/myitschool/work/utils/TextChangedListener.kt create mode 100644 app/src/main/java/ru/myitschool/work/utils/ViewExtensions.kt create mode 100644 app/src/main/res/drawable/ic_close.xml create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_logout.xml create mode 100644 app/src/main/res/drawable/ic_no_img.xml create mode 100644 app/src/main/res/drawable/ic_qr_code.xml create mode 100644 app/src/main/res/drawable/ic_refresh.xml create mode 100644 app/src/main/res/layout/activity_root.xml create mode 100644 app/src/main/res/layout/fragment_employee.xml create mode 100644 app/src/main/res/layout/fragment_login.xml create mode 100644 app/src/main/res/layout/fragment_qr_scan.xml create mode 100644 app/src/main/res/layout/fragment_result.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 app/src/main/res/navigation/nav_graph.xml create mode 100644 app/src/main/res/values-ru/strings.xml create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/ids.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/strings_qr.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/main/res/xml/data_extraction_rules.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build.gradle.kts create mode 100644 buildSrc/.gitignore create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/java/Dependencies.kt create mode 100644 buildSrc/src/main/java/DependencyHandlerExtensions.kt create mode 100644 buildSrc/src/main/java/Plugin.kt create mode 100644 buildSrc/src/main/java/Version.kt create mode 100644 gradle.properties create mode 100644 gradle/README.md create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10cfdbf --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b3f5536 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "gradle"] + path = gradle + url = https://git.sicampus.ru/core/gradle.git +[submodule "buildSrc"] + path = buildSrc + url = https://git.sicampus.ru/core/dependecies.git diff --git a/README.md b/README.md new file mode 100644 index 0000000..7bbbba0 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +[![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`), по нажатию на которую пользователь авторизуется в системе. +- По умолчанию скрытое текстовое поле с ошибкой (`id/error`). + +#### Требования к компонентам: +1. В пустом поле ввода должна отображаться подсказка, что требуется ввести пользователю. +2. Если хотя бы одно из условий ниже соблюдено - кнопка должна быть неактивной: + - Поле ввода пустое. + - Количество символов менее 3х. + - Логин начинается с цифры. + - Логин содержит символы, отличные от латинского алфавита и цифр. +3. Поле ввода и кнопку должно быть видно при раскрытии клавиатуры. +4. - При нажатии на кнопку входа необходимо проверить, что данный пользователь существует с помощью запроса `api//auth` (подробное описание представлено в техническом задании серверной части). +5. В случае отсутствия логина или любой другой неполадки - необходимо вывести ошибку, пока пользователь не изменит текстовое поле или повторно не нажмёт на кнопку. +6. После нажатия на кнопку - логин должен быть сохранён и при следующем открытии приложения экран авторизации не должен быть показан. +7. После нажатия на кнопку - при нажатии стрелки назад - экран авторизации не должен быть показан повторно. +8. Экран авторизации показывается только в случае, если пользователь неавторизован. + + + + +### 2. Главный экран + +> Данный экран содержит общую информацию о пользователе: +>- ФИО +>- Фото +>- Должность +>- Время последнего входа + +#### Элементы, которые должны присутствовать на экране: +- Текстовое поле (`id/fullname`), в котором написано имя пользователя. +- Изображение (`id/photo`), на котором отображено фото пользователя. +- Текстовое поле (`id/position`), в котором написана должность пользователя. +- Текстовое поле (`id/lastEntry`), в котором написана дата и время последнего входа пользователя. +- Кнопка (`id/logout`) для выхода пользователя из аккаунта. +- Кнопка (`id/refresh`) для обновления данных. +- Кнопка (`id/scan`) для сканирования QR кода. +- По умолчанию скрытое текстовое поле с ошибкой (`id/error`). + +#### Требования к компонентам: +- В случае любой ошибки необходимо скрыть все элементы, кроме текстового поля с ошибкой и кнопки обновления данных. +- Для получения данных необходимо использовать сетевой запрос `/api//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//open`, добавив данные из результата и получить ответ. +- Если сервер ответил успешно - то отображаем текст: + *"Успешно/Success"* +- Если сервер ответил любой ошибкой - то отображаем текст: + *"Что-то пошло не так/Something wrong"* +- Кнопка закрытия всегда открывает главный экран. + + + +## 🛠 Решение + +Необходимо загрузить свое решение в систему [по ссылке](https://innovationcampus.ru/lms/mod/quiz/view.php?id=2149). + +Отметим, что работу необходимо осуществлять в представленных проектах-заготовках (шаблонах). + + + +## ✅ Особенности оценивания + +Оценивание происходит с помощью автоматической системы тестирования, которая в автоматическом режиме находит элементы и взаимодействует с ними (именно для этого у каждого элемента указан уникальный идентификатор, по которому будет производится поиск). Каждый тест происходит с чистой установки приложения. +В случае тестирования сервера на него поочередно отправляются команды, описанные в API и ожидаются определенные корректные ответы. +Сервер и приложение тестируются независимо. + diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..ab7c357 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,72 @@ +plugins { + kotlinAndroid + androidApplication + jetbrainsKotlinSerialization version Version.Kotlin.language + kotlinAnnotationProcessor + id("com.google.dagger.hilt.android").version("2.51.1") +} + +val packageName = "ru.myitschool.work" + +android { + namespace = packageName + compileSdk = Version.Android.Sdk.compile + + defaultConfig { + applicationId = packageName + minSdk = Version.Android.Sdk.min + targetSdk = Version.Android.Sdk.target + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildFeatures.viewBinding = true + + compileOptions { + sourceCompatibility = Version.Kotlin.javaSource + targetCompatibility = Version.Kotlin.javaSource + } + + kotlinOptions { + jvmTarget = Version.Kotlin.jvmTarget + } +} + +dependencies { + implementation("androidx.legacy:legacy-support-v4:1.0.0") + implementation("androidx.lifecycle:lifecycle-livedata-ktx:2.6.1") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1") + defaultLibrary() + + implementation(Dependencies.AndroidX.activity) + implementation(Dependencies.AndroidX.fragment) + implementation(Dependencies.AndroidX.constraintLayout) + + 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") + implementation("androidx.datastore:datastore-preferences:1.1.1") + implementation("com.google.mlkit:barcode-scanning:17.3.0") + + val cameraX = "1.3.4" + implementation("androidx.camera:camera-core:$cameraX") + implementation("androidx.camera:camera-camera2:$cameraX") + implementation("androidx.camera:camera-lifecycle:$cameraX") + 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") +} + +kapt { + correctErrorTypes = true +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..a986978 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/App.kt b/app/src/main/java/ru/myitschool/work/App.kt new file mode 100644 index 0000000..3085135 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/App.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class App : Application() \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/core/Constants.kt b/app/src/main/java/ru/myitschool/work/core/Constants.kt new file mode 100644 index 0000000..5831896 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/core/Constants.kt @@ -0,0 +1,5 @@ +package ru.myitschool.work.core +// БЕРИТЕ И ИЗМЕНЯЙТЕ ХОСТ ТОЛЬКО ЗДЕСЬ И НЕ БЕРИТЕ ИЗ ДРУГИХ МЕСТ. ФАЙЛ ПЕРЕМЕЩАТЬ НЕЛЬЗЯ +object Constants { + const val SERVER_ADDRESS = "http://192.168.0.75:8080" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dto/Code.java b/app/src/main/java/ru/myitschool/work/data/dto/Code.java new file mode 100644 index 0000000..a1f295a --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/Code.java @@ -0,0 +1,20 @@ +package ru.myitschool.work.data.dto; + +public class Code { + private long value; + + public Code() { + } + + public Code(long value) { + this.value = value; + } + + public long getValue() { + return value; + } + + public void setValue(long value) { + this.value = value; + } +} diff --git a/app/src/main/java/ru/myitschool/work/data/dto/Employee.java b/app/src/main/java/ru/myitschool/work/data/dto/Employee.java new file mode 100644 index 0000000..48491f1 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/Employee.java @@ -0,0 +1,34 @@ +package ru.myitschool.work.data.dto; + +public class Employee { + private long id; + private String login; + private String name; + private String photo; + private String position; + private String lastVisit; + + public long getId() { + return id; + } + + public String getLogin() { + return login; + } + + public String getName() { + return name; + } + + public String getPhoto() { + return photo; + } + + public String getPosition() { + return position; + } + + public String getLastVisit() { + return lastVisit; + } +} diff --git a/app/src/main/java/ru/myitschool/work/data/network/RetrofitFactory.java b/app/src/main/java/ru/myitschool/work/data/network/RetrofitFactory.java new file mode 100644 index 0000000..c3515eb --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/network/RetrofitFactory.java @@ -0,0 +1,31 @@ +package ru.myitschool.work.data.network; + +import static ru.myitschool.work.core.Constants.SERVER_ADDRESS; + +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; +import ru.myitschool.work.data.network.api.EmployeeApi; + +public class RetrofitFactory { + + private static RetrofitFactory INSTANCE; + + private RetrofitFactory() { + } + + public static synchronized RetrofitFactory getInstance() { + if (INSTANCE == null) { + INSTANCE = new RetrofitFactory(); + } + return INSTANCE; + } + + private final Retrofit retrofit = new Retrofit.Builder() + .baseUrl(SERVER_ADDRESS) + .addConverterFactory(GsonConverterFactory.create()) + .build(); + + public EmployeeApi getEmployeeApi() { + return retrofit.create(EmployeeApi.class); + } +} diff --git a/app/src/main/java/ru/myitschool/work/data/network/api/EmployeeApi.java b/app/src/main/java/ru/myitschool/work/data/network/api/EmployeeApi.java new file mode 100644 index 0000000..8197a5c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/network/api/EmployeeApi.java @@ -0,0 +1,20 @@ +package ru.myitschool.work.data.network.api; + +import retrofit2.Call; +import retrofit2.http.Body; +import retrofit2.http.GET; +import retrofit2.http.PATCH; +import retrofit2.http.Path; +import ru.myitschool.work.data.dto.Code; +import ru.myitschool.work.data.dto.Employee; + +public interface EmployeeApi { + @GET("/api/{login}/auth") + Call auth(@Path("login") String login); + + @GET("/api/{login}/info") + Call info(@Path("login") String login); + + @PATCH("/api/{login}/open") + Call open(@Path("login") String login, @Body Code code); +} diff --git a/app/src/main/java/ru/myitschool/work/data/repository/InfoRepositoryImpl.java b/app/src/main/java/ru/myitschool/work/data/repository/InfoRepositoryImpl.java new file mode 100644 index 0000000..337d47c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repository/InfoRepositoryImpl.java @@ -0,0 +1,43 @@ +package ru.myitschool.work.data.repository; + +import androidx.annotation.NonNull; + +import java.util.function.Consumer; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import ru.myitschool.work.data.dto.Employee; +import ru.myitschool.work.data.network.RetrofitFactory; +import ru.myitschool.work.data.network.api.EmployeeApi; +import ru.myitschool.work.domain.entities.Status; +import ru.myitschool.work.domain.info.InfoRepository; + +public class InfoRepositoryImpl implements InfoRepository { + private static InfoRepositoryImpl INSTANCE; + + public static InfoRepositoryImpl getInstance() { + if (INSTANCE == null) { + INSTANCE = new InfoRepositoryImpl(); + } + return INSTANCE; + } + + private EmployeeApi employeeApi = RetrofitFactory.getInstance().getEmployeeApi(); + + + @Override + public void info(@NonNull String login, @NonNull Consumer> callback) { + employeeApi.info(login).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + callback.accept(new Status<>(response.code(), response.body(), null)); + } + + @Override + public void onFailure(Call call, Throwable t) { + callback.accept(new Status<>(-1, null, t)); + } + }); + } +} diff --git a/app/src/main/java/ru/myitschool/work/data/repository/LoginRepositoryImpl.java b/app/src/main/java/ru/myitschool/work/data/repository/LoginRepositoryImpl.java new file mode 100644 index 0000000..7c1db7c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repository/LoginRepositoryImpl.java @@ -0,0 +1,42 @@ +package ru.myitschool.work.data.repository; + +import androidx.annotation.NonNull; + +import java.util.function.Consumer; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import ru.myitschool.work.data.network.RetrofitFactory; +import ru.myitschool.work.data.network.api.EmployeeApi; +import ru.myitschool.work.domain.entities.Status; +import ru.myitschool.work.domain.login.LoginRepository; + +public class LoginRepositoryImpl implements LoginRepository { + + private static LoginRepositoryImpl INSTANCE; + + public static LoginRepositoryImpl getInstance() { + if (INSTANCE == null) { + INSTANCE = new LoginRepositoryImpl(); + } + return INSTANCE; + } + + private EmployeeApi employeeApi = RetrofitFactory.getInstance().getEmployeeApi(); + + @Override + public void auth(@NonNull String login, @NonNull Consumer> callback) { + employeeApi.auth(login).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + callback.accept(new Status<>(response.code(), null, null)); + } + + @Override + public void onFailure(Call call, Throwable t) { + callback.accept(new Status<>(-1, null, t)); + } + }); + } +} diff --git a/app/src/main/java/ru/myitschool/work/data/repository/OpenRepositoryImpl.java b/app/src/main/java/ru/myitschool/work/data/repository/OpenRepositoryImpl.java new file mode 100644 index 0000000..018934f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repository/OpenRepositoryImpl.java @@ -0,0 +1,43 @@ +package ru.myitschool.work.data.repository; + +import androidx.annotation.NonNull; + +import java.util.function.Consumer; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; +import ru.myitschool.work.data.dto.Code; +import ru.myitschool.work.data.network.RetrofitFactory; +import ru.myitschool.work.data.network.api.EmployeeApi; +import ru.myitschool.work.domain.entities.Status; +import ru.myitschool.work.domain.open.OpenRepository; + +public class OpenRepositoryImpl implements OpenRepository { + + private static OpenRepositoryImpl INSTANCE; + + public static OpenRepositoryImpl getInstance() { + if (INSTANCE == null) { + INSTANCE = new OpenRepositoryImpl(); + } + return INSTANCE; + } + + private EmployeeApi employeeApi = RetrofitFactory.getInstance().getEmployeeApi(); + + @Override + public void open(@NonNull String login, @NonNull Code code, @NonNull Consumer> callback) { + employeeApi.open(login, code).enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + callback.accept(new Status<>(response.code(), null, null)); + } + + @Override + public void onFailure(Call call, Throwable t) { + callback.accept(new Status<>(-1, null, t)); + } + }); + } +} diff --git a/app/src/main/java/ru/myitschool/work/domain/entities/Status.java b/app/src/main/java/ru/myitschool/work/domain/entities/Status.java new file mode 100644 index 0000000..793e0b7 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/entities/Status.java @@ -0,0 +1,32 @@ +package ru.myitschool.work.domain.entities; + +import androidx.annotation.Nullable; + +public class Status { + private final int statusCode; + @Nullable + private final T value; + + @Nullable + private final Throwable errors; + + public Status(int statusCode, @Nullable T value, @Nullable Throwable errors) { + this.statusCode = statusCode; + this.value = value; + this.errors = errors; + } + + public int getStatusCode() { + return statusCode; + } + + @Nullable + public T getValue() { + return value; + } + + @Nullable + public Throwable getErrors() { + return errors; + } +} diff --git a/app/src/main/java/ru/myitschool/work/domain/info/InfoRepository.java b/app/src/main/java/ru/myitschool/work/domain/info/InfoRepository.java new file mode 100644 index 0000000..2bb2fe3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/info/InfoRepository.java @@ -0,0 +1,12 @@ +package ru.myitschool.work.domain.info; + +import androidx.annotation.NonNull; + +import java.util.function.Consumer; + +import ru.myitschool.work.data.dto.Employee; +import ru.myitschool.work.domain.entities.Status; + +public interface InfoRepository { + void info(@NonNull String login, @NonNull Consumer> callback); +} diff --git a/app/src/main/java/ru/myitschool/work/domain/info/InfoUseCase.java b/app/src/main/java/ru/myitschool/work/domain/info/InfoUseCase.java new file mode 100644 index 0000000..8c74500 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/info/InfoUseCase.java @@ -0,0 +1,20 @@ +package ru.myitschool.work.domain.info; + +import androidx.annotation.NonNull; + +import java.util.function.Consumer; + +import ru.myitschool.work.data.dto.Employee; +import ru.myitschool.work.domain.entities.Status; + +public class InfoUseCase { + private final InfoRepository repository; + + public InfoUseCase(InfoRepository repository) { + this.repository = repository; + } + + public void execute(@NonNull String login, @NonNull Consumer> callback) { + repository.info(login, callback); + } +} diff --git a/app/src/main/java/ru/myitschool/work/domain/login/AuthUseCase.java b/app/src/main/java/ru/myitschool/work/domain/login/AuthUseCase.java new file mode 100644 index 0000000..5eac42a --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/login/AuthUseCase.java @@ -0,0 +1,25 @@ +package ru.myitschool.work.domain.login; + +import androidx.annotation.NonNull; + +import java.util.function.Consumer; + +import ru.myitschool.work.domain.entities.Status; + +public class AuthUseCase { + private final LoginRepository repository; + + public AuthUseCase(LoginRepository repository) { + this.repository = repository; + } + + public void execute(@NonNull String login, @NonNull Consumer> callback) { + repository.auth(login, status -> { + callback.accept(new Status<>( + status.getStatusCode(), + (status.getStatusCode() == 200 || status.getStatusCode() == 401) ? status.getStatusCode() == 200 : null, + status.getErrors() + )); + }); + } +} diff --git a/app/src/main/java/ru/myitschool/work/domain/login/LoginRepository.java b/app/src/main/java/ru/myitschool/work/domain/login/LoginRepository.java new file mode 100644 index 0000000..de09f3b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/login/LoginRepository.java @@ -0,0 +1,11 @@ +package ru.myitschool.work.domain.login; + +import androidx.annotation.NonNull; + +import java.util.function.Consumer; + +import ru.myitschool.work.domain.entities.Status; + +public interface LoginRepository { + void auth(@NonNull String login, @NonNull Consumer> callback); +} diff --git a/app/src/main/java/ru/myitschool/work/domain/open/OpenRepository.java b/app/src/main/java/ru/myitschool/work/domain/open/OpenRepository.java new file mode 100644 index 0000000..2855273 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/open/OpenRepository.java @@ -0,0 +1,12 @@ +package ru.myitschool.work.domain.open; + +import androidx.annotation.NonNull; + +import java.util.function.Consumer; + +import ru.myitschool.work.data.dto.Code; +import ru.myitschool.work.domain.entities.Status; + +public interface OpenRepository { + void open(@NonNull String login, @NonNull Code code, @NonNull Consumer> callback); +} diff --git a/app/src/main/java/ru/myitschool/work/domain/open/OpenUseCase.java b/app/src/main/java/ru/myitschool/work/domain/open/OpenUseCase.java new file mode 100644 index 0000000..fb4692e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/open/OpenUseCase.java @@ -0,0 +1,20 @@ +package ru.myitschool.work.domain.open; + +import androidx.annotation.NonNull; + +import java.util.function.Consumer; + +import ru.myitschool.work.data.dto.Code; +import ru.myitschool.work.domain.entities.Status; + +public class OpenUseCase { + private final OpenRepository repository; + + public OpenUseCase(OpenRepository repository) { + this.repository = repository; + } + + public void execute(@NonNull String login, @NonNull Long code, @NonNull Consumer> callback) { + repository.open(login, new Code(code), callback); + } +} diff --git a/app/src/main/java/ru/myitschool/work/ui/RootActivity.kt b/app/src/main/java/ru/myitschool/work/ui/RootActivity.kt new file mode 100644 index 0000000..51115f5 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/RootActivity.kt @@ -0,0 +1,15 @@ +package ru.myitschool.work.ui + +import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint +import ru.myitschool.work.R + +// НЕ ИЗМЕНЯЙТЕ НАЗВАНИЕ КЛАССА! +@AndroidEntryPoint +class RootActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_root) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/employee/EmployeeFragment.java b/app/src/main/java/ru/myitschool/work/ui/employee/EmployeeFragment.java new file mode 100644 index 0000000..17aea0f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/employee/EmployeeFragment.java @@ -0,0 +1,139 @@ +package ru.myitschool.work.ui.employee; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentResultListener; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.Navigation; + +import com.squareup.picasso.Picasso; + +import ru.myitschool.work.R; +import ru.myitschool.work.data.dto.Employee; +import ru.myitschool.work.databinding.FragmentEmployeeBinding; +import ru.myitschool.work.ui.qr.scan.QrScanDestination; + +public class EmployeeFragment extends Fragment { + + private static final String SHARED_PREFERENCES_LOGIN_PREFERENCES = "l01"; + private static final String SHARED_PREFERENCES_LOGIN_STRING = "l02"; + private static final String SHARED_PREFERENCES_CODE = "l03"; + + + private FragmentEmployeeBinding binding; + private EmployeeViewModel viewModel; + + public EmployeeFragment() { + super(R.layout.fragment_employee); + } + + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + binding = FragmentEmployeeBinding.bind(view); + viewModel = new ViewModelProvider(this).get(EmployeeViewModel.class); + + + binding.logout.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + getContext() + .getSharedPreferences(SHARED_PREFERENCES_LOGIN_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .remove(SHARED_PREFERENCES_LOGIN_STRING).apply(); + + Navigation.findNavController(getView()).navigate( + R.id.action_employee_fragment_to_login_fragment); + } + }); + + binding.refresh.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + viewModel.startQuery(getContext() + .getSharedPreferences(SHARED_PREFERENCES_LOGIN_PREFERENCES, Context.MODE_PRIVATE) + .getString(SHARED_PREFERENCES_LOGIN_STRING, "")); + } + }); + + + binding.scan.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + Navigation.findNavController(getView()).navigate( + R.id.action_employee_fragment_to_qr_fragment); + } + }); + + getParentFragmentManager().setFragmentResultListener(QrScanDestination.REQUEST_KEY, this, new FragmentResultListener() { + @Override + public void onFragmentResult(@NonNull String key, @NonNull Bundle bundle) { + SharedPreferences sharedPreferences = getContext().getSharedPreferences(SHARED_PREFERENCES_LOGIN_PREFERENCES, Context.MODE_PRIVATE); + sharedPreferences.edit() + .putString(SHARED_PREFERENCES_CODE, QrScanDestination.INSTANCE.getDataIfExist(bundle)) + .apply(); + Navigation.findNavController(getView()).navigate(R.id.action_employee_fragment_to_result_fragment); + } + }); + + + subscribe(viewModel); + viewModel.startQuery(getContext() + .getSharedPreferences(SHARED_PREFERENCES_LOGIN_PREFERENCES, Context.MODE_PRIVATE) + .getString(SHARED_PREFERENCES_LOGIN_STRING, "")); + } + + private void subscribe(EmployeeViewModel viewModel) { + viewModel.isSuccessfulReceiptLiveData.observe(getViewLifecycleOwner(), new Observer() { + @Override + public void onChanged(Boolean value) { + if (value) { + binding.fullname.setVisibility(View.VISIBLE); + binding.photo.setVisibility(View.VISIBLE); + binding.position.setVisibility(View.VISIBLE); + binding.lastEntry.setVisibility(View.VISIBLE); + binding.logout.setVisibility(View.VISIBLE); + binding.scan.setVisibility(View.VISIBLE); + + binding.error.setVisibility(View.GONE); + } else { + binding.error.setVisibility(View.VISIBLE); + binding.error.setText("error"); + + binding.fullname.setVisibility(View.GONE); + binding.photo.setVisibility(View.GONE); + binding.position.setVisibility(View.GONE); + binding.lastEntry.setVisibility(View.GONE); + binding.logout.setVisibility(View.GONE); + binding.scan.setVisibility(View.GONE); + } + } + }); + + viewModel.employeeLiveData.observe(getViewLifecycleOwner(), new Observer() { + @Override + public void onChanged(Employee value) { + binding.fullname.setText(value.getName()); + Picasso.get().load(value.getPhoto()).into(binding.photo); + binding.position.setText(value.getPosition()); + binding.lastEntry.setText(value.getLastVisit()); + } + }); + } + + + @Override + public void onDestroyView() { + binding = null; + super.onDestroyView(); + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/employee/EmployeeViewModel.java b/app/src/main/java/ru/myitschool/work/ui/employee/EmployeeViewModel.java new file mode 100644 index 0000000..30b0671 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/employee/EmployeeViewModel.java @@ -0,0 +1,30 @@ +package ru.myitschool.work.ui.employee; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import ru.myitschool.work.data.dto.Employee; +import ru.myitschool.work.data.repository.InfoRepositoryImpl; +import ru.myitschool.work.domain.info.InfoUseCase; + +public class EmployeeViewModel extends ViewModel { + private final MutableLiveData mutableIsSuccessfulReceiptLiveData = new MutableLiveData<>(); + public final LiveData isSuccessfulReceiptLiveData = mutableIsSuccessfulReceiptLiveData; + + private final MutableLiveData mutableEmployeeLiveData = new MutableLiveData<>(); + public final LiveData employeeLiveData = mutableEmployeeLiveData; + + private final InfoUseCase infoUseCase = new InfoUseCase(InfoRepositoryImpl.getInstance()); + + public void startQuery(String currentLogin) { + infoUseCase.execute(currentLogin, status -> { + if (status.getStatusCode() == 200) { + mutableEmployeeLiveData.postValue(status.getValue()); + mutableIsSuccessfulReceiptLiveData.setValue(true); + } else /*(status.getStatusCode() == 401) */ { + mutableIsSuccessfulReceiptLiveData.setValue(false); + } + }); + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/login/LoginFragment.java b/app/src/main/java/ru/myitschool/work/ui/login/LoginFragment.java new file mode 100644 index 0000000..6a81498 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginFragment.java @@ -0,0 +1,108 @@ +package ru.myitschool.work.ui.login; + +import android.content.Context; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.text.Editable; +import android.text.TextWatcher; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.Navigation; + +import ru.myitschool.work.R; +import ru.myitschool.work.databinding.FragmentLoginBinding; + +public class LoginFragment extends Fragment { + + private static final String SHARED_PREFERENCES_LOGIN_PREFERENCES = "l01"; + private static final String SHARED_PREFERENCES_LOGIN_STRING = "l02"; + + private FragmentLoginBinding binding; + private LoginViewModel viewModel; + + public LoginFragment() { + super(R.layout.fragment_login); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + + SharedPreferences sharedPreferences = getContext().getSharedPreferences(SHARED_PREFERENCES_LOGIN_PREFERENCES, Context.MODE_PRIVATE); + if (sharedPreferences.contains(SHARED_PREFERENCES_LOGIN_STRING)) { + + Navigation.findNavController(getView()).navigate( + R.id.action_login_fragment_to_employee_fragment); + } + + binding = FragmentLoginBinding.bind(view); + viewModel = new ViewModelProvider(this).get(LoginViewModel.class); + + binding.username.addTextChangedListener(new TextWatcher() { + @Override + public void beforeTextChanged(CharSequence s, int start, int count, int after) { + } + + @Override + public void onTextChanged(CharSequence s, int start, int before, int count) { + } + + @Override + public void afterTextChanged(Editable s) { + viewModel.onChangeLogin(s.toString()); + binding.error.setVisibility(View.GONE); + } + }); + binding.login.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View v) { + viewModel.startQuery(); + binding.error.setVisibility(View.GONE); + } + }); + + subscribe(viewModel); + + viewModel.onChangeLogin(binding.username.toString()); + + } + + private void subscribe(LoginViewModel viewModel) { + viewModel.buttonStateLiveData.observe(getViewLifecycleOwner(), new Observer() { + @Override + public void onChanged(Boolean value) { + binding.login.setClickable(value); + binding.login.setEnabled(value); + } + }); + viewModel.loginStateLiveData.observe(getViewLifecycleOwner(), new Observer() { + @Override + public void onChanged(String s) { + if (s.equals("Correct Login")) { + SharedPreferences sharedPreferences = getContext().getSharedPreferences(SHARED_PREFERENCES_LOGIN_PREFERENCES, Context.MODE_PRIVATE); + sharedPreferences.edit() + .putString(SHARED_PREFERENCES_LOGIN_STRING, binding.username.getText().toString()) + .apply(); + + Navigation.findNavController(getView()).navigate( + R.id.action_login_fragment_to_employee_fragment); + } else if (s.equals("Incorrect Login")) { + binding.error.setVisibility(View.VISIBLE); + binding.error.setText("Error"); + } + } + }); + } + + @Override + public void onDestroyView() { + binding = null; + super.onDestroyView(); + } +} diff --git a/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.java b/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.java new file mode 100644 index 0000000..5c18f1b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.java @@ -0,0 +1,47 @@ +package ru.myitschool.work.ui.login; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import ru.myitschool.work.data.repository.LoginRepositoryImpl; +import ru.myitschool.work.domain.login.AuthUseCase; + +public class LoginViewModel extends ViewModel { + + private final MutableLiveData mutableButtonStateLiveData = new MutableLiveData<>(); + public final LiveData buttonStateLiveData = mutableButtonStateLiveData; + + private final MutableLiveData mutableLoginStateLiveData = new MutableLiveData<>(); + public final LiveData loginStateLiveData = mutableLoginStateLiveData; + + + private final AuthUseCase authUseCase = new AuthUseCase(LoginRepositoryImpl.getInstance()); + + + private String login; + + public void onChangeLogin(String login) { + this.login = login; + onChangeButtonState(login); + } + + private void onChangeButtonState(String login) { + if (login.length() >= 3 && !Character.isDigit(login.charAt(0)) && login.matches("^[a-zA-Z0-9]*$")) { + mutableButtonStateLiveData.postValue(true); + } else { + mutableButtonStateLiveData.postValue(false); + } + } + + public void startQuery() { + final String currentLogin = login; + authUseCase.execute(currentLogin, status -> { + if (status.getStatusCode() == 200) { + mutableLoginStateLiveData.postValue("Correct Login"); + } else /*(status.getStatusCode() == 401) */ { + mutableLoginStateLiveData.postValue("Incorrect Login"); + } + }); + } +} diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/result/ResultFragment.java b/app/src/main/java/ru/myitschool/work/ui/qr/result/ResultFragment.java new file mode 100644 index 0000000..aa6f306 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/result/ResultFragment.java @@ -0,0 +1,82 @@ +package ru.myitschool.work.ui.qr.result; + +import android.content.Context; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.Observer; +import androidx.lifecycle.ViewModelProvider; +import androidx.navigation.Navigation; + +import ru.myitschool.work.R; +import ru.myitschool.work.databinding.FragmentResultBinding; + +public class ResultFragment extends Fragment { + private static final String SHARED_PREFERENCES_LOGIN_PREFERENCES = "l01"; + private static final String SHARED_PREFERENCES_LOGIN_STRING = "l02"; + private static final String SHARED_PREFERENCES_CODE = "l03"; + + + private FragmentResultBinding binding; + private ResultViewModel viewModel; + + public ResultFragment() { + super(R.layout.fragment_result); + } + + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + binding = FragmentResultBinding.bind(view); + viewModel = new ViewModelProvider(this).get(ResultViewModel.class); + + + binding.close.setOnClickListener(v -> { + Navigation.findNavController(getView()).navigate(R.id.action_result_fragment_to_employee_fragment); + + }); + + try { + if (getContext() + .getSharedPreferences(SHARED_PREFERENCES_LOGIN_PREFERENCES, Context.MODE_PRIVATE) + .getString(SHARED_PREFERENCES_CODE, null) == null) { + binding.result.setText(R.string.operation_was_cancelled); + } + viewModel.startQuery(getContext() + .getSharedPreferences(SHARED_PREFERENCES_LOGIN_PREFERENCES, Context.MODE_PRIVATE) + .getString(SHARED_PREFERENCES_LOGIN_STRING, ""), Long.valueOf(getContext() + .getSharedPreferences(SHARED_PREFERENCES_LOGIN_PREFERENCES, Context.MODE_PRIVATE) + .getString(SHARED_PREFERENCES_CODE, ""))); + } catch (Exception e) { + binding.result.setText(R.string.operation_was_cancelled); + } + + + subscribe(viewModel); + } + + private void subscribe(ResultViewModel viewModel) { + viewModel.isCorrectLiveData.observe(getViewLifecycleOwner(), new Observer() { + @Override + public void onChanged(Boolean value) { + if (value) { + binding.result.setText(R.string.success); + } else { + binding.result.setText(R.string.something_wrong); + } + } + }); + } + + + @Override + public void onDestroyView() { + binding = null; + super.onDestroyView(); + } +} diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/result/ResultViewModel.java b/app/src/main/java/ru/myitschool/work/ui/qr/result/ResultViewModel.java new file mode 100644 index 0000000..d803686 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/result/ResultViewModel.java @@ -0,0 +1,27 @@ +package ru.myitschool.work.ui.qr.result; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +import ru.myitschool.work.data.repository.OpenRepositoryImpl; +import ru.myitschool.work.domain.open.OpenUseCase; + +public class ResultViewModel extends ViewModel { + + private final MutableLiveData mutableIsCorrectLiveData = new MutableLiveData<>(); + public final LiveData isCorrectLiveData = mutableIsCorrectLiveData; + + private final OpenUseCase openUseCase = new OpenUseCase(OpenRepositoryImpl.getInstance()); + + + public void startQuery(String currentLogin, Long code) { + openUseCase.execute(currentLogin, code, status -> { + if (status.getStatusCode() == 200) { + mutableIsCorrectLiveData.setValue(true); + } else /*(status.getStatusCode() == 400 || status.getStatusCode() == 401)*/ { + mutableIsCorrectLiveData.setValue(false); + } + }); + } +} diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanDestination.kt b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanDestination.kt new file mode 100644 index 0000000..7e34b28 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanDestination.kt @@ -0,0 +1,30 @@ +package ru.myitschool.work.ui.qr.scan + +import android.os.Bundle +import androidx.core.os.bundleOf +import kotlinx.serialization.Serializable + +// НЕ ИЗМЕНЯЙТЕ ЭТОТ ФАЙЛ. В ТЕСТАХ ОН БУДЕМ ВОЗВРАЩЁН В ИСХОДНОЕ СОСТОЯНИЕ +@Serializable +data object QrScanDestination { + const val REQUEST_KEY = "qr_result" + private const val KEY_QR_DATA = "key_qr" + + fun newInstance(): QrScanFragment { + return QrScanFragment() + } + + fun getDataIfExist(bundle: Bundle): String? { + return if (bundle.containsKey(KEY_QR_DATA)) { + bundle.getString(KEY_QR_DATA) + } else { + null + } + } + + internal fun packToBundle(data: String): Bundle { + return bundleOf( + KEY_QR_DATA to data + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanFragment.kt b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanFragment.kt new file mode 100644 index 0000000..a9ddaab --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanFragment.kt @@ -0,0 +1,139 @@ +package ru.myitschool.work.ui.qr.scan + +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.ImageAnalysis +import androidx.camera.mlkit.vision.MlKitAnalyzer +import androidx.camera.view.LifecycleCameraController +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import ru.myitschool.work.R +import ru.myitschool.work.databinding.FragmentQrScanBinding +import ru.myitschool.work.utils.collectWhenStarted +import ru.myitschool.work.utils.visibleOrGone + +// НЕ ИЗМЕНЯЙТЕ ЭТОТ ФАЙЛ. В ТЕСТАХ ОН БУДЕМ ВОЗВРАЩЁН В ИСХОДНОЕ СОСТОЯНИЕ +class QrScanFragment : Fragment(R.layout.fragment_qr_scan) { + private var _binding: FragmentQrScanBinding? = null + private val binding: FragmentQrScanBinding get() = _binding!! + + private var barcodeScanner: BarcodeScanner? = null + private var isCameraInit: Boolean = false + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> viewModel.onPermissionResult(isGranted) } + + private val viewModel: QrScanViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentQrScanBinding.bind(view) + sendResult(bundleOf()) + subscribe() + initCallback() + } + + private fun initCallback() { + binding.close.setOnClickListener { viewModel.close() } + } + + private fun subscribe() { + viewModel.state.collectWhenStarted(this) { state -> + binding.loading.visibleOrGone(state is QrScanViewModel.State.Loading) + binding.viewFinder.visibleOrGone(state is QrScanViewModel.State.Scan) + if (!isCameraInit && state is QrScanViewModel.State.Scan) { + startCamera() + isCameraInit = true + } + } + + viewModel.action.collectWhenStarted(this) { action -> + when (action) { + is QrScanViewModel.Action.RequestPermission -> requestPermission(action.permission) + is QrScanViewModel.Action.CloseWithCancel -> { + goBack() + } + is QrScanViewModel.Action.CloseWithResult -> { + sendResult(QrScanDestination.packToBundle(action.result)) + goBack() + } + } + } + } + + private fun requestPermission(permission: String) { + permissionLauncher.launch(permission) + } + + private fun startCamera() { + val context = requireContext() + val cameraController = LifecycleCameraController(context) + val previewView: PreviewView = binding.viewFinder + val executor = ContextCompat.getMainExecutor(context) + + val options = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + val barcodeScanner = BarcodeScanning.getClient(options) + this.barcodeScanner = barcodeScanner + + cameraController.setImageAnalysisAnalyzer( + executor, + MlKitAnalyzer( + listOf(barcodeScanner), + ImageAnalysis.COORDINATE_SYSTEM_VIEW_REFERENCED, + executor + ) { result -> + result?.getValue(barcodeScanner)?.firstOrNull()?.let { value -> + viewModel.findBarcode(value) + + } + } + ) + + cameraController.bindToLifecycle(this) + previewView.controller = cameraController + } + + override fun onDestroyView() { + barcodeScanner?.close() + barcodeScanner = null + _binding = null + super.onDestroyView() + } + + private fun goBack() { + findNavControllerOrNull()?.popBackStack() + ?: requireActivity().onBackPressedDispatcher.onBackPressed() + } + + private fun sendResult(bundle: Bundle) { + setFragmentResult( + QrScanDestination.REQUEST_KEY, + bundle + ) + findNavControllerOrNull() + ?.previousBackStackEntry + ?.savedStateHandle + ?.set(QrScanDestination.REQUEST_KEY, bundle) + } + + private fun findNavControllerOrNull(): NavController? { + return try { + findNavController() + } catch (_: Throwable) { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanViewModel.kt new file mode 100644 index 0000000..14565ab --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanViewModel.kt @@ -0,0 +1,93 @@ +package ru.myitschool.work.ui.qr.scan + +import android.Manifest +import android.app.Application +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.mlkit.vision.barcode.common.Barcode +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.myitschool.work.utils.MutablePublishFlow + +// НЕ ИЗМЕНЯЙТЕ ЭТОТ ФАЙЛ. В ТЕСТАХ ОН БУДЕМ ВОЗВРАЩЁН В ИСХОДНОЕ СОСТОЯНИЕ +class QrScanViewModel( + application: Application +) : AndroidViewModel(application) { + + private val _action = MutablePublishFlow() + val action = _action.asSharedFlow() + + private val _state = MutableStateFlow(initialState) + val state = _state.asStateFlow() + + init { + checkPermission() + } + + fun onPermissionResult(isGranted: Boolean) { + viewModelScope.launch { + if (isGranted) { + _state.update { State.Scan } + } else { + _action.emit(Action.CloseWithCancel) + } + } + } + + private fun checkPermission() { + viewModelScope.launch { + val isPermissionGranted = ContextCompat.checkSelfPermission( + getApplication(), + CAMERA_PERMISSION + ) == PackageManager.PERMISSION_GRANTED + if (isPermissionGranted) { + _state.update { State.Scan } + } else { + delay(1000) + _action.emit(Action.RequestPermission(CAMERA_PERMISSION)) + } + } + } + + fun findBarcode(barcode: Barcode) { + viewModelScope.launch { + barcode.rawValue?.let { value -> + _action.emit(Action.CloseWithResult(value)) + } + } + } + + fun close() { + viewModelScope.launch { + _action.emit(Action.CloseWithCancel) + } + } + + sealed interface State { + data object Loading : State + + data object Scan : State + } + + sealed interface Action { + data class RequestPermission( + val permission: String + ) : Action + data object CloseWithCancel : Action + data class CloseWithResult( + val result: String + ) : Action + } + + private companion object { + val initialState = State.Loading + + const val CAMERA_PERMISSION = Manifest.permission.CAMERA + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/FlowExtensions.kt b/app/src/main/java/ru/myitschool/work/utils/FlowExtensions.kt new file mode 100644 index 0000000..87bccc2 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/FlowExtensions.kt @@ -0,0 +1,10 @@ +package ru.myitschool.work.utils + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow + +fun MutablePublishFlow() = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + BufferOverflow.DROP_OLDEST +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/FragmentExtesions.kt b/app/src/main/java/ru/myitschool/work/utils/FragmentExtesions.kt new file mode 100644 index 0000000..8c99ef3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/FragmentExtesions.kt @@ -0,0 +1,18 @@ +package ru.myitschool.work.utils + +import androidx.fragment.app.Fragment +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +inline fun Flow.collectWhenStarted( + fragment: Fragment, + crossinline collector: (T) -> Unit +) { + fragment.viewLifecycleOwner.lifecycleScope.launch { + flowWithLifecycle(fragment.viewLifecycleOwner.lifecycle).collect { value -> + collector.invoke(value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/TextChangedListener.kt b/app/src/main/java/ru/myitschool/work/utils/TextChangedListener.kt new file mode 100644 index 0000000..c81147d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/TextChangedListener.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.utils + +import android.text.Editable +import android.text.TextWatcher + +open class TextChangedListener: TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + + override fun afterTextChanged(s: Editable?) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/ViewExtensions.kt b/app/src/main/java/ru/myitschool/work/utils/ViewExtensions.kt new file mode 100644 index 0000000..5c38f67 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/ViewExtensions.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work.utils + +import android.view.View + +fun View.visibleOrGone(isVisible: Boolean) { + this.visibility = if (isVisible) View.VISIBLE else View.GONE +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..f8ca0c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..c22a96f --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_no_img.xml b/app/src/main/res/drawable/ic_no_img.xml new file mode 100644 index 0000000..44206c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_no_img.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_qr_code.xml b/app/src/main/res/drawable/ic_qr_code.xml new file mode 100644 index 0000000..b03f9ae --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_code.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..86504d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/activity_root.xml b/app/src/main/res/layout/activity_root.xml new file mode 100644 index 0000000..6229e5a --- /dev/null +++ b/app/src/main/res/layout/activity_root.xml @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_employee.xml b/app/src/main/res/layout/fragment_employee.xml new file mode 100644 index 0000000..77271f5 --- /dev/null +++ b/app/src/main/res/layout/fragment_employee.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + +