This commit is contained in:
v228a 2025-02-18 18:52:23 +03:00
commit ded9526406
83 changed files with 2142 additions and 0 deletions

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

69
app/build.gradle.kts Normal file
View File

@ -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
}

21
app/proguard-rules.pro vendored Normal file
View File

@ -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

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<application
android:name=".App"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Default"
tools:targetApi="31">
<activity
android:name=".ui.RootActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,7 @@
package ru.myitschool.work
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class App : Application()

View File

@ -0,0 +1,5 @@
package ru.myitschool.work.core
object Constants {
const val SERVER_ADDRESS = "http://localhost:8090"
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -0,0 +1,8 @@
package ru.myitschool.work.data.dto
import com.google.gson.annotations.SerializedName
class OpenQrDto(
@SerializedName("value")
val value: String
)

View File

@ -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?,
)

View File

@ -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<UserInfoEntity> {
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
)
}
}

View File

@ -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<UserInfoMapper>,
): UserInfoRepository {
override suspend fun getInfo(username: String): Result<UserInfoEntity> {
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<Unit> {
return withContext(Dispatchers.IO) {
accountNetworkDataSource.openByQr(username, content)
}
}
}

View File

@ -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<AuthorizationStorageDataSource>,
private val authorizationNetworkDataSource: Lazy<AuthorizationNetworkDataSource>,
): AuthorizationRepository {
override suspend fun login(username: String): Result<Unit> {
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<String> {
val result = authorizationStorageDataSource.get().login.firstOrNull()
return if (result == null) {
Result.failure(Exception("Not authorize"))
} else {
Result.success(result)
}
}
}

View File

@ -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
)
}

View File

@ -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<UserInfoDto> {
return kotlin.runCatching { api.getInfo(username = username) }
}
suspend fun openByQr(username: String, content: String): Result<Unit> {
return kotlin.runCatching {
api.openByQr(
username = username,
content = OpenQrDto(value = content)
)
}
}
}

View File

@ -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<Unit>
}

View File

@ -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<Unit> {
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) }
)
}
}

View File

@ -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<Preferences> by preferencesDataStore(name = NAME)
val login: Flow<String?> = 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")
}
}

View File

@ -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() }
}
}
}

View File

@ -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<String> {
return repo.getLogin()
}
}

View File

@ -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
}
}

View File

@ -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<Unit> {
return repo.login(username = login)
}
}

View File

@ -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()
}
}

View File

@ -0,0 +1,8 @@
package ru.myitschool.work.domain.auth.repo
interface AuthorizationRepository {
suspend fun login(username: String): Result<Unit>
suspend fun logout()
suspend fun getLogin(): Result<String>
}

View File

@ -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<UserInfoEntity> {
return getLoginUseCase().fold(
onSuccess = { username -> repo.getInfo(username = username) },
onFailure = { error -> Result.failure(error) }
)
}
}

View File

@ -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<Unit> {
return getLoginUseCase().fold(
onSuccess = { username -> repo.openByQr(username = username, content = content) },
onFailure = { error -> Result.failure(error) }
)
}
}

View File

@ -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,
)

View File

@ -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<UserInfoEntity>
suspend fun openByQr(username: String, content: String) : Result<Unit>
}

View File

@ -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<SplashFragment, SplashDestination>()
fragment<LoginFragment, LoginDestination>()
fragment<ProfileFragment, ProfileDestination>()
fragment<QrResultFragment, QrResultDestination>()
fragment<QrScanFragment, QrScanDestination>()
}
}
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()
}
}

View File

@ -0,0 +1,6 @@
package ru.myitschool.work.ui.login
import kotlinx.serialization.Serializable
@Serializable
data object LoginDestination

View File

@ -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<LoginDestination> { 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()
}
}

View File

@ -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<CheckValidLoginUseCase>,
private val loginUseCase: Lazy<LoginUseCase>,
) : ViewModel() {
private val _action = MutablePublishFlow<Action>()
val action = _action.asSharedFlow()
private val _state = MutableStateFlow<State>(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
)
}
}

View File

@ -0,0 +1,6 @@
package ru.myitschool.work.ui.profile
import kotlinx.serialization.Serializable
@Serializable
data object ProfileDestination

View File

@ -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<ProfileDestination> { 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()
}
}

View File

@ -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<GetUserInfoUseCase>,
private val logoutUseCase: Lazy<LogoutUseCase>,
) : ViewModel() {
private val _action = MutablePublishFlow<Action>()
val action = _action.asSharedFlow()
private val _state = MutableStateFlow<State>(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
}
}

View File

@ -0,0 +1,6 @@
package ru.myitschool.work.ui.qr.result
import kotlinx.serialization.Serializable
@Serializable
object QrResultDestination

View File

@ -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<Bundle>(QrScanDestination.REQUEST_KEY)
?.observe(viewLifecycleOwner) { result ->
viewModel.setScanResult(QrScanDestination.getDataIfExist(result))
}
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
}

View File

@ -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<OpenByQrUseCase>,
) : ViewModel() {
private val _action = MutablePublishFlow<Action>()
val action = _action.asSharedFlow()
private val _state = MutableStateFlow<State>(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
}
}

View File

@ -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
)
}
}

View File

@ -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
}
}
}

View File

@ -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<Action>()
val action = _action.asSharedFlow()
private val _state = MutableStateFlow<State>(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
}
}

View File

@ -0,0 +1,6 @@
package ru.myitschool.work.ui.splash
import kotlinx.serialization.Serializable
@Serializable
data object SplashDestination

View File

@ -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<SplashDestination> { inclusive = true }
}
}
}
}
}
override fun onDestroyView() {
_binding = null
super.onDestroyView()
}
}

View File

@ -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<IsLoginUseCase>,
) : ViewModel() {
private val _action = MutablePublishFlow<Action>()
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
}
}

View File

@ -0,0 +1,10 @@
package ru.myitschool.work.utils
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
fun <T> MutablePublishFlow() = MutableSharedFlow<T>(
replay = 0,
extraBufferCapacity = 1,
BufferOverflow.DROP_OLDEST
)

View File

@ -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 <T> Flow<T>.collectWhenStarted(
fragment: Fragment,
crossinline collector: (T) -> Unit
) {
fragment.viewLifecycleOwner.lifecycleScope.launch {
flowWithLifecycle(fragment.viewLifecycleOwner.lifecycle).collect { value ->
collector.invoke(value)
}
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z"/>
</vector>

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17,7l-1.41,1.41L18.17,11H8v2h10.17l-2.58,2.58L17,17l5,-5zM4,5h8V3H4c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h8v-2H4V5z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M21.9,21.9l-8.49,-8.49l0,0L3.59,3.59l0,0L2.1,2.1L0.69,3.51L3,5.83V19c0,1.1 0.9,2 2,2h13.17l2.31,2.31L21.9,21.9zM5,18l3.5,-4.5l2.5,3.01L12.17,15l3,3H5zM21,18.17L5.83,3H19c1.1,0 2,0.9 2,2V18.17z"/>
</vector>

View File

@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M3,11h8V3H3V11zM5,5h4v4H5V5z"/>
<path android:fillColor="@android:color/white" android:pathData="M3,21h8v-8H3V21zM5,15h4v4H5V15z"/>
<path android:fillColor="@android:color/white" android:pathData="M13,3v8h8V3H13zM19,9h-4V5h4V9z"/>
<path android:fillColor="@android:color/white" android:pathData="M19,19h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M13,13h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M15,15h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M13,17h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M15,19h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M17,17h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M17,13h2v2h-2z"/>
<path android:fillColor="@android:color/white" android:pathData="M19,15h2v2h-2z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.fragment.app.FragmentContainerView
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:defaultNavHost="true" />
</FrameLayout>

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.constraintlayout.helper.widget.Flow
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
app:constraint_referenced_ids="username,login"
app:flow_verticalGap="32dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/username"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="@string/login_username_hint"
android:inputType="text"
android:maxLines="1"
app:layout_constraintWidth_max="400dp"
app:layout_constraintWidth_percent="0.8" />
<Button
android:id="@+id/login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/login_button" />
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/username" />
<TextView
android:id="@+id/error"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/login"
tools:text="Something wrong. Try again" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,115 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="16dp">
<androidx.constraintlayout.widget.Group
android:id="@+id/showState"
android:layout_width="0dp"
android:layout_height="0dp"
app:constraint_referenced_ids="logout,fullname,photo,position,lastEntry,scan" />
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ImageView
android:id="@+id/photo"
android:layout_width="64dp"
android:layout_height="64dp"
android:contentDescription="@null"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />
<TextView
android:id="@+id/fullname"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:textAppearance="@style/TextAppearance.AppCompat.Headline"
app:layout_constraintEnd_toStartOf="@id/buttons"
app:layout_constraintStart_toEndOf="@id/photo"
app:layout_constraintTop_toTopOf="parent"
tools:text="@tools:sample/full_names" />
<TextView
android:id="@+id/position"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
app:layout_constraintEnd_toStartOf="@id/lastEntry"
app:layout_constraintStart_toEndOf="@id/photo"
app:layout_constraintTop_toBottomOf="@id/fullname"
tools:text="@tools:sample/cities" />
<TextView
android:id="@+id/lastEntry"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
app:layout_constraintEnd_toStartOf="@id/buttons"
app:layout_constraintStart_toEndOf="@id/position"
app:layout_constraintTop_toTopOf="@id/position"
tools:text="@tools:sample/date/hhmm" />
<TextView
android:id="@+id/error"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:textAlignment="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Something wrong. Try later" />
<androidx.constraintlayout.helper.widget.Flow
android:id="@+id/buttons"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:constraint_referenced_ids="logout,refresh"
app:flow_horizontalGap="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/logout"
style="?attr/floatingActionButtonSmallStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/profile_refresh_button"
android:src="@drawable/ic_logout"
app:elevation="0dp" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/refresh"
style="?attr/floatingActionButtonSmallStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/profile_refresh_button"
android:src="@drawable/ic_refresh"
app:elevation="0dp" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/scan"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/profile_scan_button"
android:src="@drawable/ic_qr_code"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:useCompatPadding="true" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/close"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="gone" />
<TextView
android:id="@+id/status"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="16dp"
android:gravity="center"
android:textAlignment="center"
android:textAppearance="@style/TextAppearance.MaterialComponents.Headline3"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/close"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="This is test text with result scanning"
tools:visibility="visible" />
<Button
android:id="@+id/close"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/close_button"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.camera.view.PreviewView
android:id="@+id/viewFinder"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:contentDescription="@string/close_button"
android:src="@drawable/ic_close"
app:elevation="0dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto">
<ProgressBar
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<item name="rootContainer" type="id"/>
</resources>

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name">NTO Pass</string>
</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="login_username_hint">Username</string>
<string name="login_button">Login</string>
<string name="login_error">Something wrong</string>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="profile_refresh_button">Refresh</string>
<string name="profile_scan_button">Scan QR</string>
</resources>

View File

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

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="qr_result_status_success">Success</string>
<string name="qr_result_status_cancel">Operation was cancelled</string>
<string name="qr_result_status_error">Something wrong</string>
</resources>

View File

@ -0,0 +1,16 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme. -->
<style name="Theme.Default" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/purple_500</item>
<item name="colorPrimaryVariant">@color/purple_700</item>
<item name="colorOnPrimary">@color/white</item>
<!-- Secondary brand color. -->
<item name="colorSecondary">@color/teal_200</item>
<item name="colorSecondaryVariant">@color/teal_700</item>
<item name="colorOnSecondary">@color/black</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
<!-- Customize your theme here. -->
</style>
</resources>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older that API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>