From 8cdf504893bf25a2ddcf97b79c087e107563a014 Mon Sep 17 00:00:00 2001 From: shipovnikaaa Date: Tue, 18 Feb 2025 19:41:28 +0300 Subject: [PATCH] first commit --- .gitignore | 10 + .gitmodules | 6 + README.md | 122 +++++++++ app/.gitignore | 1 + app/build.gradle.kts | 69 ++++++ app/proguard-rules.pro | 21 ++ app/src/main/AndroidManifest.xml | 34 +++ app/src/main/java/ru/myitschool/work/App.kt | 9 + .../java/ru/myitschool/work/MainActivity.kt | 18 ++ .../java/ru/myitschool/work/core/Constants.kt | 6 + .../myitschool/work/data/remote/ErrorDto.kt | 8 + .../myitschool/work/data/remote/LoginApi.kt | 19 ++ .../work/data/remote/OpenWithCodeRequest.kt | 5 + .../work/data/remote/PersonInfoDto.kt | 10 + .../java/ru/myitschool/work/di/AppModule.kt | 47 ++++ .../ru/myitschool/work/ui/RootActivity.kt | 68 +++++ .../work/ui/login/LoginDestination.kt | 6 + .../myitschool/work/ui/login/LoginFragment.kt | 64 +++++ .../ru/myitschool/work/ui/login/LoginState.kt | 5 + .../work/ui/login/LoginViewModel.kt | 89 +++++++ .../work/ui/main/MainDestination.kt | 6 + .../myitschool/work/ui/main/MainFragment.kt | 68 +++++ .../ru/myitschool/work/ui/main/MainState.kt | 9 + .../myitschool/work/ui/main/MainViewModel.kt | 73 ++++++ .../work/ui/qr/result/QrResultDestination.kt | 6 + .../work/ui/qr/result/QrResultFragment.kt | 47 ++++ .../work/ui/qr/result/QrResultViewModel.kt | 45 ++++ .../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 | 17 ++ .../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 | 15 ++ app/src/main/res/layout/fragment_login.xml | 36 +++ app/src/main/res/layout/fragment_main.xml | 85 +++++++ .../main/res/layout/fragment_qr_result.xml | 27 ++ app/src/main/res/layout/fragment_qr_scan.xml | 35 +++ .../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/values-ru/strings.xml | 2 + app/src/main/res/values-ru/strings_qr.xml | 9 + app/src/main/res/values/colors.xml | 10 + app/src/main/res/values/ids.xml | 4 + app/src/main/res/values/strings.xml | 5 + app/src/main/res/values/strings_qr.xml | 7 + 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 + gradle.properties | 21 ++ gradlew | 234 ++++++++++++++++++ gradlew.bat | 89 +++++++ settings.gradle.kts | 17 ++ 73 files changed, 2097 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/MainActivity.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/remote/ErrorDto.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/remote/LoginApi.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/remote/OpenWithCodeRequest.kt create mode 100644 app/src/main/java/ru/myitschool/work/data/remote/PersonInfoDto.kt create mode 100644 app/src/main/java/ru/myitschool/work/di/AppModule.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/RootActivity.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/login/LoginDestination.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/login/LoginFragment.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/login/LoginState.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/main/MainDestination.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/main/MainFragment.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/main/MainState.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/main/MainViewModel.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultDestination.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultFragment.kt create mode 100644 app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultViewModel.kt 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_login.xml create mode 100644 app/src/main/res/layout/fragment_main.xml create mode 100644 app/src/main/res/layout/fragment_qr_result.xml create mode 100644 app/src/main/res/layout/fragment_qr_scan.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/values-ru/strings.xml create mode 100644 app/src/main/res/values-ru/strings_qr.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 gradle.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..a9fae98 --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +[![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..a28d464 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,69 @@ +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 { + 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..5141fc3 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + \ 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..1591a73 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/App.kt @@ -0,0 +1,9 @@ +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/MainActivity.kt b/app/src/main/java/ru/myitschool/work/MainActivity.kt new file mode 100644 index 0000000..beb3986 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/MainActivity.kt @@ -0,0 +1,18 @@ +package ru.myitschool.work + +import android.os.Bundle +import android.widget.Button +import androidx.activity.ComponentActivity +import androidx.appcompat.app.AppCompatActivity +import dagger.hilt.android.AndroidEntryPoint + +//@AndroidEntryPoint +//class MainActivity : ComponentActivity() { +// +// override fun onCreate(savedInstanceState: Bundle?) { +// super.onCreate(savedInstanceState) +// +// val button: Button = findViewById(R.id.login) +// } +// +//} \ 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..fb4da22 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/core/Constants.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.core +// БЕРИТЕ И ИЗМЕНЯЙТЕ ХОСТ ТОЛЬКО ЗДЕСЬ И НЕ БЕРИТЕ ИЗ ДРУГИХ МЕСТ. ФАЙЛ ПЕРЕМЕЩАТЬ НЕЛЬЗЯ +object Constants { + const val SERVER_ADDRESS = "http://localhost:8090/api/" + //const val SERVER_ADDRESS = "http://192.168.1.73:8080/api/" +} diff --git a/app/src/main/java/ru/myitschool/work/data/remote/ErrorDto.kt b/app/src/main/java/ru/myitschool/work/data/remote/ErrorDto.kt new file mode 100644 index 0000000..685bdfe --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/remote/ErrorDto.kt @@ -0,0 +1,8 @@ +package ru.myitschool.work.data.remote + +data class ErrorDto( + val timestamp: String, + val status: Long, + val error: String, + val path: String, +) diff --git a/app/src/main/java/ru/myitschool/work/data/remote/LoginApi.kt b/app/src/main/java/ru/myitschool/work/data/remote/LoginApi.kt new file mode 100644 index 0000000..16edc8e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/remote/LoginApi.kt @@ -0,0 +1,19 @@ +package ru.myitschool.work.data.remote + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.Path + +interface LoginApi { + @GET("{login}/auth") + suspend fun auth(@Path("login") login: String): Response + + @GET("{login}/info") + suspend fun info(@Path("login") login: String): PersonInfoDto + + @PATCH("{login}/open") + suspend fun open(@Path("login") login: String, @Body request: OpenWithCodeRequest): Response +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/remote/OpenWithCodeRequest.kt b/app/src/main/java/ru/myitschool/work/data/remote/OpenWithCodeRequest.kt new file mode 100644 index 0000000..eb69891 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/remote/OpenWithCodeRequest.kt @@ -0,0 +1,5 @@ +package ru.myitschool.work.data.remote + +data class OpenWithCodeRequest( + val value: Long +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/remote/PersonInfoDto.kt b/app/src/main/java/ru/myitschool/work/data/remote/PersonInfoDto.kt new file mode 100644 index 0000000..47b27c8 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/remote/PersonInfoDto.kt @@ -0,0 +1,10 @@ +package ru.myitschool.work.data.remote + +data class PersonInfoDto( + val id: Long, + val login: String, + val name: String, + val photo: String, + val position: String, + val lastVisit: String, +) diff --git a/app/src/main/java/ru/myitschool/work/di/AppModule.kt b/app/src/main/java/ru/myitschool/work/di/AppModule.kt new file mode 100644 index 0000000..c1b73f0 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/di/AppModule.kt @@ -0,0 +1,47 @@ +package ru.myitschool.work.di + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import ru.myitschool.work.core.Constants +import ru.myitschool.work.data.remote.LoginApi +import javax.inject.Inject +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Provides + @Singleton + fun provideHttpClient() = Retrofit.Builder() + .baseUrl(Constants.SERVER_ADDRESS) + .addConverterFactory(GsonConverterFactory.create()) + .build() + + @Provides + @Singleton + fun provideLoginApi(retrofit: Retrofit) = retrofit.create(LoginApi::class.java) + + private val Context.dataStore by preferencesDataStore("settings") + + @Singleton + class DataStoreManager @Inject constructor(@ApplicationContext appContext: Context) { + private val settingsDataStore = appContext.dataStore + private val lastUsernameKey = stringPreferencesKey("last_username") + val lastUsername: Flow + get() = settingsDataStore.data.map { it[lastUsernameKey].orEmpty() } + + suspend fun setLastUsername(username: String) = + settingsDataStore.edit { it[lastUsernameKey] = username } + } +} \ No newline at end of file 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..df210c3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/RootActivity.kt @@ -0,0 +1,68 @@ +package ru.myitschool.work.ui + +import android.os.Bundle +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.navigation.createGraph +import androidx.navigation.findNavController +import androidx.navigation.fragment.NavHostFragment +import androidx.navigation.fragment.fragment +import dagger.hilt.android.AndroidEntryPoint +import ru.myitschool.work.R +import ru.myitschool.work.ui.login.LoginDestination +import ru.myitschool.work.ui.login.LoginFragment +import ru.myitschool.work.ui.main.MainDestination +import ru.myitschool.work.ui.main.MainFragment +import ru.myitschool.work.ui.qr.result.QrResultDestination +import ru.myitschool.work.ui.qr.result.QrResultFragment +import ru.myitschool.work.ui.qr.scan.QrScanDestination +import ru.myitschool.work.ui.qr.scan.QrScanFragment +import java.text.SimpleDateFormat +import java.time.LocalDate +import java.time.LocalDateTime +import java.time.ZonedDateTime +import java.time.format.DateTimeFormatter + +// НЕ ИЗМЕНЯЙТЕ НАЗВАНИЕ КЛАССА! +@AndroidEntryPoint +class RootActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_root) + + + val navHostFragment = supportFragmentManager + .findFragmentById(R.id.nav_host_fragment) as NavHostFragment? + + if (navHostFragment != null) { + val navController = navHostFragment.navController + navController.graph = navController.createGraph( + startDestination = LoginDestination + ) { + fragment() + fragment() + fragment() + fragment() + } + } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + onSupportNavigateUp() + } + } + ) + } + + override fun onSupportNavigateUp(): Boolean { + val navController = findNavController(R.id.nav_host_fragment) + val popBackResult = if (navController.previousBackStackEntry != null) { + navController.popBackStack() + } else { + false + } + return popBackResult || super.onSupportNavigateUp() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/login/LoginDestination.kt b/app/src/main/java/ru/myitschool/work/ui/login/LoginDestination.kt new file mode 100644 index 0000000..50acfb0 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.login + +import kotlinx.serialization.Serializable + +@Serializable +data object LoginDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/login/LoginFragment.kt b/app/src/main/java/ru/myitschool/work/ui/login/LoginFragment.kt new file mode 100644 index 0000000..2b80a9c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginFragment.kt @@ -0,0 +1,64 @@ +package ru.myitschool.work.ui.login + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import dagger.hilt.android.AndroidEntryPoint +import ru.myitschool.work.R +import ru.myitschool.work.databinding.FragmentLoginBinding +import ru.myitschool.work.ui.main.MainDestination +import ru.myitschool.work.utils.collectWhenStarted +import ru.myitschool.work.utils.TextChangedListener + +@AndroidEntryPoint +class LoginFragment : Fragment(R.layout.fragment_login) { + private var _binding: FragmentLoginBinding? = null + private val binding: FragmentLoginBinding get() = _binding!! + + private val viewModel: LoginViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentLoginBinding.bind(view) + + binding.username.addTextChangedListener(TextChangedListener { viewModel.onUsernameChanged(it) }) + subscribe() + viewModel.initialize() + binding.login.setOnClickListener { + login(binding.username.text.toString()) + } + } + + private fun login(username: String) { + viewModel.tryLogin(username) { + findNavController().apply { + popBackStack(true) + navigate(MainDestination(username)) + } + } + } + + private fun subscribe() { + viewModel.state.collectWhenStarted(this) { state -> + binding.login.isEnabled = state.isLoginEnabled + if (state.error != null) { + binding.error.visibility = View.VISIBLE + binding.error.text = state.error + } else { + binding.error.visibility = View.GONE + } + } + viewModel.savedUsername.collectWhenStarted(this) { username -> + if (!username.isNullOrBlank()) { + login(username) + } + } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/login/LoginState.kt b/app/src/main/java/ru/myitschool/work/ui/login/LoginState.kt new file mode 100644 index 0000000..dfe01f4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginState.kt @@ -0,0 +1,5 @@ +package ru.myitschool.work.ui.login + +data class LoginState( + val isLoginEnabled: Boolean = false, val error: String? = null +) diff --git a/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..ef72a3f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.kt @@ -0,0 +1,89 @@ +package ru.myitschool.work.ui.login + +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.gson.GsonBuilder +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.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import retrofit2.HttpException +import ru.myitschool.work.data.remote.LoginApi +import ru.myitschool.work.data.remote.ErrorDto +import ru.myitschool.work.di.AppModule +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val api: LoginApi, + private val dataStoreManager: AppModule.DataStoreManager +) : ViewModel() { + private val _state = MutableStateFlow(LoginState()) + val state = _state.asStateFlow() + + private val _savedUsername = MutableStateFlow(null) + val savedUsername = _savedUsername.asStateFlow() + + fun initialize() { + viewModelScope.launch { + dataStoreManager.lastUsername.distinctUntilChanged().collect { lastUsername -> + if (lastUsername.isNotEmpty()) { + _savedUsername.update { lastUsername } + } else { + _savedUsername.update { null } + _state.update { LoginState() } + } + } + } + } + + fun tryLogin(username: String, onSuccess: () -> Unit) { + viewModelScope.launch { + try { + val resp = api.auth(username) + if (resp.code() != 200) { + throw HttpException(resp) + } + Log.d("LoginViewModel", "Login success for $username") + dataStoreManager.setLastUsername(username) + onSuccess() + } catch (httpExc: HttpException) { + Log.e("LoginViewModel", "Login failed for $username", httpExc) + try { + httpExc.response()?.errorBody()?.string()?.let { errorString -> + val gson = GsonBuilder().create() + val errorDto = gson.fromJson(errorString, ErrorDto::class.java) + _state.update { it.copy(error = errorDto.error) } + } + } catch (e: Exception) { + _state.update { it.copy(error = httpExc.message()) } + } + } catch (e: Exception) { + Log.e("LoginViewModel", "Login failed for $username", e) + _state.update { it.copy(error = "Unknown error: ${e.message}") } + } + } + } + + fun onUsernameChanged(username: String) = + _state.update { it.copy(isLoginEnabled = isUsernameValid(username), error = null) } + + companion object { + fun isUsernameValid(username: String): Boolean { + if (username.isEmpty() || username.length < 3 || username.first().isDigit()) { + return false + } + return username.all { it.isLetterOrDigit() && it.isAsciiPrintable() } + } + + private fun Char.isAsciiPrintable(): Boolean { + return this.code in 32..126 + } + } +} diff --git a/app/src/main/java/ru/myitschool/work/ui/main/MainDestination.kt b/app/src/main/java/ru/myitschool/work/ui/main/MainDestination.kt new file mode 100644 index 0000000..97505b7 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/main/MainDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.main + +import kotlinx.serialization.Serializable + +@Serializable +data class MainDestination(val username: String) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/main/MainFragment.kt b/app/src/main/java/ru/myitschool/work/ui/main/MainFragment.kt new file mode 100644 index 0000000..2f4f9a1 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/main/MainFragment.kt @@ -0,0 +1,68 @@ +package ru.myitschool.work.ui.main + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.toRoute +import com.squareup.picasso.Picasso +import dagger.hilt.android.AndroidEntryPoint +import ru.myitschool.work.R +import ru.myitschool.work.databinding.FragmentMainBinding +import ru.myitschool.work.ui.login.LoginDestination +import ru.myitschool.work.ui.qr.result.QrResultDestination +import ru.myitschool.work.ui.qr.scan.QrScanDestination +import ru.myitschool.work.utils.collectWhenStarted + +@AndroidEntryPoint +class MainFragment: Fragment(R.layout.fragment_main) { + private var _binding: FragmentMainBinding? = null + private val binding: FragmentMainBinding get() = _binding!! + private val viewModel: MainViewModel by viewModels() + private val picasso: Picasso by lazy { Picasso.get() } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentMainBinding.bind(view) + + val username = findNavController().currentBackStackEntry?.toRoute()?.username + username?.let { user -> + viewModel.loadPersonInfo(user) + binding.refresh.setOnClickListener { viewModel.loadPersonInfo(user) } + binding.logout.setOnClickListener { + viewModel.logout { + findNavController().apply { + popBackStack(true) + navigate(LoginDestination) + } + } + } + binding.scan.setOnClickListener { + findNavController().navigate(QrResultDestination(user)) + findNavController().navigate(QrScanDestination) + } + } + subscribe() + } + + private fun subscribe() { + viewModel.state.collectWhenStarted(this) { state -> + if (state.photo.isNotEmpty()) { + picasso.load(state.photo).into(binding.photo) + } + binding.fullname.text = state.fullName + binding.position.text = state.position + binding.lastEntry.text = state.lastVisit + setError(state.error) + } + } + + private fun setError(error: String?) { + val showError = error != null + val views = listOf(binding.fullname, binding.position, binding.lastEntry, binding.photo, binding.scan, binding.logout) + views.forEach { it.visibility = if (showError) View.GONE else View.VISIBLE} + binding.error.visibility = if (showError) View.VISIBLE else View.GONE + binding.error.text = error ?: "" + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/main/MainState.kt b/app/src/main/java/ru/myitschool/work/ui/main/MainState.kt new file mode 100644 index 0000000..5596a57 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/main/MainState.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.ui.main + +data class MainState( + val fullName: String = "", + val photo: String = "", + val position: String = "", + val lastVisit: String = "", + val error: String? = null +) diff --git a/app/src/main/java/ru/myitschool/work/ui/main/MainViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/main/MainViewModel.kt new file mode 100644 index 0000000..80fec6e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/main/MainViewModel.kt @@ -0,0 +1,73 @@ +package ru.myitschool.work.ui.main + +import android.content.Context +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.gson.GsonBuilder +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.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import retrofit2.HttpException +import ru.myitschool.work.data.remote.LoginApi +import ru.myitschool.work.data.remote.ErrorDto +import ru.myitschool.work.di.AppModule +import java.text.SimpleDateFormat +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val api: LoginApi, + private val dataStoreManager: AppModule.DataStoreManager +) : ViewModel() { + private val _state = MutableStateFlow(MainState()) + val state = _state.asStateFlow() + + private val dfo = SimpleDateFormat("yyyy-MM-dd HH:mm") + private val dfi= SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss") + + fun loadPersonInfo(username: String) { + viewModelScope.launch { + try { + val info = api.info(username) + _state.update { + MainState( + fullName = info.name, + photo = info.photo, + position = info.position, + lastVisit = dfo.format(dfi.parse(info.lastVisit)!!), + error = null + ) + } + } catch (httpException: HttpException) { + try { + httpException.response()?.errorBody()?.string()?.let { errorString -> + val gson = GsonBuilder().create() + val errorDto = gson.fromJson(errorString, ErrorDto::class.java) + _state.update { + MainState( + error = errorDto.error + ) + } + } + } catch (e: Exception) { + _state.update { MainState(error = httpException.message()) } + } + } catch (e: Exception) { + _state.update { MainState(error = "Unknown error: ${e.message}") } + } + } + } + + fun logout(onLogout: () -> Unit) { + viewModelScope.launch { + dataStoreManager.setLastUsername("") + onLogout() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultDestination.kt b/app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultDestination.kt new file mode 100644 index 0000000..32cceec --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.qr.result + +import kotlinx.serialization.Serializable + +@Serializable +data class QrResultDestination(val username: String) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultFragment.kt b/app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultFragment.kt new file mode 100644 index 0000000..a6632ed --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultFragment.kt @@ -0,0 +1,47 @@ +package ru.myitschool.work.ui.qr.result + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import androidx.navigation.toRoute +import dagger.hilt.android.AndroidEntryPoint +import ru.myitschool.work.R +import ru.myitschool.work.databinding.FragmentQrResultBinding +import ru.myitschool.work.ui.main.MainDestination +import ru.myitschool.work.ui.qr.scan.QrScanDestination +import ru.myitschool.work.utils.collectWhenStarted + +@AndroidEntryPoint +class QrResultFragment: Fragment(R.layout.fragment_qr_result) { + private var _binding: FragmentQrResultBinding? = null + private val binding: FragmentQrResultBinding get() = _binding!! + + private val viewModel: QrResultViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentQrResultBinding.bind(view) + subscribe() + binding.close.setOnClickListener { findNavController().navigateUp() } + + findNavController().currentBackStackEntry?.savedStateHandle?.let { + val data = it.get(QrScanDestination.REQUEST_KEY)?.getString("key_qr") + val username = findNavController().currentBackStackEntry?.toRoute()?.username + username?.let { user -> + viewModel.tryParseData(user, data) + } + } + } + + private fun subscribe() { + viewModel.result.collectWhenStarted(this) { data -> + binding.result.setText(when (data) { + QrResultViewModel.QrResultSource.SUCCESS -> R.string.success_result + QrResultViewModel.QrResultSource.FAILURE -> R.string.failure_result + QrResultViewModel.QrResultSource.CANCEL -> R.string.cancel_result + }) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultViewModel.kt new file mode 100644 index 0000000..4b981bd --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultViewModel.kt @@ -0,0 +1,45 @@ +package ru.myitschool.work.ui.qr.result + +import android.content.Context +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.update +import kotlinx.coroutines.launch +import retrofit2.HttpException +import ru.myitschool.work.data.remote.LoginApi +import ru.myitschool.work.data.remote.OpenWithCodeRequest +import javax.inject.Inject + +@HiltViewModel +class QrResultViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val api: LoginApi +): ViewModel() { + enum class QrResultSource { SUCCESS, FAILURE, CANCEL } + + private val _result = MutableStateFlow(QrResultSource.CANCEL) + val result = _result.asStateFlow() + + fun tryParseData(username: String, data: String?) { + viewModelScope.launch { + if (data == null) { + _result.update { + QrResultSource.CANCEL + } + } else { + try { + api.open(username, OpenWithCodeRequest(data.toLong())) + _result.update { QrResultSource.SUCCESS } + } catch (e: HttpException) { + e.response()?.errorBody()?.string()?.let { errorString -> + _result.update { QrResultSource.FAILURE } + } + } + } + } + } +} \ No newline at end of file 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..350b64e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/TextChangedListener.kt @@ -0,0 +1,17 @@ +package ru.myitschool.work.utils + +import android.annotation.SuppressLint +import android.text.Editable +import android.text.TextWatcher +import android.view.View +import ru.myitschool.work.databinding.FragmentLoginBinding + +class TextChangedListener(private val onChange: (String) -> Unit) : + TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = + onChange(s?.toString().orEmpty()) + + 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..86bc77c --- /dev/null +++ b/app/src/main/res/layout/activity_root.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_login.xml b/app/src/main/res/layout/fragment_login.xml new file mode 100644 index 0000000..667ae5e --- /dev/null +++ b/app/src/main/res/layout/fragment_login.xml @@ -0,0 +1,36 @@ + + + + + +