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 {
implementation("androidx.paging:paging-runtime:3.3.2")
val cameraX = "1.3.4"
implementation("androidx.camera:camera-core:$cameraX")
implementation("androidx.camera:camera-camera2:$cameraX")

View File

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

View File

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

View File

@ -3,7 +3,9 @@ package com.displaynone.acss.components.auth.models.user
import android.content.Context
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.dto.LastVisitsDto
import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO
import com.displaynone.acss.components.auth.models.user.repository.dto.VisitDto
class UserServiceST(
@ -28,13 +30,34 @@ class UserServiceST(
return instance ?: throw RuntimeException("null instance")
}
}
suspend fun login(login: String): Result<Unit>{
suspend fun login(login: String, password:String): Result<Unit>{
return runCatching {
userRepository.login(login = login).getOrThrow().let { data ->
userRepository.login(login = login, password).getOrThrow().let { 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(){
tokenManager.clear()
}

View File

@ -2,10 +2,12 @@ package com.displaynone.acss.components.auth.models.user.repository
import android.util.Log
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.Network
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.UserLoginDto
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.headers
@ -33,13 +35,14 @@ class UserRepository(
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 {
val result = Network.client.post("$serverUrl/api/auth/login") {
headers {
append(HttpHeaders.ContentType, ContentType.Application.Json.toString())
}
setBody("""{ "login": "$login" }""")
contentType(ContentType.Application.Json)
setBody(UserLoginDto(login, password))
}
if (result.status != HttpStatusCode.OK) {
error("Status ${result.status}: ${result.body<String>()}")
@ -99,6 +102,57 @@ class UserRepository(
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) {
runCatching {
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")
val position: String,
@SerialName("lastVisit")
val lastVisit: String,
// @SerialName("lastVisit")
// val lastVisit: String,
) : 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 bundle = Bundle().apply{
putSerializable("user", userDto)
putBoolean("isMe", false)
}
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 com.displaynone.acss.components.auth.models.user.UserServiceST
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.asStateFlow
import kotlinx.coroutines.launch
@ -20,7 +19,8 @@ class AdminViewModel: ViewModel() {
_state.emit(State.Show(item = dto))
},
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()) }
)
}
@ -30,6 +30,9 @@ class AdminViewModel: ViewModel() {
data class Show(
val item: UserDTO
): State
data class Error(
val errorMessage: String
): State
data object Loading : State
}
}

View File

@ -59,6 +59,7 @@ class AuthFragment: Fragment(R.layout.fragment_auth) {
val username = s.toString()
val valid = isUsernameValid(username)
binding.hint.visibility = if(valid) View.INVISIBLE else View.VISIBLE
binding.next.isEnabled = valid
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?) {}
})
// 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 {
@ -76,6 +106,10 @@ class AuthFragment: Fragment(R.layout.fragment_auth) {
!username[0].isDigit() &&
alf.matches(username)
}
private fun isPasswordValid(password: String): Boolean {
return password.isNotEmpty() &&
password.length >= 8
}
// private fun subscribe() {
// viewModel.state.collectWhenStarted(this) { state ->
// binding.login.setOnClickListener(this::onLoginButtonClicked)
@ -84,8 +118,9 @@ class AuthFragment: Fragment(R.layout.fragment_auth) {
private fun onLoginButtonClicked(view: View) {
val login = binding.login.text.toString()
val password = binding.password.text.toString()
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)
val errorState: StateFlow<String?> = _errorState.asStateFlow()
fun login(login: String){
fun login(login: String, password:String){
viewModelScope.launch {
try {
UserServiceST.getInstance().login(login).fold(
UserServiceST.getInstance().login(login, password).fold(
onSuccess = { openProfile() },
onFailure = { 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 com.bumptech.glide.Glide
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.databinding.FragmentProfileBinding
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?) {
super.onViewCreated(view, savedInstanceState)
_binding = FragmentProfileBinding.bind(view)
binding.swipeRefresh.setOnRefreshListener {
refreshData()
}
//
// binding.swipeRefresh.setOnRefreshListener {
// if (getIsMe()){
// refreshData()
// } else{
// showData(getUserDto()!!)
// }
// }
binding.logout.setOnClickListener{
logout()
}
binding.scan.setOnClickListener{
viewModel.openScan()
}
val user = getUserDto()
if (user == null) {
val adapter = VisitAdapter()
binding.recyclerViewLogs.adapter = adapter
if (getIsMe()) {
refreshData()
// viewModel.visitListState.collectWithLifecycle(this) { data ->
// adapter.submitData(data)
// }
waitForQRScanResult()
} else{
showData(user) // TODO() не показывать кнопки
showData(getUserDto()!!)
hideButtons()
}
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){
binding.fio.text = userDTO.name
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)
}
private fun refreshData() {
@ -72,6 +100,10 @@ class ProfileFragment: Fragment(R.layout.fragment_profile) {
private fun getUserDto(): UserDTO? {
return arguments?.getSerializable("user") as? UserDTO
}
private fun getIsMe(): Boolean {
return arguments?.getBoolean("isMe", true) ?: true
}
private fun waitForQRScanResult() {
requireActivity().onBackPressedDispatcher.addCallback(

View File

@ -3,7 +3,12 @@ package com.displaynone.acss.ui.profile
import android.util.Log
import androidx.lifecycle.ViewModel
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.repository.VisitListPagingSource
import com.displaynone.acss.components.auth.models.user.repository.dto.UserDTO
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.channels.Channel
@ -17,10 +22,31 @@ class ProfileViewModel(): ViewModel() {
capacity = Channel.BUFFERED,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
private var login: String = "" // FIXME()
fun setLogin(login1: String){
login = login1
}
val action = _action.receiveAsFlow()
val _state = MutableStateFlow<State>(State.Loading)
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(){
UserServiceST.getInstance().logout()
@ -48,6 +74,7 @@ class ProfileViewModel(): ViewModel() {
_action.send(Action.GoToScan)
}
}
sealed interface State {
data object Loading : State
data class Show(