commit 9cd1a28cdfa91c92d06e19e9e76d7af45f21c14f Author: pedro Date: Tue Feb 18 18:26:58 2025 +0300 first commit 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/.kotlin/errors/errors-1733770864492.log b/.kotlin/errors/errors-1733770864492.log new file mode 100644 index 0000000..4f711c4 --- /dev/null +++ b/.kotlin/errors/errors-1733770864492.log @@ -0,0 +1,45 @@ +kotlin version: 2.0.21 +error message: androidx.compose.compiler.plugins.kotlin.IncompatibleComposeRuntimeVersionException: The Compose Compiler requires the Compose Runtime to be on the class path, but none could be found. The compose compiler plugin you are using (version 1.5.14) expects a minimum runtime version of 1.0.0. + at androidx.compose.compiler.plugins.kotlin.VersionChecker.noRuntimeOnClasspathError(VersionChecker.kt:221) + at androidx.compose.compiler.plugins.kotlin.VersionChecker.check(VersionChecker.kt:193) + at androidx.compose.compiler.plugins.kotlin.ComposeIrGenerationExtension.generate(ComposeIrGenerationExtension.kt:73) + at org.jetbrains.kotlin.fir.pipeline.ConvertToIrKt.applyIrGenerationExtensions(convertToIr.kt:442) + at org.jetbrains.kotlin.fir.pipeline.Fir2IrPipeline.runActualizationPipeline(convertToIr.kt:246) + at org.jetbrains.kotlin.fir.pipeline.Fir2IrPipeline.convertToIrAndActualize(convertToIr.kt:130) + at org.jetbrains.kotlin.fir.pipeline.ConvertToIrKt.convertToIrAndActualize(convertToIr.kt:99) + at org.jetbrains.kotlin.fir.pipeline.ConvertToIrKt.convertToIrAndActualize$default(convertToIr.kt:72) + at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.convertToIrAndActualizeForJvm(jvmCompilerPipeline.kt:196) + at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.convertAnalyzedFirToIr(jvmCompilerPipeline.kt:169) + at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:140) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49) + at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally$lambda$9$compile(IncrementalCompilerRunner.kt:249) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:267) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:120) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + + diff --git a/.kotlin/errors/errors-1733770932568.log b/.kotlin/errors/errors-1733770932568.log new file mode 100644 index 0000000..4f711c4 --- /dev/null +++ b/.kotlin/errors/errors-1733770932568.log @@ -0,0 +1,45 @@ +kotlin version: 2.0.21 +error message: androidx.compose.compiler.plugins.kotlin.IncompatibleComposeRuntimeVersionException: The Compose Compiler requires the Compose Runtime to be on the class path, but none could be found. The compose compiler plugin you are using (version 1.5.14) expects a minimum runtime version of 1.0.0. + at androidx.compose.compiler.plugins.kotlin.VersionChecker.noRuntimeOnClasspathError(VersionChecker.kt:221) + at androidx.compose.compiler.plugins.kotlin.VersionChecker.check(VersionChecker.kt:193) + at androidx.compose.compiler.plugins.kotlin.ComposeIrGenerationExtension.generate(ComposeIrGenerationExtension.kt:73) + at org.jetbrains.kotlin.fir.pipeline.ConvertToIrKt.applyIrGenerationExtensions(convertToIr.kt:442) + at org.jetbrains.kotlin.fir.pipeline.Fir2IrPipeline.runActualizationPipeline(convertToIr.kt:246) + at org.jetbrains.kotlin.fir.pipeline.Fir2IrPipeline.convertToIrAndActualize(convertToIr.kt:130) + at org.jetbrains.kotlin.fir.pipeline.ConvertToIrKt.convertToIrAndActualize(convertToIr.kt:99) + at org.jetbrains.kotlin.fir.pipeline.ConvertToIrKt.convertToIrAndActualize$default(convertToIr.kt:72) + at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.convertToIrAndActualizeForJvm(jvmCompilerPipeline.kt:196) + at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.convertAnalyzedFirToIr(jvmCompilerPipeline.kt:169) + at org.jetbrains.kotlin.cli.jvm.compiler.pipeline.JvmCompilerPipelineKt.compileModulesUsingFrontendIrAndLightTree(jvmCompilerPipeline.kt:140) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:148) + at org.jetbrains.kotlin.cli.jvm.K2JVMCompiler.doExecute(K2JVMCompiler.kt:43) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:103) + at org.jetbrains.kotlin.cli.common.CLICompiler.execImpl(CLICompiler.kt:49) + at org.jetbrains.kotlin.cli.common.CLITool.exec(CLITool.kt:101) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:464) + at org.jetbrains.kotlin.incremental.IncrementalJvmCompilerRunner.runCompiler(IncrementalJvmCompilerRunner.kt:73) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.doCompile(IncrementalCompilerRunner.kt:506) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compileImpl(IncrementalCompilerRunner.kt:423) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally$lambda$9$compile(IncrementalCompilerRunner.kt:249) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.tryCompileIncrementally(IncrementalCompilerRunner.kt:267) + at org.jetbrains.kotlin.incremental.IncrementalCompilerRunner.compile(IncrementalCompilerRunner.kt:120) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.execIncrementalCompiler(CompileServiceImpl.kt:675) + at org.jetbrains.kotlin.daemon.CompileServiceImplBase.access$execIncrementalCompiler(CompileServiceImpl.kt:92) + at org.jetbrains.kotlin.daemon.CompileServiceImpl.compile(CompileServiceImpl.kt:1660) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(Unknown Source) + at java.base/java.lang.reflect.Method.invoke(Unknown Source) + at java.rmi/sun.rmi.server.UnicastServerRef.dispatch(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.rmi/sun.rmi.transport.Transport$1.run(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.Transport.serviceCall(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport.handleMessages(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run0(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.lambda$run$0(Unknown Source) + at java.base/java.security.AccessController.doPrivileged(Unknown Source) + at java.rmi/sun.rmi.transport.tcp.TCPTransport$ConnectionHandler.run(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(Unknown Source) + at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(Unknown Source) + at java.base/java.lang.Thread.run(Unknown Source) + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef91da2 --- /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`), по нажатию на которую пользователь авторизуется в системе. +- По умолчанию скрытое (`GONE`) текстовое поле с ошибкой (`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..01a15f4 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,94 @@ +plugins { + kotlinAndroid + androidApplication + jetbrainsKotlinSerialization version Version.Kotlin.language + kotlinAnnotationProcessor + id("com.google.dagger.hilt.android").version("2.51.1") + id("org.jetbrains.kotlin.plugin.compose") version "2.0.0" +} + +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 + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = Version.Kotlin.javaSource + targetCompatibility = Version.Kotlin.javaSource + } + + kotlinOptions { + jvmTarget = Version.Kotlin.jvmTarget + } +} + +dependencies { + defaultLibrary() + + val lifecycle_version = "2.2.0" + + implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycle_version") + implementation("androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version") + + val composeBom = platform("androidx.compose:compose-bom:2024.10.01") + implementation(composeBom) + androidTestImplementation(composeBom) + + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") + + 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") + + implementation("com.squareup.okhttp3:logging-interceptor:4.10.0") + + implementation("com.google.code.gson:gson:2.11.0") +} + +kapt { + correctErrorTypes = true +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..71dd361 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,25 @@ +# 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 +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} 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/ApiService.kt b/app/src/main/java/ru/myitschool/work/ApiService.kt new file mode 100644 index 0000000..46e41c8 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ApiService.kt @@ -0,0 +1,17 @@ +package ru.myitschool.work +import okhttp3.ResponseBody +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.Path + +interface ApiService { + @GET("/api/{LOGIN}/auth") + suspend fun auth(@Path("LOGIN") login: String): ResponseBody + + @GET("/api/{LOGIN}/info") + suspend fun info(@Path("LOGIN") login: String): ResponseBody + + @PATCH("/api/{LOGIN}/open") + suspend fun open(@Body data: Data, @Path("LOGIN") login: String): ResponseBody +} \ 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/Data.kt b/app/src/main/java/ru/myitschool/work/Data.kt new file mode 100644 index 0000000..33832e8 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/Data.kt @@ -0,0 +1,5 @@ +package ru.myitschool.work + +data class Data( + val value: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/RetrofitClient.kt b/app/src/main/java/ru/myitschool/work/RetrofitClient.kt new file mode 100644 index 0000000..d504d4a --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/RetrofitClient.kt @@ -0,0 +1,15 @@ +import ru.myitschool.work.core.Constants.SERVER_ADDRESS +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import ru.myitschool.work.ApiService + +object RetrofitClient { + val baseUrl = SERVER_ADDRESS + + fun getInstance(): Retrofit { + return Retrofit.Builder().baseUrl(baseUrl) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } + +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/User.kt b/app/src/main/java/ru/myitschool/work/User.kt new file mode 100644 index 0000000..325c76d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/User.kt @@ -0,0 +1,10 @@ +package ru.myitschool.work + +data class User( + val id: Int, + val lastVisit: String, + val login: String, + val name: String, + val photo: String, + val position: String +) \ 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..9ebbf50 --- /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://172.31.254.138:8090" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/core/env.kt b/app/src/main/java/ru/myitschool/work/core/env.kt new file mode 100644 index 0000000..33b641a --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/core/env.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.core + +object env { + const val SHARED_PREFS = "shared_prefs" + const val EMAIL_KEY = "email_key" +} \ 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..851baac --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/RootActivity.kt @@ -0,0 +1,90 @@ +package ru.myitschool.work.ui + +import RetrofitClient +import android.os.Bundle +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.util.Log +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.navigation.NavController +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.scan.QrScanDestination +import ru.myitschool.work.ui.qr.scan.QrScanFragment +import ru.myitschool.work.core.env.SHARED_PREFS +import ru.myitschool.work.core.env.EMAIL_KEY +import ru.myitschool.work.ui.qr.results.QrResultDestination +import ru.myitschool.work.ui.qr.results.QrResultFragment + +// НЕ ИЗМЕНЯЙТЕ НАЗВАНИЕ КЛАССА! +@AndroidEntryPoint +class RootActivity : AppCompatActivity() { + private lateinit var sharedpreferences: SharedPreferences + public lateinit var navController: NavController + private var username: String? = null + + var qrData = "" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + sharedpreferences = getSharedPreferences(SHARED_PREFS, Context.MODE_PRIVATE) + username = sharedpreferences.getString(EMAIL_KEY, null) + + setContentView(R.layout.activity_root) + + val navHostFragment = supportFragmentManager + .findFragmentById(R.id.nav_host_fragment) as NavHostFragment? + + navController = navHostFragment?.navController!! + + if (navHostFragment != null) { + navController?.graph = navController?.createGraph( + startDestination = if (username != null) MainDestination else LoginDestination + ) { + fragment() + fragment() + fragment() + fragment() + }!! + +// navController.navigate(QrResultDestination) + } + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + onSupportNavigateUp() + } + } + ) + } + + override fun onSupportNavigateUp(): Boolean { + val popBackResult = if (navController.previousBackStackEntry != null) { + navController.popBackStack() + } else { + false + } + return popBackResult || super.onSupportNavigateUp() + } + + public fun navigate(a: Int, b: androidx.fragment.app.Fragment) { + supportFragmentManager.beginTransaction() + .replace(a, b) + .commit() + } +} \ 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..f43a55e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginFragment.kt @@ -0,0 +1,91 @@ +package ru.myitschool.work.ui.login + +import RetrofitClient +import RetrofitClient.getInstance +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.util.Log +import android.view.View +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import androidx.navigation.NavController +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import ru.myitschool.work.R +import ru.myitschool.work.databinding.FragmentLoginBinding +import ru.myitschool.work.ui.RootActivity +import ru.myitschool.work.ui.main.MainFragment +import ru.myitschool.work.ApiService +import ru.myitschool.work.core.env.EMAIL_KEY +import ru.myitschool.work.core.env.SHARED_PREFS +import ru.myitschool.work.ui.main.MainDestination +import ru.myitschool.work.ui.qr.scan.QrScanDestination + +@AndroidEntryPoint +class LoginFragment : Fragment(R.layout.fragment_login) { + private lateinit var sharedpreferences: SharedPreferences + + private var _binding: FragmentLoginBinding? = null + private val binding: FragmentLoginBinding get() = _binding!! + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentLoginBinding.bind(view) + + binding.login.setOnClickListener(View.OnClickListener { + handleLogin() + }) + + binding.username.addTextChangedListener { + val name = binding.username.text.toString() + if(name != "" && name.length >= 3 && !isNumericToX(name[0].toString()) && name.matches(Regex("^[A-Za-z0-9]+\$"))) { + binding.login.isEnabled = true + binding.login.isClickable = true + } else { + binding.login.isEnabled = false + binding.login.isClickable = false + } + } + } + + fun isNumericToX(toCheck: String): Boolean { + return toCheck.toDoubleOrNull() != null + } + + private fun handleLogin() { + binding.loading.visibility = View.VISIBLE + binding.fields.visibility = View.GONE + + sharedpreferences = activity?.getSharedPreferences(SHARED_PREFS, Context.MODE_PRIVATE)!! + + MainScope().launch { + try { + val apiInterface = getInstance().create(ApiService::class.java) + val result = apiInterface.auth(binding.username.text.toString()) + with(sharedpreferences.edit()) { + putString(EMAIL_KEY, binding.username.text.toString()) + apply() + } + (activity as RootActivity).navController?.popBackStack() + (activity as RootActivity).navController?.navigate(MainDestination) + } catch (e: Exception) { + binding.loading.visibility = View.GONE + binding.fields.visibility = View.VISIBLE + binding.error.visibility = View.VISIBLE + binding.error.text = e.toString() + } + } + } + + 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/LoginViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..3a53d6c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.kt @@ -0,0 +1,17 @@ +package ru.myitschool.work.ui.login + +import android.content.Context +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + @ApplicationContext private val context: Context, +) : ViewModel() { + private val _state = MutableStateFlow(true) + val state = _state.asStateFlow() +} \ No newline at end of file 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..a044334 --- /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 object MainDestination \ 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..4d0606e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/main/MainFragment.kt @@ -0,0 +1,112 @@ +package ru.myitschool.work.ui.main + +import RetrofitClient.getInstance +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.ImageView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResultListener +import androidx.lifecycle.lifecycleScope +import com.google.gson.Gson +import com.squareup.picasso.Picasso +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.Response +import ru.myitschool.work.ApiService +import ru.myitschool.work.R +import ru.myitschool.work.User +import ru.myitschool.work.core.env.EMAIL_KEY +import ru.myitschool.work.core.env.SHARED_PREFS +import ru.myitschool.work.databinding.FragmentMainBinding +import ru.myitschool.work.ui.RootActivity +import ru.myitschool.work.ui.login.LoginDestination +import ru.myitschool.work.ui.login.LoginFragment +import ru.myitschool.work.ui.qr.results.QrResultDestination +import ru.myitschool.work.ui.qr.scan.QrScanDestination +import ru.myitschool.work.ui.qr.scan.QrScanFragment +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + +@AndroidEntryPoint +class MainFragment: Fragment(R.layout.fragment_main) { + private var _binding: FragmentMainBinding? = null + private val binding: FragmentMainBinding get() = _binding!! + + private lateinit var sharedpreferences: SharedPreferences + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentMainBinding.bind(view) + sharedpreferences = activity?.getSharedPreferences(SHARED_PREFS, Context.MODE_PRIVATE)!! + + refresh() + binding.logout.setOnClickListener(View.OnClickListener { + with(sharedpreferences.edit()) { + clear() + apply() + } + (activity as RootActivity).navController?.popBackStack() + (activity as RootActivity).navController?.navigate(LoginDestination) + }) + + binding.scan.setOnClickListener(View.OnClickListener { + (activity as RootActivity).navController?.navigate(QrScanDestination) + }) + + binding.refresh.setOnClickListener(View.OnClickListener { + refresh() + }) + + setFragmentResultListener(QrScanDestination.REQUEST_KEY) { requestKey, bundle -> + val result = bundle.getString("key_qr") + if(result != null) { + (activity as RootActivity).qrData = result.toString() + (activity as RootActivity).navController?.navigate(QrResultDestination) + } + } + } + + fun refresh() { + val username = sharedpreferences.getString(EMAIL_KEY, null) + val gson = Gson() + + MainScope().launch { + val task = async { + try { + val apiInterface = getInstance().create(ApiService::class.java) + val response = apiInterface.info(""+username) + return@async response + } catch (e: Exception) { + binding.innerFields.visibility = View.GONE + binding.error.visibility = View.VISIBLE + binding.error.text = e.toString() + return@async null + } + } + val info = gson.fromJson(task.await()?.string(), User::class.java) + if(info != null) { + withContext(Dispatchers.Main) { + binding.error.visibility = View.GONE + binding.innerFields.visibility = View.VISIBLE + + binding.fullname.text = info.name + binding.position.text = info.position + val time = LocalDateTime.parse(info.lastVisit).format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) + binding.lastEntry.text = time.toString() + val imageView: ImageView = binding.photo + Picasso.get().load(info.photo).into(imageView) + } + } + } + } +} \ No newline at end of file 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..0a8f655 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/main/MainViewModel.kt @@ -0,0 +1,17 @@ +package ru.myitschool.work.ui.main + +import android.content.Context +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class MainViewModel @Inject constructor( + @ApplicationContext private val context: Context, +) : ViewModel() { + private val _state = MutableStateFlow(true) + val state = _state.asStateFlow() +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/results/QrResultDestination.kt b/app/src/main/java/ru/myitschool/work/ui/qr/results/QrResultDestination.kt new file mode 100644 index 0000000..70e282b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/results/QrResultDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.qr.results +import kotlinx.serialization.Serializable + +@Serializable +object QrResultDestination { +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/results/QrResultFragment.kt b/app/src/main/java/ru/myitschool/work/ui/qr/results/QrResultFragment.kt new file mode 100644 index 0000000..d0c1d45 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/results/QrResultFragment.kt @@ -0,0 +1,63 @@ +package ru.myitschool.work.ui.qr.results + +import RetrofitClient.getInstance +import android.content.Context +import android.content.SharedPreferences +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import ru.myitschool.work.ApiService +import ru.myitschool.work.Data +import ru.myitschool.work.R +import ru.myitschool.work.core.env.EMAIL_KEY +import ru.myitschool.work.core.env.SHARED_PREFS +import ru.myitschool.work.databinding.FragmentMainBinding +import ru.myitschool.work.databinding.FragmentQrResultBinding +import ru.myitschool.work.databinding.FragmentQrScanBinding +import ru.myitschool.work.ui.RootActivity +import ru.myitschool.work.ui.login.LoginDestination +import ru.myitschool.work.ui.main.MainDestination +import ru.myitschool.work.ui.qr.scan.QrScanDestination + +class QrResultFragment: Fragment(R.layout.fragment_qr_result) { + private var _binding: FragmentQrResultBinding? = null + private lateinit var sharedpreferences: SharedPreferences + private val binding: FragmentQrResultBinding get() = _binding!! + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentQrResultBinding.bind(view) + + sharedpreferences = activity?.getSharedPreferences(SHARED_PREFS, Context.MODE_PRIVATE)!! + + val username = sharedpreferences.getString(EMAIL_KEY, null) + val data = (activity as RootActivity).qrData + + if(data == "" || data == "Отмена") { + binding.result.text = resources.getString(R.string.canceled) + } else { + MainScope().launch { + try { + val apiInterface = getInstance().create(ApiService::class.java) + val req: Data = Data(data) + val result = apiInterface.open(req, ""+username) + binding.result.text = resources.getString(R.string.success) + } catch (e: Exception) { + binding.result.text = resources.getString(R.string.smthwr) + } + } + } + + (activity as RootActivity).qrData = "null" + + binding.close.setOnClickListener(View.OnClickListener { + goBack() + }) + } + + private fun goBack() { + (activity as RootActivity).navController?.popBackStack() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/results/QrResultViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/qr/results/QrResultViewModel.kt new file mode 100644 index 0000000..31669f4 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/results/QrResultViewModel.kt @@ -0,0 +1,17 @@ +package ru.myitschool.work.ui.main + +import android.content.Context +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class QrResultViewModel @Inject constructor( + @ApplicationContext private val context: Context, +) : ViewModel() { + private val _state = MutableStateFlow(true) + val state = _state.asStateFlow() +} 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..de438a8 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanFragment.kt @@ -0,0 +1,140 @@ +package ru.myitschool.work.ui.qr.scan + +import android.os.Bundle +import android.util.Log +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..927380c --- /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) + } + } +} 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/drawable/ic_rounded.xml b/app/src/main/res/drawable/ic_rounded.xml new file mode 100644 index 0000000..8877b62 --- /dev/null +++ b/app/src/main/res/drawable/ic_rounded.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..0bf7f5b --- /dev/null +++ b/app/src/main/res/layout/activity_root.xml @@ -0,0 +1,14 @@ + + + + + \ 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..5c85e8a --- /dev/null +++ b/app/src/main/res/layout/fragment_login.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + +