Added start logic for visits pagination, changed visits logic, changed Dto objects, added password in login, bug fixes,

This commit is contained in:
Izlydov 2025-02-19 13:00:08 +03:00
parent 12c6b6e1c2
commit 961c37d265
17 changed files with 334 additions and 23 deletions

View File

@ -39,7 +39,7 @@ android {
} }
dependencies { dependencies {
implementation("androidx.paging:paging-runtime:3.3.2")
val cameraX = "1.3.4" val cameraX = "1.3.4"
implementation("androidx.camera:camera-core:$cameraX") implementation("androidx.camera:camera-core:$cameraX")
implementation("androidx.camera:camera-camera2:$cameraX") implementation("androidx.camera:camera-camera2:$cameraX")

View File

@ -6,5 +6,5 @@ data class UserEntity(
val name: String, val name: String,
val photo: String, val photo: String,
val position: String, val position: String,
val lastVisit: String, // val lastVisit: String,
) )

View File

@ -10,7 +10,7 @@ class UserMapper {
login = userDTO.login, login = userDTO.login,
position = userDTO.position, position = userDTO.position,
name = userDTO.name, name = userDTO.name,
lastVisit = userDTO.lastVisit, // lastVisit = userDTO.lastVisit,
photo = userDTO.photo photo = userDTO.photo
) )
return userEntity return userEntity
@ -22,7 +22,7 @@ class UserMapper {
name = userEntity.name, name = userEntity.name,
photo = userEntity.photo, photo = userEntity.photo,
position = userEntity.position, position = userEntity.position,
lastVisit = userEntity.lastVisit, // lastVisit = userEntity.lastVisit,
) )
return userDto return userDto
} }

View File

@ -3,7 +3,9 @@ package com.displaynone.acss.components.auth.models.user
import android.content.Context import android.content.Context
import com.displaynone.acss.components.auth.internal_utils.AuthTokenManager import com.displaynone.acss.components.auth.internal_utils.AuthTokenManager
import com.displaynone.acss.components.auth.models.user.repository.UserRepository import com.displaynone.acss.components.auth.models.user.repository.UserRepository
import com.displaynone.acss.components.auth.models.user.repository.dto.LastVisitsDto
import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO
import com.displaynone.acss.components.auth.models.user.repository.dto.VisitDto
class UserServiceST( class UserServiceST(
@ -28,13 +30,34 @@ class UserServiceST(
return instance ?: throw RuntimeException("null instance") return instance ?: throw RuntimeException("null instance")
} }
} }
suspend fun login(login: String): Result<Unit>{ suspend fun login(login: String, password:String): Result<Unit>{
return runCatching { return runCatching {
userRepository.login(login = login).getOrThrow().let { data -> userRepository.login(login = login, password).getOrThrow().let { data ->
tokenManager.saveTokens(data) tokenManager.saveTokens(data)
} }
} }
} }
suspend fun getMyLastVisits(pageNum: Int,
pageSize: Int): Result<List<VisitDto>> {
if (!tokenManager.hasTokens()) {
throw RuntimeException("access token is null")
}
return userRepository.getMyLastVisits(
pageNum = pageNum,
pageSize = pageSize,
token = tokenManager.authTokenPair!!.accessToken
).map { pagingDto -> pagingDto.content
}
}
suspend fun getLastVisitsByLogin(pageNum: Int,
pageSize: Int,
login: String): Result<List<VisitDto>> {
if (!tokenManager.hasTokens()) {
throw RuntimeException("access token is null")
}
return userRepository.getLastVisitsByLogin(pageNum, pageSize, tokenManager.authTokenPair!!.accessToken, login).map { pagingDto -> pagingDto.content }
}
fun logout(){ fun logout(){
tokenManager.clear() tokenManager.clear()
} }

View File

@ -2,10 +2,12 @@ package com.displaynone.acss.components.auth.models.user.repository
import android.util.Log import android.util.Log
import com.displaynone.acss.components.auth.models.AuthTokenPair import com.displaynone.acss.components.auth.models.AuthTokenPair
import com.displaynone.acss.components.auth.models.user.repository.dto.LastVisitsDto
import com.displaynone.acss.config.Constants.serverUrl import com.displaynone.acss.config.Constants.serverUrl
import com.displaynone.acss.config.Network import com.displaynone.acss.config.Network
import com.displaynone.acss.components.auth.models.user.repository.dto.RegisterUserDto import com.displaynone.acss.components.auth.models.user.repository.dto.RegisterUserDto
import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO
import com.displaynone.acss.components.auth.models.user.repository.dto.UserLoginDto
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.headers import io.ktor.client.request.headers
@ -33,13 +35,14 @@ class UserRepository(
result.status != HttpStatusCode.OK result.status != HttpStatusCode.OK
} }
} }
suspend fun login(login: String): Result<AuthTokenPair> = withContext(Dispatchers.IO) { suspend fun login(login: String, password: String): Result<AuthTokenPair> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val result = Network.client.post("$serverUrl/api/auth/login") { val result = Network.client.post("$serverUrl/api/auth/login") {
headers { headers {
append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) append(HttpHeaders.ContentType, ContentType.Application.Json.toString())
} }
setBody("""{ "login": "$login" }""") contentType(ContentType.Application.Json)
setBody(UserLoginDto(login, password))
} }
if (result.status != HttpStatusCode.OK) { if (result.status != HttpStatusCode.OK) {
error("Status ${result.status}: ${result.body<String>()}") error("Status ${result.status}: ${result.body<String>()}")
@ -99,6 +102,57 @@ class UserRepository(
result.body() result.body()
} }
} }
suspend fun getMyLastVisits(pageNum: Int,
pageSize: Int,
token: String): Result<LastVisitsDto> = withContext(Dispatchers.IO){
runCatching {
val result = Network.client.get("$serverUrl/api/acs/visits/me?page=$pageNum&size=$pageSize") {
headers {
append(HttpHeaders.Authorization, "Bearer $token")
}
}
Log.d("UserRepository", result.bodyAsText())
if (result.status != HttpStatusCode.OK) {
error("Status ${result.status}: ${result.body<String>()}")
}
result.body()
}
}
suspend fun getLastVisitsByLogin(pageNum: Int,
pageSize: Int,
token: String, login: String): Result<LastVisitsDto> = withContext(Dispatchers.IO){
runCatching {
val encodedLogin = login.encodeURLPath()
val result = Network.client.get("$serverUrl/api/acs/login/${encodedLogin}?page=$pageNum&size=$pageSize") {
headers {
append(HttpHeaders.Authorization, "Bearer $token")
}
setBody("""{ "login": "$encodedLogin" }""")
}
if (result.status != HttpStatusCode.OK) {
error("Status ${result.status}: ${result.body<String>()}")
}
Log.d("UserRepository", result.bodyAsText())
result.body()
}
}
suspend fun getAllLastVisitsAsAdmin(token: String, login: String): Result<UserDTO> = withContext(Dispatchers.IO){
runCatching {
val encodedLogin = login.encodeURLPath()
val result = Network.client.get("$serverUrl/api/users/login/$encodedLogin") {
headers {
append(HttpHeaders.Authorization, "Bearer $token")
}
setBody("""{ "code": "$encodedLogin" }""")
}
if (result.status != HttpStatusCode.OK) {
error("Status ${result.status}: ${result.body<String>()}")
}
Log.d("UserRepository", result.bodyAsText())
result.body()
}
}
suspend fun openDoor(token: String, code: Long): Result<Unit> = withContext(Dispatchers.IO) { suspend fun openDoor(token: String, code: Long): Result<Unit> = withContext(Dispatchers.IO) {
runCatching { runCatching {
val result = Network.client.patch("$serverUrl/api/open") { val result = Network.client.patch("$serverUrl/api/open") {

View File

@ -0,0 +1,55 @@
package com.displaynone.acss.components.auth.models.user.repository
import android.graphics.Color
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import com.bumptech.glide.Glide
import com.displaynone.acss.components.auth.models.user.repository.dto.VisitDto
import com.displaynone.acss.databinding.ItemScannerViewBinding
class VisitAdapter: PagingDataAdapter<VisitDto, VisitAdapter.ViewHolder>(VisitDiff) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
return ViewHolder(
ItemScannerViewBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position) ?:
VisitDto(
id = -1,
userId = -1,
gateId = -1,
createdAt = "Loading...",
))
}
class ViewHolder(
private val binding: ItemScannerViewBinding
): RecyclerView.ViewHolder(binding.root) {
fun bind(item: VisitDto){
binding.scanTime.text = item.createdAt
binding.scannerId.text = item.gateId.toString()
}
}
object VisitDiff : DiffUtil.ItemCallback<VisitDto>() {
override fun areItemsTheSame(oldItem: VisitDto, newItem: VisitDto): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: VisitDto, newItem: VisitDto): Boolean {
return oldItem == newItem
}
}
}

View File

@ -0,0 +1,35 @@
package com.displaynone.acss.components.auth.models.user.repository
import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.displaynone.acss.components.auth.models.user.repository.dto.VisitDto
class VisitListPagingSource(
private val request: suspend (pageNum: Int, pageSize: Int) -> Result<List<VisitDto>>
) : PagingSource<Int, VisitDto>() {
override fun getRefreshKey(state: PagingState<Int, VisitDto>): Int? {
return state.anchorPosition?.let {
state.closestPageToPosition(it)?.prevKey?.plus(1)
?: state.closestPageToPosition(it)?.nextKey?.minus(1)
}
}
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, VisitDto> {
val pageNum = params.key ?: 0
return request.invoke(
pageNum,
params.loadSize
).fold(
onSuccess = { value ->
LoadResult.Page(
data = value,
prevKey = (pageNum - 1).takeIf { it >= 0 },
nextKey = (pageNum + 1).takeIf { value.size == params.loadSize }
)
},
onFailure = { error ->
LoadResult.Error(error)
}
)
}
}

View File

@ -0,0 +1,12 @@
package com.displaynone.acss.components.auth.models.user.repository.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LastVisitsDto(
@SerialName("content")
val content: List<VisitDto>
) {
}

View File

@ -21,6 +21,6 @@ data class UserDTO (
@SerialName("position") @SerialName("position")
val position: String, val position: String,
@SerialName("lastVisit") // @SerialName("lastVisit")
val lastVisit: String, // val lastVisit: String,
) : java.io.Serializable ) : java.io.Serializable

View File

@ -0,0 +1,13 @@
package com.displaynone.acss.components.auth.models.user.repository.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UserLoginDto(
@SerialName("login")
val login: String,
@SerialName("password")
val password: String,
) {
}

View File

@ -0,0 +1,17 @@
package com.displaynone.acss.components.auth.models.user.repository.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class VisitDto(
@SerialName("id")
val id: Long,
@SerialName("userId")
val userId: Long,
@SerialName("gateId")
val gateId: Long,
@SerialName("createdAt")
val createdAt: String,
) {
}

View File

@ -40,9 +40,14 @@ class AdminFragment : Fragment(R.layout.fragment_admin) {
val userDto = state.item val userDto = state.item
val bundle = Bundle().apply{ val bundle = Bundle().apply{
putSerializable("user", userDto) putSerializable("user", userDto)
putBoolean("isMe", false)
} }
navigateTo(view, R.id.action_adminFragment_to_profileFragment, bundle) navigateTo(view, R.id.action_adminFragment_to_profileFragment, bundle)
} }
if (state is AdminViewModel.State.Error){
val errorMessage = state.errorMessage
binding.loginSearch.setError(errorMessage)
}
} }
} }
} }

View File

@ -5,7 +5,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.displaynone.acss.components.auth.models.user.UserServiceST import com.displaynone.acss.components.auth.models.user.UserServiceST
import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO
import com.displaynone.acss.ui.profile.ProfileViewModel.State
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -20,7 +19,8 @@ class AdminViewModel: ViewModel() {
_state.emit(State.Show(item = dto)) _state.emit(State.Show(item = dto))
}, },
onFailure = { error -> onFailure = { error ->
error.message?.let { error(it) } // error.message?.let { error(it) }
_state.emit(State.Error(error.message.toString()))
Log.e("AdminViewModel", error.message.toString()) } Log.e("AdminViewModel", error.message.toString()) }
) )
} }
@ -30,6 +30,9 @@ class AdminViewModel: ViewModel() {
data class Show( data class Show(
val item: UserDTO val item: UserDTO
): State ): State
data class Error(
val errorMessage: String
): State
data object Loading : State data object Loading : State
} }
} }

View File

@ -59,6 +59,7 @@ class AuthFragment: Fragment(R.layout.fragment_auth) {
val username = s.toString() val username = s.toString()
val valid = isUsernameValid(username) val valid = isUsernameValid(username)
binding.hint.visibility = if(valid) View.INVISIBLE else View.VISIBLE binding.hint.visibility = if(valid) View.INVISIBLE else View.VISIBLE
binding.next.isEnabled = valid binding.next.isEnabled = valid
val color = if (valid) R.color.primary else R.color.secondary val color = if (valid) R.color.primary else R.color.secondary
@ -67,6 +68,35 @@ class AuthFragment: Fragment(R.layout.fragment_auth) {
override fun afterTextChanged(s: Editable?) {} override fun afterTextChanged(s: Editable?) {}
}) })
// binding.password.addTextChangedListener(object : TextWatcher {
// override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
// @SuppressLint("ResourceAsColor")
// override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
// binding.error.visibility = View.GONE
// val username = s.toString()
// val valid = isPasswordValid(username)
//
// binding.hint.visibility = if(valid) View.INVISIBLE else View.VISIBLE
// if (!valid){
// val errorMessage = getPasswordValidError(s.toString())
// binding.password.setError(errorMessage)
// }
// binding.next.isEnabled = valid
// val color = if (valid) R.color.primary else R.color.secondary
// binding.next.backgroundTintList = ContextCompat.getColorStateList(requireContext(), color)
// }
//
// override fun afterTextChanged(s: Editable?) {}
// })
}
private fun getPasswordValidError(password: String): String {
if (password.length < 8){ return "LenError" }
val letterRegex = Regex("^(?=.*[A-Z]).+$")
if(!letterRegex.matches(password)){ return "UpperCaseError"}
val digitRegex = Regex("^(?=.*\\\\d).+$")
if(!digitRegex.matches(password)){ return "DigitCaseError"}
return "NoErrors"
} }
private fun isUsernameValid(username: String): Boolean { private fun isUsernameValid(username: String): Boolean {
@ -76,6 +106,10 @@ class AuthFragment: Fragment(R.layout.fragment_auth) {
!username[0].isDigit() && !username[0].isDigit() &&
alf.matches(username) alf.matches(username)
} }
private fun isPasswordValid(password: String): Boolean {
return password.isNotEmpty() &&
password.length >= 8
}
// private fun subscribe() { // private fun subscribe() {
// viewModel.state.collectWhenStarted(this) { state -> // viewModel.state.collectWhenStarted(this) { state ->
// binding.login.setOnClickListener(this::onLoginButtonClicked) // binding.login.setOnClickListener(this::onLoginButtonClicked)
@ -84,8 +118,9 @@ class AuthFragment: Fragment(R.layout.fragment_auth) {
private fun onLoginButtonClicked(view: View) { private fun onLoginButtonClicked(view: View) {
val login = binding.login.text.toString() val login = binding.login.text.toString()
val password = binding.password.text.toString()
if (login.isEmpty()) return if (login.isEmpty()) return
viewModel.login(login) viewModel.login(login, password)
} }

View File

@ -22,10 +22,10 @@ class AuthViewModel(): ViewModel() {
private val _errorState = MutableStateFlow<String?>(null) private val _errorState = MutableStateFlow<String?>(null)
val errorState: StateFlow<String?> = _errorState.asStateFlow() val errorState: StateFlow<String?> = _errorState.asStateFlow()
fun login(login: String){ fun login(login: String, password:String){
viewModelScope.launch { viewModelScope.launch {
try { try {
UserServiceST.getInstance().login(login).fold( UserServiceST.getInstance().login(login, password).fold(
onSuccess = { openProfile() }, onSuccess = { openProfile() },
onFailure = { error -> onFailure = { error ->
Log.e("AuthViewModel", "Login failed: ${error.message ?: "Unknown error"}") Log.e("AuthViewModel", "Login failed: ${error.message ?: "Unknown error"}")

View File

@ -10,6 +10,7 @@ import androidx.fragment.app.viewModels
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
import com.displaynone.acss.R import com.displaynone.acss.R
import com.displaynone.acss.components.auth.models.user.repository.VisitAdapter
import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO
import com.displaynone.acss.databinding.FragmentProfileBinding import com.displaynone.acss.databinding.FragmentProfileBinding
import com.displaynone.acss.ui.profile.ProfileViewModel.Action import com.displaynone.acss.ui.profile.ProfileViewModel.Action
@ -27,31 +28,58 @@ class ProfileFragment: Fragment(R.layout.fragment_profile) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
_binding = FragmentProfileBinding.bind(view) _binding = FragmentProfileBinding.bind(view)
//
binding.swipeRefresh.setOnRefreshListener { // binding.swipeRefresh.setOnRefreshListener {
refreshData() // if (getIsMe()){
} // refreshData()
// } else{
// showData(getUserDto()!!)
// }
// }
binding.logout.setOnClickListener{ binding.logout.setOnClickListener{
logout() logout()
} }
binding.scan.setOnClickListener{ binding.scan.setOnClickListener{
viewModel.openScan() viewModel.openScan()
} }
val user = getUserDto() val adapter = VisitAdapter()
if (user == null) { binding.recyclerViewLogs.adapter = adapter
if (getIsMe()) {
refreshData() refreshData()
// viewModel.visitListState.collectWithLifecycle(this) { data ->
// adapter.submitData(data)
// }
waitForQRScanResult() waitForQRScanResult()
} else{ } else{
showData(user) // TODO() не показывать кнопки showData(getUserDto()!!)
hideButtons()
} }
subscribe() subscribe()
viewModel.visitListState.collectWithLifecycle(this) { data ->
adapter.submitData(data)
Log.d("ProfileFragment", "submitted data")
}
// viewModel.visitListStateFromLogin.collectWithLifecycle(this) { data ->
// adapter.submitData(data)
// }
}
private fun hideButtons() {
binding.logout.visibility = View.INVISIBLE
binding.scan.visibility = View.INVISIBLE
} }
fun showData(userDTO: UserDTO){ fun showData(userDTO: UserDTO){
binding.fio.text = userDTO.name binding.fio.text = userDTO.name
binding.position.text = userDTO.position binding.position.text = userDTO.position
binding.lastEntry.text = userDTO.lastVisit viewModel.setLogin(login1 = userDTO.login)
// binding.lastEntry.text = userDTO.lastVisit
// viewModel.visitListStateFromLogin.collectWithLifecycle(this){
//
// }
setAvatar(userDTO.photo) setAvatar(userDTO.photo)
} }
private fun refreshData() { private fun refreshData() {
@ -72,6 +100,10 @@ class ProfileFragment: Fragment(R.layout.fragment_profile) {
private fun getUserDto(): UserDTO? { private fun getUserDto(): UserDTO? {
return arguments?.getSerializable("user") as? UserDTO return arguments?.getSerializable("user") as? UserDTO
} }
private fun getIsMe(): Boolean {
return arguments?.getBoolean("isMe", true) ?: true
}
private fun waitForQRScanResult() { private fun waitForQRScanResult() {
requireActivity().onBackPressedDispatcher.addCallback( requireActivity().onBackPressedDispatcher.addCallback(

View File

@ -3,7 +3,12 @@ package com.displaynone.acss.ui.profile
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import androidx.paging.log
import com.displaynone.acss.components.auth.models.user.UserServiceST import com.displaynone.acss.components.auth.models.user.UserServiceST
import com.displaynone.acss.components.auth.models.user.repository.VisitListPagingSource
import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO
import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
@ -17,10 +22,31 @@ class ProfileViewModel(): ViewModel() {
capacity = Channel.BUFFERED, capacity = Channel.BUFFERED,
onBufferOverflow = BufferOverflow.DROP_OLDEST, onBufferOverflow = BufferOverflow.DROP_OLDEST,
) )
private var login: String = "" // FIXME()
fun setLogin(login1: String){
login = login1
}
val action = _action.receiveAsFlow() val action = _action.receiveAsFlow()
val _state = MutableStateFlow<State>(State.Loading) val _state = MutableStateFlow<State>(State.Loading)
val state = _state.asStateFlow() val state = _state.asStateFlow()
val visitListState = Pager(
config = PagingConfig(pageSize = 20,
enablePlaceholders = false,
maxSize = 100
)
) {
VisitListPagingSource(UserServiceST.getInstance()::getMyLastVisits)
}.flow
.cachedIn(viewModelScope)
val visitListStateFromLogin = Pager(
config = PagingConfig(pageSize = 20, enablePlaceholders = false, maxSize = 100)
) {
VisitListPagingSource { pageNum, pageSize ->
UserServiceST.getInstance().getLastVisitsByLogin(pageNum,pageSize, login)
}
}.flow.cachedIn(viewModelScope)
fun logout(){ fun logout(){
UserServiceST.getInstance().logout() UserServiceST.getInstance().logout()
@ -48,6 +74,7 @@ class ProfileViewModel(): ViewModel() {
_action.send(Action.GoToScan) _action.send(Action.GoToScan)
} }
} }
sealed interface State { sealed interface State {
data object Loading : State data object Loading : State
data class Show( data class Show(