diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8c899db..a28d464 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,6 +1,9 @@ plugins { kotlinAndroid androidApplication + jetbrainsKotlinSerialization version Version.Kotlin.language + kotlinAnnotationProcessor + id("com.google.dagger.hilt.android").version("2.51.1") } val packageName = "ru.myitschool.work" @@ -34,4 +37,33 @@ android { 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/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9ee5c40..795af31 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,12 @@ + + + + + tools:targetApi="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..5af1798 --- /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/ui/RootActivity.kt b/app/src/main/java/ru/myitschool/work/ui/RootActivity.kt new file mode 100644 index 0000000..88a796a --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/RootActivity.kt @@ -0,0 +1,56 @@ +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.qr.scan.QrScanDestination +import ru.myitschool.work.ui.qr.scan.QrScanFragment + +// НЕ ИЗМЕНЯЙТЕ НАЗВАНИЕ КЛАССА! +@AndroidEntryPoint +class RootActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_root) + + val navHostFragment = supportFragmentManager + .findFragmentById(R.id.nav_host_fragment) as NavHostFragment? + + if (navHostFragment != null) { + val navController = navHostFragment.navController + navController.graph = navController.createGraph( + startDestination = LoginDestination + ) { + fragment() + fragment() + } + } + + 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..02842ce --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginFragment.kt @@ -0,0 +1,36 @@ +package ru.myitschool.work.ui.login + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import dagger.hilt.android.AndroidEntryPoint +import ru.myitschool.work.R +import ru.myitschool.work.databinding.FragmentLoginBinding +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) + subscribe() + } + + private fun subscribe() { + viewModel.state.collectWhenStarted(this) { state -> + binding.loading.visibleOrGone(state) + } + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.kt new file mode 100644 index 0000000..3a53d6c --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/login/LoginViewModel.kt @@ -0,0 +1,17 @@ +package ru.myitschool.work.ui.login + +import android.content.Context +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class LoginViewModel @Inject constructor( + @ApplicationContext private val context: Context, +) : ViewModel() { + private val _state = MutableStateFlow(true) + val state = _state.asStateFlow() +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanDestination.kt b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanDestination.kt new file mode 100644 index 0000000..7e34b28 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanDestination.kt @@ -0,0 +1,30 @@ +package ru.myitschool.work.ui.qr.scan + +import android.os.Bundle +import androidx.core.os.bundleOf +import kotlinx.serialization.Serializable + +// НЕ ИЗМЕНЯЙТЕ ЭТОТ ФАЙЛ. В ТЕСТАХ ОН БУДЕМ ВОЗВРАЩЁН В ИСХОДНОЕ СОСТОЯНИЕ +@Serializable +data object QrScanDestination { + const val REQUEST_KEY = "qr_result" + private const val KEY_QR_DATA = "key_qr" + + fun newInstance(): QrScanFragment { + return QrScanFragment() + } + + fun getDataIfExist(bundle: Bundle): String? { + return if (bundle.containsKey(KEY_QR_DATA)) { + bundle.getString(KEY_QR_DATA) + } else { + null + } + } + + internal fun packToBundle(data: String): Bundle { + return bundleOf( + KEY_QR_DATA to data + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanFragment.kt b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanFragment.kt new file mode 100644 index 0000000..a9ddaab --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanFragment.kt @@ -0,0 +1,139 @@ +package ru.myitschool.work.ui.qr.scan + +import android.os.Bundle +import android.view.View +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.ImageAnalysis +import androidx.camera.mlkit.vision.MlKitAnalyzer +import androidx.camera.view.LifecycleCameraController +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.navigation.NavController +import androidx.navigation.fragment.findNavController +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import ru.myitschool.work.R +import ru.myitschool.work.databinding.FragmentQrScanBinding +import ru.myitschool.work.utils.collectWhenStarted +import ru.myitschool.work.utils.visibleOrGone + +// НЕ ИЗМЕНЯЙТЕ ЭТОТ ФАЙЛ. В ТЕСТАХ ОН БУДЕМ ВОЗВРАЩЁН В ИСХОДНОЕ СОСТОЯНИЕ +class QrScanFragment : Fragment(R.layout.fragment_qr_scan) { + private var _binding: FragmentQrScanBinding? = null + private val binding: FragmentQrScanBinding get() = _binding!! + + private var barcodeScanner: BarcodeScanner? = null + private var isCameraInit: Boolean = false + private val permissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestPermission() + ) { isGranted -> viewModel.onPermissionResult(isGranted) } + + private val viewModel: QrScanViewModel by viewModels() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + _binding = FragmentQrScanBinding.bind(view) + sendResult(bundleOf()) + subscribe() + initCallback() + } + + private fun initCallback() { + binding.close.setOnClickListener { viewModel.close() } + } + + private fun subscribe() { + viewModel.state.collectWhenStarted(this) { state -> + binding.loading.visibleOrGone(state is QrScanViewModel.State.Loading) + binding.viewFinder.visibleOrGone(state is QrScanViewModel.State.Scan) + if (!isCameraInit && state is QrScanViewModel.State.Scan) { + startCamera() + isCameraInit = true + } + } + + viewModel.action.collectWhenStarted(this) { action -> + when (action) { + is QrScanViewModel.Action.RequestPermission -> requestPermission(action.permission) + is QrScanViewModel.Action.CloseWithCancel -> { + goBack() + } + is QrScanViewModel.Action.CloseWithResult -> { + sendResult(QrScanDestination.packToBundle(action.result)) + goBack() + } + } + } + } + + private fun requestPermission(permission: String) { + permissionLauncher.launch(permission) + } + + private fun startCamera() { + val context = requireContext() + val cameraController = LifecycleCameraController(context) + val previewView: PreviewView = binding.viewFinder + val executor = ContextCompat.getMainExecutor(context) + + val options = BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + val barcodeScanner = BarcodeScanning.getClient(options) + this.barcodeScanner = barcodeScanner + + cameraController.setImageAnalysisAnalyzer( + executor, + MlKitAnalyzer( + listOf(barcodeScanner), + ImageAnalysis.COORDINATE_SYSTEM_VIEW_REFERENCED, + executor + ) { result -> + result?.getValue(barcodeScanner)?.firstOrNull()?.let { value -> + viewModel.findBarcode(value) + + } + } + ) + + cameraController.bindToLifecycle(this) + previewView.controller = cameraController + } + + override fun onDestroyView() { + barcodeScanner?.close() + barcodeScanner = null + _binding = null + super.onDestroyView() + } + + private fun goBack() { + findNavControllerOrNull()?.popBackStack() + ?: requireActivity().onBackPressedDispatcher.onBackPressed() + } + + private fun sendResult(bundle: Bundle) { + setFragmentResult( + QrScanDestination.REQUEST_KEY, + bundle + ) + findNavControllerOrNull() + ?.previousBackStackEntry + ?.savedStateHandle + ?.set(QrScanDestination.REQUEST_KEY, bundle) + } + + private fun findNavControllerOrNull(): NavController? { + return try { + findNavController() + } catch (_: Throwable) { + null + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanViewModel.kt b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanViewModel.kt new file mode 100644 index 0000000..14565ab --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/ui/qr/scan/QrScanViewModel.kt @@ -0,0 +1,93 @@ +package ru.myitschool.work.ui.qr.scan + +import android.Manifest +import android.app.Application +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.google.mlkit.vision.barcode.common.Barcode +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import ru.myitschool.work.utils.MutablePublishFlow + +// НЕ ИЗМЕНЯЙТЕ ЭТОТ ФАЙЛ. В ТЕСТАХ ОН БУДЕМ ВОЗВРАЩЁН В ИСХОДНОЕ СОСТОЯНИЕ +class QrScanViewModel( + application: Application +) : AndroidViewModel(application) { + + private val _action = MutablePublishFlow() + val action = _action.asSharedFlow() + + private val _state = MutableStateFlow(initialState) + val state = _state.asStateFlow() + + init { + checkPermission() + } + + fun onPermissionResult(isGranted: Boolean) { + viewModelScope.launch { + if (isGranted) { + _state.update { State.Scan } + } else { + _action.emit(Action.CloseWithCancel) + } + } + } + + private fun checkPermission() { + viewModelScope.launch { + val isPermissionGranted = ContextCompat.checkSelfPermission( + getApplication(), + CAMERA_PERMISSION + ) == PackageManager.PERMISSION_GRANTED + if (isPermissionGranted) { + _state.update { State.Scan } + } else { + delay(1000) + _action.emit(Action.RequestPermission(CAMERA_PERMISSION)) + } + } + } + + fun findBarcode(barcode: Barcode) { + viewModelScope.launch { + barcode.rawValue?.let { value -> + _action.emit(Action.CloseWithResult(value)) + } + } + } + + fun close() { + viewModelScope.launch { + _action.emit(Action.CloseWithCancel) + } + } + + sealed interface State { + data object Loading : State + + data object Scan : State + } + + sealed interface Action { + data class RequestPermission( + val permission: String + ) : Action + data object CloseWithCancel : Action + data class CloseWithResult( + val result: String + ) : Action + } + + private companion object { + val initialState = State.Loading + + const val CAMERA_PERMISSION = Manifest.permission.CAMERA + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/FlowExtensions.kt b/app/src/main/java/ru/myitschool/work/utils/FlowExtensions.kt new file mode 100644 index 0000000..87bccc2 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/FlowExtensions.kt @@ -0,0 +1,10 @@ +package ru.myitschool.work.utils + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow + +fun MutablePublishFlow() = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + BufferOverflow.DROP_OLDEST +) \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/FragmentExtesions.kt b/app/src/main/java/ru/myitschool/work/utils/FragmentExtesions.kt new file mode 100644 index 0000000..8c99ef3 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/FragmentExtesions.kt @@ -0,0 +1,18 @@ +package ru.myitschool.work.utils + +import androidx.fragment.app.Fragment +import androidx.lifecycle.flowWithLifecycle +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch + +inline fun Flow.collectWhenStarted( + fragment: Fragment, + crossinline collector: (T) -> Unit +) { + fragment.viewLifecycleOwner.lifecycleScope.launch { + flowWithLifecycle(fragment.viewLifecycleOwner.lifecycle).collect { value -> + collector.invoke(value) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/TextChangedListener.kt b/app/src/main/java/ru/myitschool/work/utils/TextChangedListener.kt new file mode 100644 index 0000000..c81147d --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/TextChangedListener.kt @@ -0,0 +1,12 @@ +package ru.myitschool.work.utils + +import android.text.Editable +import android.text.TextWatcher + +open class TextChangedListener: TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + + override fun afterTextChanged(s: Editable?) = Unit +} \ No newline at end of file diff --git a/app/src/main/java/ru/myitschool/work/utils/ViewExtensions.kt b/app/src/main/java/ru/myitschool/work/utils/ViewExtensions.kt new file mode 100644 index 0000000..5c38f67 --- /dev/null +++ b/app/src/main/java/ru/myitschool/work/utils/ViewExtensions.kt @@ -0,0 +1,7 @@ +package ru.myitschool.work.utils + +import android.view.View + +fun View.visibleOrGone(isVisible: Boolean) { + this.visibility = if (isVisible) View.VISIBLE else View.GONE +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_close.xml b/app/src/main/res/drawable/ic_close.xml new file mode 100644 index 0000000..f8ca0c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_close.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_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..7f3cd66 --- /dev/null +++ b/app/src/main/res/layout/fragment_login.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_qr_scan.xml b/app/src/main/res/layout/fragment_qr_scan.xml new file mode 100644 index 0000000..a52eb71 --- /dev/null +++ b/app/src/main/res/layout/fragment_qr_scan.xml @@ -0,0 +1,35 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml new file mode 100644 index 0000000..ce65075 --- /dev/null +++ b/app/src/main/res/values/ids.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96034ac..b183019 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,3 @@ - Work + NTO Pass \ No newline at end of file diff --git a/app/src/main/res/values/strings_qr.xml b/app/src/main/res/values/strings_qr.xml new file mode 100644 index 0000000..ce50067 --- /dev/null +++ b/app/src/main/res/values/strings_qr.xml @@ -0,0 +1,4 @@ + + + Close + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 64d8748..f83a038 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,4 +2,5 @@ plugins { androidApplication version Version.agp apply false kotlinJvm version Version.Kotlin.language apply false + id("com.google.dagger.hilt.android") version "2.51.1" apply false } \ No newline at end of file diff --git a/buildSrc b/buildSrc index d959060..ec48d5f 160000 --- a/buildSrc +++ b/buildSrc @@ -1 +1 @@ -Subproject commit d9590600045906edeb852eaa3f0b9bf7d1875813 +Subproject commit ec48d5f6b8c45e8058303282e9ec1c1d0ed02989