Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Pinakes Android — Build Status

## 1.13.0 (versionCode 27): the visitor log finds a member without the card

Pairs with Pinakes 0.9.2 (Mobile API 1.11.0). Works with older servers: without `staff_attendance_member_pick` in `/health` the button is not shown and the desk behaves as in 1.12.0.

- **Find member** in the visitor log's desk tab: a reader who forgot the card is found by name, e-mail, card or phone with the loan desk's borrower search (`GET /staff/users`, at least two characters) and checked in with `user_id` on `POST /staff/attendance/check-ins`, under its own Idempotency-Key, with the same result card, tone and inactive-account warning as a scanned card. The rows show card and e-mail only: loan eligibility (a debt block, for example) plays no part at the entrance. The camera stops reading while the search is open. `404 member_not_found` has its own wording; a `422` keeps the server's text.
- Shown only when the server advertises it (`/health.features.staff_attendance_member_pick`, Pinakes 0.9.2), and with the visitor log on.

## 1.12.0 (versionCode 26): loans follow the server calendar, and the same-day handover switch

Pairs with Pinakes 0.9.0 and 0.9.1 (Mobile API 1.10.0 as shipped in the stable release). Works with older servers: the new fields are optional and their absence keeps the 1.11.0 behaviour.
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
minSdk = 26
targetSdk = 35
versionCode = 26
versionName = "1.12.0"
versionCode = 27
versionName = "1.13.0"

vectorDrawables {
useSupportLibrary = true
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/pinakes/app/data/model/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,8 @@ data class HealthFeatures(
@SerialName("staff_catalogue_cards") val staffCatalogueCards: Boolean = false,
@SerialName("staff_marc") val staffMarc: Boolean = false,
@SerialName("staff_attendance") val staffAttendance: Boolean = false,
// Mobile API 1.11.0 (Pinakes 0.9.2): a check-in may name a member picked from the search.
@SerialName("staff_attendance_member_pick") val staffAttendanceMemberPick: Boolean = false,
@SerialName("staff_statistics") val staffStatistics: Boolean = false,
@SerialName("staff_user_import") val staffUserImport: Boolean = false,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,13 @@ data class AttendanceVisit(
)

@Serializable
data class AttendanceCheckInRequest(@SerialName("card_code") val cardCode: String, @SerialName("branch_id") val branchId: Int? = null)
data class AttendanceCheckInRequest(
/** The scanned or typed card; null when [userId] names the member (never both). */
@SerialName("card_code") val cardCode: String? = null,
/** Mobile API 1.11.0: a member picked from `GET /staff/users`. */
@SerialName("user_id") val userId: Int? = null,
@SerialName("branch_id") val branchId: Int? = null,
)

@Serializable
data class AttendanceGuestRequest(val name: String? = null, val reason: String? = null, @SerialName("branch_id") val branchId: Int? = null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,11 @@ class Staff090PatronsRepository @Inject constructor(
suspend fun attendance(days: Int): ApiResult<AttendanceOverview> = session.staffCall { api.attendance(days) }

suspend fun checkIn(cardCode: String, branchId: Int?, idempotencyKey: String?): ApiResult<AttendanceVisit> =
session.staffCall { api.checkIn(AttendanceCheckInRequest(cardCode.trim(), branchId), idempotencyKey) }
session.staffCall { api.checkIn(AttendanceCheckInRequest(cardCode = cardCode.trim(), branchId = branchId), idempotencyKey) }

/** A member picked from the borrower search (Mobile API 1.11.0), in place of a card. */
suspend fun checkInMember(userId: Int, branchId: Int?, idempotencyKey: String?): ApiResult<AttendanceVisit> =
session.staffCall { api.checkIn(AttendanceCheckInRequest(userId = userId, branchId = branchId), idempotencyKey) }

suspend fun guest(name: String?, reason: String?, branchId: Int?, idempotencyKey: String?): ApiResult<AttendanceVisit> =
session.staffCall { api.guest(AttendanceGuestRequest(name.blankToNull()?.take(150), reason.blankToNull()?.take(255), branchId), idempotencyKey) }
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/com/pinakes/app/data/staff/Staff090Areas.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ data class Staff090Areas(
val catalogueCards: Boolean = false,
val marc: Boolean = false,
val attendance: Boolean = false,
/** The visitor log also checks in a member picked by name (Mobile API 1.11.0, Pinakes 0.9.2). */
val attendanceMemberPick: Boolean = false,
val statistics: Boolean = false,
val userImport: Boolean = false,
) {
Expand Down Expand Up @@ -60,6 +62,7 @@ data class Staff090Areas(
catalogueCards = f.staffCatalogueCards,
marc = f.staffMarc,
attendance = f.staffAttendance,
attendanceMemberPick = f.staffAttendance && f.staffAttendanceMemberPick,
statistics = f.staffStatistics,
userImport = f.staffUserImport,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ object Staff090PatronsErrors {
"card_not_renewable" -> R.string.staff090_patrons_err_card_not_renewable
"amount_exceeds_balance" -> R.string.staff090_patrons_err_amount_exceeds_balance
"card_not_found" -> R.string.staff090_patrons_err_card_not_found
"member_not_found" -> R.string.staff090_patrons_err_member_not_found
"not_started" -> R.string.staff090_patrons_err_not_started
"already_started" -> R.string.staff090_patrons_err_already_started
"job_cancelled" -> R.string.staff090_patrons_err_job_cancelled
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/pinakes/app/data/store/Staff090Flags.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ data class Staff090Flags(
val staffCatalogueCards: Boolean = false,
val staffMarc: Boolean = false,
val staffAttendance: Boolean = false,
val staffAttendanceMemberPick: Boolean = false,
val staffStatistics: Boolean = false,
val staffUserImport: Boolean = false,
) {
Expand Down Expand Up @@ -72,6 +73,7 @@ data class Staff090Flags(
staffCatalogueCards = f.staffCatalogueCards,
staffMarc = f.staffMarc,
staffAttendance = f.staffAttendance && circulation,
staffAttendanceMemberPick = f.staffAttendanceMemberPick && f.staffAttendance && circulation,
staffStatistics = f.staffStatistics,
staffUserImport = f.staffUserImport,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.DeleteSweep
import androidx.compose.material.icons.outlined.Download
import androidx.compose.material.icons.outlined.PersonAdd
import androidx.compose.material.icons.outlined.PersonSearch
import androidx.compose.material.icons.outlined.Search
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
Expand Down Expand Up @@ -64,7 +65,9 @@ import com.pinakes.app.R
import com.pinakes.app.data.model.AttendanceOverview
import com.pinakes.app.data.model.AttendanceVisit
import com.pinakes.app.data.network.ApiResult
import com.pinakes.app.data.model.StaffUser
import com.pinakes.app.data.repository.Staff090PatronsRepository
import com.pinakes.app.data.repository.StaffCirculationRepository
import com.pinakes.app.data.repository.StaffFilesRepository
import com.pinakes.app.data.repository.isForbidden
import com.pinakes.app.data.staff.AttendanceRules
Expand All @@ -87,6 +90,9 @@ import com.pinakes.app.ui.screens.staff.FormSectionTitle
import com.pinakes.app.ui.screens.staff.InfoCard
import com.pinakes.app.ui.screens.staff.InfoLine
import com.pinakes.app.ui.screens.staff.LeaveWhen
import com.pinakes.app.ui.screens.staff.PickItem
import com.pinakes.app.ui.screens.staff.SearchController
import com.pinakes.app.ui.screens.staff.SearchPickerSheet
import com.pinakes.app.ui.screens.staff.StaffMessage
import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect
import com.pinakes.app.ui.screens.staff.resolve
Expand All @@ -113,7 +119,8 @@ data class VisitResult(
val photo: ByteArray? = null,
)

private data class CheckInSubmission(val code: String, val branchId: Int?)
/** One check-in attempt: a card code or a picked member, at a branch. */
private data class CheckInSubmission(val code: String?, val userId: Int?, val branchId: Int?)
private data class GuestSubmission(val name: String, val reason: String, val branchId: Int?)

data class AttendanceUiState(
Expand All @@ -131,6 +138,8 @@ data class AttendanceUiState(
val tonePlayed: Int = 0,
val manualError: Boolean = false,
val guestSheet: Boolean = false,
/** The member search (Mobile API 1.11.0) is open. */
val memberSheet: Boolean = false,
val savingGuest: Boolean = false,
val guestError: StaffMessage? = null,
val savingSettings: Boolean = false,
Expand All @@ -143,21 +152,32 @@ data class AttendanceUiState(

/**
* The visitor log at the entrance: the phone scans the patron card (CODE 128 or QR), the
* server records the visit and answers the kiosk's message; guests are recorded by hand. The
* server records the visit and answers the kiosk's message; guests are recorded by hand. A
* member who forgot the card is found by name with the loan desk's borrower search and checked
* in by id, on servers that advertise it (`staff_attendance_member_pick`). The
* statistics tab shows the counters of the web page, the CSV export and, for administrators,
* the retention and the purge.
*/
@HiltViewModel
class Staff090AttendanceViewModel @Inject constructor(
private val patrons: Staff090PatronsRepository,
private val files: StaffFilesRepository,
private val circulation: StaffCirculationRepository,
) : ViewModel() {
private val _state = MutableStateFlow(AttendanceUiState())
val state: StateFlow<AttendanceUiState> = _state.asStateFlow()
private val debouncer = ScanDebouncer()
private val checkInKeys = SubmissionKeys()
private val guestKeys = SubmissionKeys()

/** The member search: the loan desk's borrower search, at least two characters. */
val members = SearchController(viewModelScope, minLength = 2) { q ->
when (val res = circulation.users(q)) {
is ApiResult.Success -> ApiResult.Success(res.data.map { it.toVisitorItem() })
is ApiResult.Failure -> res
}
}.also { it.onFailure = { f -> if (f.isForbidden()) _state.update { s -> s.copy(forbidden = true) } } }

init { load() }

fun load(days: Int = _state.value.days) {
Expand Down Expand Up @@ -189,7 +209,7 @@ class Staff090AttendanceViewModel @Inject constructor(
fun selectBranch(id: Int?) = _state.update { it.copy(branchId = id) }

fun onDecoded(raw: String) {
if (_state.value.checking || _state.value.guestSheet) return
if (_state.value.checking || _state.value.guestSheet || _state.value.memberSheet) return
val code = AttendanceRules.normalizeCard(raw).takeIf { it.isNotEmpty() } ?: return
val accepted = debouncer.offer(code, SystemClock.elapsedRealtime()) ?: return
checkIn(accepted)
Expand All @@ -209,13 +229,33 @@ class Staff090AttendanceViewModel @Inject constructor(
return true
}

private fun checkIn(code: String) {
fun openMembers() {
debouncer.pause()
members.clear()
_state.update { it.copy(memberSheet = true) }
}

fun closeMembers() {
_state.update { it.copy(memberSheet = false) }
members.clear()
debouncer.resume(SystemClock.elapsedRealtime())
}

/** A member picked from the search is checked in by id, like a scanned card. */
fun pickMember(item: PickItem) {
if (_state.value.checking) return
_state.update { it.copy(memberSheet = false) }
members.clear()
checkIn(code = null, userId = item.id)
}

private fun checkIn(code: String?, userId: Int? = null) {
val branch = _state.value.branchId
val key = checkInKeys.keyFor(CheckInSubmission(code, branch))
val key = checkInKeys.keyFor(CheckInSubmission(code, userId, branch))
debouncer.pause()
_state.update { it.copy(checking = true) }
viewModelScope.launch {
val res = patrons.checkIn(code, branch, key)
val res = if (userId != null) patrons.checkInMember(userId, branch, key) else patrons.checkIn(code.orEmpty(), branch, key)
val result = when (res) {
is ApiResult.Success -> {
checkInKeys.reset()
Expand Down Expand Up @@ -364,10 +404,17 @@ fun Staff090AttendanceScreen(onNavigateUp: () -> Unit) {
Tab(selected = tab == state.tab, onClick = { vm.selectTab(tab) }, text = { Text(stringResource(tab.label)) }, modifier = Modifier.testTag("tab-${tab.name.lowercase()}"))
}
}
if (state.tab == AttendanceTab.DESK) DeskTab(state, vm) else StatsTab(state, vm)
if (state.tab == AttendanceTab.DESK) DeskTab(state, vm, memberPick = areas.s090.attendanceMemberPick) else StatsTab(state, vm)
}
}
if (state.guestSheet) GuestSheet(state, vm)
if (state.memberSheet) {
val s by vm.members.state.collectAsStateWithLifecycle()
SearchPickerSheet(
title = stringResource(R.string.staff090_patrons_member_pick_title), placeholder = stringResource(R.string.staff_search_users), state = s,
onQuery = vm.members::onQuery, onPick = vm::pickMember, onDismiss = vm::closeMembers, hint = stringResource(R.string.staff_search_min_chars),
)
}
if (state.confirmPurge) ConfirmDialog(
title = stringResource(R.string.staff090_patrons_purge),
body = stringResource(R.string.staff090_patrons_purge_body, state.overview?.retention?.days ?: 0),
Expand All @@ -379,7 +426,7 @@ fun Staff090AttendanceScreen(onNavigateUp: () -> Unit) {

@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun DeskTab(state: AttendanceUiState, vm: Staff090AttendanceViewModel) {
private fun DeskTab(state: AttendanceUiState, vm: Staff090AttendanceViewModel, memberPick: Boolean) {
Column(
Modifier.fillMaxSize().imePadding().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg, vertical = Spacing.sm),
verticalArrangement = Arrangement.spacedBy(Spacing.md),
Expand Down Expand Up @@ -423,6 +470,10 @@ private fun DeskTab(state: AttendanceUiState, vm: Staff090AttendanceViewModel) {
}
}
state.last?.let { VisitCard(it) }
if (memberPick) SecondaryButton(
stringResource(R.string.staff090_patrons_member_pick), onClick = vm::openMembers, leadingIcon = Icons.Outlined.PersonSearch,
enabled = !state.checking, modifier = Modifier.fillMaxWidth().testTag("member-open"),
)
SecondaryButton(
stringResource(R.string.staff090_patrons_guest), onClick = vm::openGuest, leadingIcon = Icons.Outlined.PersonAdd,
modifier = Modifier.fillMaxWidth().testTag("guest-open"),
Expand Down Expand Up @@ -586,3 +637,10 @@ private fun RetentionCard(overview: AttendanceOverview, saving: Boolean, vm: Sta
}
}
}

/** A search row for the entrance: name, card and e-mail; loan eligibility plays no part here. */
internal fun StaffUser.toVisitorItem(): PickItem = PickItem(
id = id,
title = name,
subtitle = listOfNotNull(cardNumber?.takeIf { it.isNotBlank() }, email?.takeIf { it.isNotBlank() }).joinToString(" · ").ifBlank { null },
)
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.pinakes.app.server090

import com.pinakes.app.data.model.AttendanceCheckInRequest
import com.pinakes.app.data.model.AttendanceOverview
import com.pinakes.app.data.model.AttendancePurgeResult
import com.pinakes.app.data.model.AttendanceSettingsResult
Expand Down Expand Up @@ -31,6 +32,9 @@ import com.pinakes.app.data.repository.isForbidden
import com.pinakes.app.R
import com.pinakes.app.ui.screens.staff.s090.patrons.toPatronsMessage
import com.pinakes.app.data.store.InstanceFeatures
import com.pinakes.app.data.staff.AttendanceRules
import com.pinakes.app.data.staff.VisitOutcome
import com.pinakes.app.ui.screens.staff.s090.patrons.toVisitorItem
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
Expand Down Expand Up @@ -61,6 +65,41 @@ class Staff090PatronsParsingTest {
assertTrue(areas.ledger && areas.attendance && areas.userImport)
}

@Test fun aServerThatTakesAPickedMemberSaysSo() {
// Pinakes 0.9.2 (Mobile API 1.11.0): the visitor log also checks in a member by id.
val areas = Staff090Areas.of("staff", InstanceFeatures.from(data<HealthPayload>("health_092")), online = true)
assertTrue(areas.attendance && areas.attendanceMemberPick)
// A 0.9.0 / 0.9.1 server has no flag: the search stays hidden.
assertFalse(Staff090Areas.of("staff", InstanceFeatures.from(data<HealthPayload>("health")), online = true).attendanceMemberPick)
}

@Test fun aPickedMemberIsSentByIdAndRecordedLikeACard() {
val app = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true }
assertEquals("""{"user_id":6805}""", app.encodeToString(AttendanceCheckInRequest.serializer(), AttendanceCheckInRequest(userId = 6805)))
assertEquals("""{"card_code":"GB2026","branch_id":2}""", app.encodeToString(AttendanceCheckInRequest.serializer(), AttendanceCheckInRequest(cardCode = "GB2026", branchId = 2)))
val visit = data<AttendanceVisit>("att_checkin_member")
assertEquals("member", visit.type)
assertEquals(6805, visit.member?.id)
assertEquals(VisitOutcome.WELCOME, AttendanceRules.outcome(visit))
val missing = failure("att_member_not_found", 404)
assertEquals("member_not_found", missing.code)
assertEquals(R.string.staff090_patrons_err_member_not_found, Staff090PatronsErrors.messageRes(missing))
assertEquals(VisitOutcome.ERROR, AttendanceRules.outcome(missing))
// Card and member together: the server's precise 422 text is kept.
val both = failure("att_member_and_card", 422)
assertEquals("validation", both.code)
assertNull(Staff090PatronsErrors.messageRes(both))
}

@Test fun theEntranceSearchShowsCardAndMailWithoutLoanEligibility() {
val blocked = data<List<StaffUser>>("users_search").single().copy(eligible = false, eligibilityReason = "debt_block")
val item = blocked.toVisitorItem()
assertEquals(blocked.id, item.id)
assertTrue(item.enabled)
assertNull(item.note)
assertEquals(listOfNotNull(blocked.cardNumber, blocked.email).filter { it.isNotBlank() }.joinToString(" · ").ifBlank { null }, item.subtitle)
}

@Test fun staffUserCarriesTheNewPatronFields() {
val user = data<StaffUserDetail>("user_detail")
assertEquals("PCAP090A", user.cardNumber)
Expand Down
24 changes: 24 additions & 0 deletions app/src/test/resources/server090/patrons/att_checkin_member.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"data": {
"id": 60,
"type": "member",
"created_at": "2026-11-06 15:25:31",
"branch": null,
"member": {
"id": 6805,
"short_name": "Giulia B.",
"card_code": "GB2026",
"account_status": "attivo",
"card_active": true,
"card_expires_at": null,
"card_expired": false,
"has_photo": false,
"photo_url": null
},
"guest": null,
"warning": null,
"message": "Benvenuto/a, Giulia B.: presenza registrata."
},
"meta": {},
"error": null
}
13 changes: 13 additions & 0 deletions app/src/test/resources/server090/patrons/att_member_and_card.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"data": null,
"meta": {
"field": "user_id",
"fields": {
"user_id": "Indica la tessera oppure il socio, non entrambi."
}
},
"error": {
"code": "validation",
"message": "Indica la tessera oppure il socio, non entrambi."
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"data": null,
"meta": {},
"error": {
"code": "member_not_found",
"message": "Utente non trovato"
}
}
Loading
Loading