commit 028230718a1784bc22f5c3da55e91410f04058b8 Author: Oqisu_req Date: Tue Feb 18 18:46:12 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/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..795af31 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/App.kt b/app/src/main/java/ru/myitschool/work/App.kt new file mode 100644 index 0000000..3085135 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/App.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work + +import android.app.Application +import dagger.hilt.android.HiltAndroidApp + +@HiltAndroidApp +class App : Application() \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/core/Constants.kt b/app/src/main/java/ru/myitschool/work/core/Constants.kt new file mode 100644 index 0000000..8f8138d --- /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://localhost:8090" +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/di/NetworkModule.kt b/app/src/main/java/ru/myitschool/work/data/di/NetworkModule.kt new file mode 100644 index 0000000..acb0e74 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/di/NetworkModule.kt @@ -0,0 +1,21 @@ +package ru.myitschool.work.data.di + +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import ru.myitschool.work.core.Constants + +@Module +@InstallIn(SingletonComponent::class) +object NetworkModule { + @Provides + fun provideRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl(Constants.SERVER_ADDRESS) + .addConverterFactory(GsonConverterFactory.create()) + .build() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/di/RepoModule.kt b/app/src/main/java/ru/myitschool/work/data/di/RepoModule.kt new file mode 100644 index 0000000..4b32a3b --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/di/RepoModule.kt @@ -0,0 +1,25 @@ +package ru.myitschool.work.data.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ViewModelComponent +import ru.myitschool.work.data.repo.AccountRepositoryImpl +import ru.myitschool.work.data.repo.AuthorizationRepositoryImpl +import ru.myitschool.work.domain.auth.repo.AuthorizationRepository +import ru.myitschool.work.domain.profile.repo.UserInfoRepository + +@Module +@InstallIn(ViewModelComponent::class) +abstract class RepoModule { + + @Binds + abstract fun bindAuthRepo( + impl: AuthorizationRepositoryImpl + ): AuthorizationRepository + + @Binds + abstract fun bindAccountRepo( + impl: AccountRepositoryImpl + ): UserInfoRepository +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dto/OpenQrDto.kt b/app/src/main/java/ru/myitschool/work/data/dto/OpenQrDto.kt new file mode 100644 index 0000000..528e1ee --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/OpenQrDto.kt @@ -0,0 +1,8 @@ +package ru.myitschool.work.data.dto + +import com.google.gson.annotations.SerializedName + +class OpenQrDto( + @SerializedName("value") + val value: String +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/dto/UserInfoDto.kt b/app/src/main/java/ru/myitschool/work/data/dto/UserInfoDto.kt new file mode 100644 index 0000000..0ffc7db --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/dto/UserInfoDto.kt @@ -0,0 +1,14 @@ +package ru.myitschool.work.data.dto + +import com.google.gson.annotations.SerializedName + +class UserInfoDto( + @SerializedName("name") + val fullname: String?, + @SerializedName("photo") + val imageUrl: String?, + @SerializedName("position") + val position: String?, + @SerializedName("lastVisit") + val lastEntry: String?, +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/mapper/UserInfoMapper.kt b/app/src/main/java/ru/myitschool/work/data/mapper/UserInfoMapper.kt new file mode 100644 index 0000000..548b2dc --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/mapper/UserInfoMapper.kt @@ -0,0 +1,30 @@ +package ru.myitschool.work.data.mapper + +import ru.myitschool.work.data.dto.UserInfoDto +import ru.myitschool.work.domain.profile.entities.UserInfoEntity +import java.text.SimpleDateFormat +import java.util.Locale +import javax.inject.Inject + +class UserInfoMapper @Inject constructor() { + + operator fun invoke(model: UserInfoDto): Result { + return kotlin.runCatching { + UserInfoEntity( + fullname = model.fullname ?: error("fullname is null"), + imageUrl = model.imageUrl ?: error("imageUrl is null"), + position = model.position ?: error("position is null"), + lastEntryMillis = model.lastEntry?.let { date -> + simpleDateFormat.parse(date)?.time ?: error("parse lastEntry error") + } ?: error("lastEntry is null") + ) + } + } + + private companion object { + private val simpleDateFormat = SimpleDateFormat( + "yyyy-MM-dd'T'HH:mm:ss", + Locale.US + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/AccountRepositoryImpl.kt b/app/src/main/java/ru/myitschool/work/data/repo/AccountRepositoryImpl.kt new file mode 100644 index 0000000..51b9997 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/AccountRepositoryImpl.kt @@ -0,0 +1,32 @@ +package ru.myitschool.work.data.repo + +import dagger.Lazy +import dagger.Reusable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import ru.myitschool.work.data.mapper.UserInfoMapper +import ru.myitschool.work.data.source.AccountNetworkDataSource +import ru.myitschool.work.domain.profile.entities.UserInfoEntity +import ru.myitschool.work.domain.profile.repo.UserInfoRepository +import javax.inject.Inject + +@Reusable +class AccountRepositoryImpl @Inject constructor( + private val accountNetworkDataSource: AccountNetworkDataSource, + private val userInfoMapper: Lazy, +): UserInfoRepository { + override suspend fun getInfo(username: String): Result { + return withContext(Dispatchers.IO) { + accountNetworkDataSource.getInfo(username).fold( + onSuccess = { value -> userInfoMapper.get().invoke(value) }, + onFailure = { error -> Result.failure(error) } + ) + } + } + + override suspend fun openByQr(username: String, content: String): Result { + return withContext(Dispatchers.IO) { + accountNetworkDataSource.openByQr(username, content) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/repo/AuthorizationRepositoryImpl.kt b/app/src/main/java/ru/myitschool/work/data/repo/AuthorizationRepositoryImpl.kt new file mode 100644 index 0000000..2229564 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/repo/AuthorizationRepositoryImpl.kt @@ -0,0 +1,40 @@ +package ru.myitschool.work.data.repo + +import dagger.Reusable +import ru.myitschool.work.data.source.AuthorizationNetworkDataSource +import ru.myitschool.work.data.source.AuthorizationStorageDataSource +import ru.myitschool.work.domain.auth.repo.AuthorizationRepository +import javax.inject.Inject +import dagger.Lazy +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withContext + +@Reusable +class AuthorizationRepositoryImpl @Inject constructor( + private val authorizationStorageDataSource: Lazy, + private val authorizationNetworkDataSource: Lazy, +): AuthorizationRepository { + + override suspend fun login(username: String): Result { + return withContext(Dispatchers.IO) { + authorizationNetworkDataSource.get().checkLogin(username) + .onSuccess { + authorizationStorageDataSource.get().updateLogin(username) + } + } + } + + override suspend fun logout() { + authorizationStorageDataSource.get().updateLogin(null) + } + + override suspend fun getLogin(): Result { + val result = authorizationStorageDataSource.get().login.firstOrNull() + return if (result == null) { + Result.failure(Exception("Not authorize")) + } else { + Result.success(result) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/source/AccountApi.kt b/app/src/main/java/ru/myitschool/work/data/source/AccountApi.kt new file mode 100644 index 0000000..2a5101c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/source/AccountApi.kt @@ -0,0 +1,21 @@ +package ru.myitschool.work.data.source + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.PATCH +import retrofit2.http.Path +import ru.myitschool.work.data.dto.OpenQrDto +import ru.myitschool.work.data.dto.UserInfoDto + +interface AccountApi { + @GET("api/{username}/info") + suspend fun getInfo( + @Path("username") username: String + ) : UserInfoDto + + @PATCH("api/{username}/open") + suspend fun openByQr( + @Path("username") username: String, + @Body content: OpenQrDto + ) +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/source/AccountNetworkDataSource.kt b/app/src/main/java/ru/myitschool/work/data/source/AccountNetworkDataSource.kt new file mode 100644 index 0000000..4010778 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/source/AccountNetworkDataSource.kt @@ -0,0 +1,29 @@ +package ru.myitschool.work.data.source + +import dagger.Reusable +import retrofit2.Retrofit +import ru.myitschool.work.data.dto.OpenQrDto +import ru.myitschool.work.data.dto.UserInfoDto +import javax.inject.Inject + +@Reusable +class AccountNetworkDataSource @Inject constructor( + private val retrofit: Retrofit +) { + private val api by lazy { + retrofit.create(AccountApi::class.java) + } + + suspend fun getInfo(username: String): Result { + return kotlin.runCatching { api.getInfo(username = username) } + } + + suspend fun openByQr(username: String, content: String): Result { + return kotlin.runCatching { + api.openByQr( + username = username, + content = OpenQrDto(value = content) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/source/AuthorizationApi.kt b/app/src/main/java/ru/myitschool/work/data/source/AuthorizationApi.kt new file mode 100644 index 0000000..38ce0e9 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/source/AuthorizationApi.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.data.source + +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path + +interface AuthorizationApi { + @GET("api/{username}/auth") + suspend fun checkLogin( + @Path("username") username: String + ) : Response +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/source/AuthorizationNetworkDataSource.kt b/app/src/main/java/ru/myitschool/work/data/source/AuthorizationNetworkDataSource.kt new file mode 100644 index 0000000..a728203 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/source/AuthorizationNetworkDataSource.kt @@ -0,0 +1,27 @@ +package ru.myitschool.work.data.source + +import android.accounts.NetworkErrorException +import dagger.Reusable +import retrofit2.Retrofit +import javax.inject.Inject + +@Reusable +class AuthorizationNetworkDataSource @Inject constructor( + private val retrofit: Retrofit +) { + private val api by lazy { + retrofit.create(AuthorizationApi::class.java) + } + + suspend fun checkLogin(username: String): Result { + return kotlin.runCatching { api.checkLogin(username = username) }.fold( + onSuccess = { response -> + when (response.code()) { + 200 -> Result.success(Unit) + else -> Result.failure(NetworkErrorException("Error ${response.code()}")) + } + }, + onFailure = { error -> Result.failure(error) } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/data/source/AuthorizationStorageDataSource.kt b/app/src/main/java/ru/myitschool/work/data/source/AuthorizationStorageDataSource.kt new file mode 100644 index 0000000..d4ce936 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/data/source/AuthorizationStorageDataSource.kt @@ -0,0 +1,38 @@ +package ru.myitschool.work.data.source + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AuthorizationStorageDataSource @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val Context.storage: DataStore by preferencesDataStore(name = NAME) + val login: Flow = context.storage.data.map { preferences -> + preferences[LOGIN_KEY] + } + + suspend fun updateLogin(username: String?) { + context.storage.edit { settings -> + if (username != null) { + settings[LOGIN_KEY] = username + } else { + settings.remove(LOGIN_KEY) + } + } + } + + private companion object { + const val NAME = "auth_data" + val LOGIN_KEY = stringPreferencesKey("login") + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/CheckValidLoginUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/CheckValidLoginUseCase.kt new file mode 100644 index 0000000..f5352da --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/CheckValidLoginUseCase.kt @@ -0,0 +1,16 @@ +package ru.myitschool.work.domain.auth + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject + +class CheckValidLoginUseCase @Inject constructor() { + suspend operator fun invoke(login: String): Boolean { + return withContext(Dispatchers.Default) { + login.isNotBlank() && + login.length >= 3 && + !login[0].isDigit() && + login.all { char -> char.isLetterOrDigit() } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/GetLoginUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/GetLoginUseCase.kt new file mode 100644 index 0000000..6502495 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/GetLoginUseCase.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.domain.auth + +import ru.myitschool.work.domain.auth.repo.AuthorizationRepository +import javax.inject.Inject + +class GetLoginUseCase @Inject constructor( + private val repo: AuthorizationRepository, +) { + suspend operator fun invoke(): Result { + return repo.getLogin() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/IsLoginUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/IsLoginUseCase.kt new file mode 100644 index 0000000..15067ed --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/IsLoginUseCase.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.domain.auth + +import ru.myitschool.work.domain.auth.repo.AuthorizationRepository +import javax.inject.Inject + +class IsLoginUseCase @Inject constructor( + private val repo: AuthorizationRepository, +) { + suspend operator fun invoke(): Boolean { + return repo.getLogin().isSuccess + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/LoginUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/LoginUseCase.kt new file mode 100644 index 0000000..62b593d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/LoginUseCase.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.domain.auth + +import ru.myitschool.work.domain.auth.repo.AuthorizationRepository +import javax.inject.Inject + +class LoginUseCase @Inject constructor( + private val repo: AuthorizationRepository, +) { + suspend operator fun invoke(login: String): Result { + return repo.login(username = login) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/LogoutUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/auth/LogoutUseCase.kt new file mode 100644 index 0000000..0e583c2 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/LogoutUseCase.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.domain.auth + +import ru.myitschool.work.domain.auth.repo.AuthorizationRepository +import javax.inject.Inject + +class LogoutUseCase @Inject constructor( + private val repo: AuthorizationRepository, +) { + suspend operator fun invoke() { + return repo.logout() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/auth/repo/AuthorizationRepository.kt b/app/src/main/java/ru/myitschool/work/domain/auth/repo/AuthorizationRepository.kt new file mode 100644 index 0000000..7b4bab8 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/auth/repo/AuthorizationRepository.kt @@ -0,0 +1,8 @@ +package ru.myitschool.work.domain.auth.repo + +interface AuthorizationRepository { + suspend fun login(username: String): Result + suspend fun logout() + + suspend fun getLogin(): Result +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/profile/GetUserInfoUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/profile/GetUserInfoUseCase.kt new file mode 100644 index 0000000..d9cda8f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/profile/GetUserInfoUseCase.kt @@ -0,0 +1,18 @@ +package ru.myitschool.work.domain.profile + +import ru.myitschool.work.domain.auth.GetLoginUseCase +import ru.myitschool.work.domain.profile.entities.UserInfoEntity +import ru.myitschool.work.domain.profile.repo.UserInfoRepository +import javax.inject.Inject + +class GetUserInfoUseCase @Inject constructor( + private val repo: UserInfoRepository, + private val getLoginUseCase: GetLoginUseCase, +) { + suspend operator fun invoke(): Result { + return getLoginUseCase().fold( + onSuccess = { username -> repo.getInfo(username = username) }, + onFailure = { error -> Result.failure(error) } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/profile/OpenByQrUseCase.kt b/app/src/main/java/ru/myitschool/work/domain/profile/OpenByQrUseCase.kt new file mode 100644 index 0000000..8e6d8ae --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/profile/OpenByQrUseCase.kt @@ -0,0 +1,17 @@ +package ru.myitschool.work.domain.profile + +import ru.myitschool.work.domain.auth.GetLoginUseCase +import ru.myitschool.work.domain.profile.repo.UserInfoRepository +import javax.inject.Inject + +class OpenByQrUseCase @Inject constructor( + private val repo: UserInfoRepository, + private val getLoginUseCase: GetLoginUseCase, +) { + suspend operator fun invoke(content: String): Result { + return getLoginUseCase().fold( + onSuccess = { username -> repo.openByQr(username = username, content = content) }, + onFailure = { error -> Result.failure(error) } + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/profile/entities/UserInfoEntity.kt b/app/src/main/java/ru/myitschool/work/domain/profile/entities/UserInfoEntity.kt new file mode 100644 index 0000000..46dc55f --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/profile/entities/UserInfoEntity.kt @@ -0,0 +1,8 @@ +package ru.myitschool.work.domain.profile.entities + +class UserInfoEntity( + val fullname: String, + val imageUrl: String, + val position: String, + val lastEntryMillis: Long, +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/domain/profile/repo/UserInfoRepository.kt b/app/src/main/java/ru/myitschool/work/domain/profile/repo/UserInfoRepository.kt new file mode 100644 index 0000000..d10df45 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/domain/profile/repo/UserInfoRepository.kt @@ -0,0 +1,9 @@ +package ru.myitschool.work.domain.profile.repo + +import ru.myitschool.work.domain.profile.entities.UserInfoEntity + +interface UserInfoRepository { + suspend fun getInfo(username: String) : Result + + suspend fun openByQr(username: String, content: String) : Result +} \ 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..ba0cd96 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/RootActivity.kt @@ -0,0 +1,64 @@ +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.profile.ProfileDestination +import ru.myitschool.work.ui.profile.ProfileFragment +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 ru.myitschool.work.ui.splash.SplashDestination +import ru.myitschool.work.ui.splash.SplashFragment + +@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 = SplashDestination + ) { + fragment() + 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..aea92d6 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginFragment.kt @@ -0,0 +1,73 @@ +package ru.myitschool.work.ui.login + +import android.os.Bundle +import android.text.Editable +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.profile.ProfileDestination +import ru.myitschool.work.utils.TextChangedListener +import ru.myitschool.work.utils.collectWhenStarted +import ru.myitschool.work.utils.visibleOrGone + +@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) + initCallback() + subscribe() + } + + private fun subscribe() { + viewModel.state.collectWhenStarted(this) { state -> + binding.error.visibleOrGone(state is LoginViewModel.State.Error) + binding.loading.visibleOrGone(state is LoginViewModel.State.Loading) + binding.login.visibleOrGone(state !is LoginViewModel.State.Loading) + binding.username.isEnabled = state !is LoginViewModel.State.Loading + when (state) { + is LoginViewModel.State.Loading -> Unit + is LoginViewModel.State.Error -> { + binding.error.text = state.errorText + } + + is LoginViewModel.State.Show -> { + binding.login.isEnabled = state.isLoginButtonEnabled + } + } + } + + viewModel.action.collectWhenStarted(this) { action -> + when (action) { + is LoginViewModel.Action.OpenProfile -> { + findNavController().navigate(ProfileDestination) { + popUpTo { inclusive = true } + } + } + } + } + } + + private fun initCallback() { + binding.login.setOnClickListener { viewModel.clickLogin() } + binding.username.addTextChangedListener(object : TextChangedListener() { + override fun afterTextChanged(s: Editable?) { + viewModel.inputLogin(s.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..1f62bc0 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.kt @@ -0,0 +1,86 @@ +package ru.myitschool.work.ui.login + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.Lazy +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +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.R +import ru.myitschool.work.domain.auth.CheckValidLoginUseCase +import ru.myitschool.work.domain.auth.LoginUseCase +import ru.myitschool.work.utils.MutablePublishFlow +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val checkValidLoginUseCase: Lazy, + private val loginUseCase: Lazy, +) : ViewModel() { + + private val _action = MutablePublishFlow() + val action = _action.asSharedFlow() + + private val _state = MutableStateFlow(initialState) + val state = _state.asStateFlow() + + private var login: String = "" + + fun clickLogin() { + viewModelScope.launch { + _state.update { State.Loading } + loginUseCase.get().invoke(login = login).fold( + onSuccess = { + _action.emit(Action.OpenProfile) + }, + onFailure = { error -> + _state.update { + State.Error( + errorText = error.localizedMessage + ?: context.resources.getString(R.string.login_error) + ) + } + } + ) + } + } + + fun inputLogin(login: String) { + this.login = login + viewModelScope.launch { + _state.update { + State.Show( + isLoginButtonEnabled = checkValidLoginUseCase.get().invoke(login = login) + ) + } + } + } + + sealed interface Action { + data object OpenProfile : Action + } + + sealed interface State { + data object Loading : State + + data class Error( + val errorText: String, + ) : State + + data class Show( + val isLoginButtonEnabled: Boolean + ) : State + } + + private companion object { + val initialState = State.Show( + isLoginButtonEnabled = false + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/profile/ProfileDestination.kt b/app/src/main/java/ru/myitschool/work/ui/profile/ProfileDestination.kt new file mode 100644 index 0000000..83c7a80 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/profile/ProfileDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.profile + +import kotlinx.serialization.Serializable + +@Serializable +data object ProfileDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/profile/ProfileFragment.kt b/app/src/main/java/ru/myitschool/work/ui/profile/ProfileFragment.kt new file mode 100644 index 0000000..d1d6ba1 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/profile/ProfileFragment.kt @@ -0,0 +1,83 @@ +package ru.myitschool.work.ui.profile + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.navigation.fragment.findNavController +import com.squareup.picasso.Picasso +import dagger.hilt.android.AndroidEntryPoint +import ru.myitschool.work.R +import ru.myitschool.work.databinding.FragmentProfileBinding +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 +import ru.myitschool.work.utils.visibleOrGone + +@AndroidEntryPoint +class ProfileFragment : Fragment(R.layout.fragment_profile) { + private var _binding: FragmentProfileBinding? = null + private val binding: FragmentProfileBinding get() = _binding!! + + private val viewModel: ProfileViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentProfileBinding.bind(view) + initCallback() + subscribe() + } + + private fun subscribe() { + viewModel.state.collectWhenStarted(this) { state -> + binding.showState.visibleOrGone(state is ProfileViewModel.State.Show) + binding.error.visibleOrGone(state is ProfileViewModel.State.Error) + binding.loading.visibleOrGone(state is ProfileViewModel.State.Loading) + + when(state) { + is ProfileViewModel.State.Loading -> Unit + is ProfileViewModel.State.Error -> { + binding.error.text = state.errorText + } + is ProfileViewModel.State.Show -> { + binding.fullname.text = state.fullname + binding.position.text = state.position + binding.lastEntry.text = state.lastEntry + Picasso.get() + .load(state.imageUrl) + .error(R.drawable.ic_no_img) + .into(binding.photo) + } + } + } + + viewModel.action.collectWhenStarted(this) { action -> + when(action) { + is ProfileViewModel.Action.OpenLogin -> { + findNavController().navigate(LoginDestination) { + popUpTo { inclusive = true } + } + } + + is ProfileViewModel.Action.OpenScan -> { + findNavController().apply { + navigate(QrResultDestination) + navigate(QrScanDestination) + } + } + } + } + } + + private fun initCallback() { + binding.refresh.setOnClickListener { viewModel.clickRefresh() } + binding.logout.setOnClickListener { viewModel.clickLogout() } + binding.scan.setOnClickListener { viewModel.clickScan() } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/profile/ProfileViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/profile/ProfileViewModel.kt new file mode 100644 index 0000000..2c51899 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/profile/ProfileViewModel.kt @@ -0,0 +1,111 @@ +package ru.myitschool.work.ui.profile + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.Lazy +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +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.R +import ru.myitschool.work.domain.auth.LogoutUseCase +import ru.myitschool.work.domain.profile.GetUserInfoUseCase +import ru.myitschool.work.utils.MutablePublishFlow +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +@HiltViewModel +class ProfileViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val getUserInfoUseCase: Lazy, + private val logoutUseCase: Lazy, +) : ViewModel() { + + private val _action = MutablePublishFlow() + val action = _action.asSharedFlow() + + private val _state = MutableStateFlow(initialState) + val state = _state.asStateFlow() + + init { + updateUserInfo() + } + + fun clickRefresh() { + updateUserInfo() + } + + fun clickLogout() { + viewModelScope.launch { + logoutUseCase.get().invoke() + _action.emit(Action.OpenLogin) + } + } + + fun clickScan() { + viewModelScope.launch { + _action.emit(Action.OpenScan) + } + } + + private fun updateUserInfo() { + viewModelScope.launch { + _state.update { State.Loading } + getUserInfoUseCase.get().invoke().fold( + onSuccess = { value -> + _state.update { + State.Show( + fullname = value.fullname, + imageUrl = value.imageUrl, + position = value.position, + lastEntry = simpleDateFormat.format(Date(value.lastEntryMillis)) + ) + } + }, + onFailure = { error -> + _state.update { + State.Error( + errorText = error.localizedMessage + ?: context.resources.getString(R.string.login_error) + ) + } + } + ) + } + } + + sealed interface State { + data object Loading : State + + data class Error( + val errorText: String, + ) : State + + data class Show( + val fullname: String, + val imageUrl: String, + val position: String, + val lastEntry: String, + ) : State + } + + sealed interface Action { + data object OpenLogin : Action + data object OpenScan : Action + } + + private companion object { + private val simpleDateFormat = SimpleDateFormat( + "yyyy-MM-DD HH:mm", + Locale.US + ) + + val initialState = State.Loading + } +} \ 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..1cde936 --- /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 +object QrResultDestination \ 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..30ebddc --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultFragment.kt @@ -0,0 +1,64 @@ +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 dagger.hilt.android.AndroidEntryPoint +import ru.myitschool.work.R +import ru.myitschool.work.databinding.FragmentQrResultBinding +import ru.myitschool.work.ui.qr.scan.QrScanDestination +import ru.myitschool.work.utils.collectWhenStarted +import ru.myitschool.work.utils.visibleOrGone + +@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) + initCallback() + subscribe() + } + + private fun subscribe() { + viewModel.state.collectWhenStarted(this) { state -> + binding.loading.visibleOrGone(state is QrResultViewModel.State.Loading) + binding.status.visibleOrGone(state is QrResultViewModel.State.Show) + when (state) { + is QrResultViewModel.State.Loading -> Unit + is QrResultViewModel.State.Show -> { + binding.status.text = state.status + } + } + } + + viewModel.action.collectWhenStarted(this) { action -> + when (action) { + is QrResultViewModel.Action.Close -> { + findNavController().popBackStack() + } + } + } + } + + private fun initCallback() { + binding.close.setOnClickListener { viewModel.clickClose() } + findNavController().currentBackStackEntry + ?.savedStateHandle + ?.getLiveData(QrScanDestination.REQUEST_KEY) + ?.observe(viewLifecycleOwner) { result -> + viewModel.setScanResult(QrScanDestination.getDataIfExist(result)) + } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} \ 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..27e5ac1 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/result/QrResultViewModel.kt @@ -0,0 +1,71 @@ +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.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.myitschool.work.domain.profile.OpenByQrUseCase +import ru.myitschool.work.utils.MutablePublishFlow +import javax.inject.Inject +import dagger.Lazy +import ru.myitschool.work.R + +@HiltViewModel +class QrResultViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val openByQrUseCase: Lazy, +) : ViewModel() { + + private val _action = MutablePublishFlow() + val action = _action.asSharedFlow() + + private val _state = MutableStateFlow(initialState) + val state = _state.asStateFlow() + + fun clickClose() { + viewModelScope.launch { + _action.emit(Action.Close) + } + } + + fun setScanResult(content: String?) { + viewModelScope.launch { + _state.update { State.Loading } + val statusText = if (content == null) { + R.string.qr_result_status_cancel + } else { + openByQrUseCase.get().invoke(content).fold( + onSuccess = { R.string.qr_result_status_success }, + onFailure = { R.string.qr_result_status_error } + ) + } + _state.update { + State.Show( + status = context.resources.getString(statusText) + ) + } + } + } + + sealed interface Action { + data object Close : Action + } + + sealed interface State { + data object Loading : State + + data class Show( + val status: String + ) : State + } + + private companion object { + val initialState = State.Loading + } +} \ 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..d7b8e6d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanDestination.kt @@ -0,0 +1,29 @@ +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..38beb4d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanFragment.kt @@ -0,0 +1,138 @@ +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..e4fd4da --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanViewModel.kt @@ -0,0 +1,92 @@ +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/ui/splash/SplashDestination.kt b/app/src/main/java/ru/myitschool/work/ui/splash/SplashDestination.kt new file mode 100644 index 0000000..b0a6e34 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/splash/SplashDestination.kt @@ -0,0 +1,6 @@ +package ru.myitschool.work.ui.splash + +import kotlinx.serialization.Serializable + +@Serializable +data object SplashDestination \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/splash/SplashFragment.kt b/app/src/main/java/ru/myitschool/work/ui/splash/SplashFragment.kt new file mode 100644 index 0000000..bcd9f3e --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/splash/SplashFragment.kt @@ -0,0 +1,50 @@ +package ru.myitschool.work.ui.splash + +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.FragmentSplashBinding +import ru.myitschool.work.ui.login.LoginDestination +import ru.myitschool.work.ui.profile.ProfileDestination +import ru.myitschool.work.utils.collectWhenStarted + +@AndroidEntryPoint +class SplashFragment : Fragment(R.layout.fragment_splash) { + private var _binding: FragmentSplashBinding? = null + private val binding: FragmentSplashBinding get() = _binding!! + + private val viewModel: SplashViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentSplashBinding.bind(view) + subscribe() + } + + private fun subscribe() { + viewModel.action.collectWhenStarted(this) { action -> + when (action) { + SplashViewModel.Action.OpenProfile -> { + findNavController().navigate(ProfileDestination) { + popUpTo(SplashDestination) { inclusive = true } + } + } + + SplashViewModel.Action.OpenLogin -> { + findNavController().navigate(LoginDestination) { + popUpTo { inclusive = true } + } + } + } + } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/splash/SplashViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/splash/SplashViewModel.kt new file mode 100644 index 0000000..1eb0fbc --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/splash/SplashViewModel.kt @@ -0,0 +1,35 @@ +package ru.myitschool.work.ui.splash + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.Lazy +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.launch +import ru.myitschool.work.domain.auth.IsLoginUseCase +import ru.myitschool.work.utils.MutablePublishFlow +import javax.inject.Inject + +@HiltViewModel +class SplashViewModel @Inject constructor( + private val isLoginUseCase: Lazy, +) : ViewModel() { + + private val _action = MutablePublishFlow() + val action = _action.asSharedFlow() + + init { + viewModelScope.launch { + if (isLoginUseCase.get().invoke()) { + _action.emit(Action.OpenProfile) + } else { + _action.emit(Action.OpenLogin) + } + } + } + + sealed interface Action { + data object OpenProfile : Action + data object OpenLogin : Action + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/FlowExtensions.kt b/app/src/main/java/ru/myitschool/work/utils/FlowExtensions.kt new file mode 100644 index 0000000..87bccc2 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/FlowExtensions.kt @@ -0,0 +1,10 @@ +package ru.myitschool.work.utils + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow + +fun MutablePublishFlow() = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + BufferOverflow.DROP_OLDEST +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/FragmentExtesions.kt b/app/src/main/java/ru/myitschool/work/utils/FragmentExtesions.kt new file mode 100644 index 0000000..8c99ef3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/FragmentExtesions.kt @@ -0,0 +1,18 @@ +package ru.myitschool.work.utils + +import androidx.fragment.app.Fragment +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +inline fun Flow.collectWhenStarted( + fragment: Fragment, + crossinline collector: (T) -> Unit +) { + fragment.viewLifecycleOwner.lifecycleScope.launch { + flowWithLifecycle(fragment.viewLifecycleOwner.lifecycle).collect { value -> + collector.invoke(value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/TextChangedListener.kt b/app/src/main/java/ru/myitschool/work/utils/TextChangedListener.kt new file mode 100644 index 0000000..c81147d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/TextChangedListener.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.utils + +import android.text.Editable +import android.text.TextWatcher + +open class TextChangedListener: TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + + override fun afterTextChanged(s: Editable?) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/ViewExtensions.kt b/app/src/main/java/ru/myitschool/work/utils/ViewExtensions.kt new file mode 100644 index 0000000..5c38f67 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/ViewExtensions.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work.utils + +import android.view.View + +fun View.visibleOrGone(isVisible: Boolean) { + this.visibility = if (isVisible) View.VISIBLE else View.GONE +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..f8ca0c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_logout.xml b/app/src/main/res/drawable/ic_logout.xml new file mode 100644 index 0000000..c22a96f --- /dev/null +++ b/app/src/main/res/drawable/ic_logout.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_no_img.xml b/app/src/main/res/drawable/ic_no_img.xml new file mode 100644 index 0000000..44206c9 --- /dev/null +++ b/app/src/main/res/drawable/ic_no_img.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_qr_code.xml b/app/src/main/res/drawable/ic_qr_code.xml new file mode 100644 index 0000000..b03f9ae --- /dev/null +++ b/app/src/main/res/drawable/ic_qr_code.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml new file mode 100644 index 0000000..86504d0 --- /dev/null +++ b/app/src/main/res/drawable/ic_refresh.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/activity_root.xml b/app/src/main/res/layout/activity_root.xml new file mode 100644 index 0000000..e7cb1a9 --- /dev/null +++ b/app/src/main/res/layout/activity_root.xml @@ -0,0 +1,13 @@ + + + + + \ 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..4ce5199 --- /dev/null +++ b/app/src/main/res/layout/fragment_login.xml @@ -0,0 +1,55 @@ + + + + + + + +