From d76b0d60ed0073a0dc09bcc2359b4a7e08fef9c8 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 11:57:52 +0200 Subject: [PATCH 1/6] Restyle the loan request sheet after the web's loan calendar The sheet is a plain dialog instead of DatePickerDialog, which capped its height and cut the month in half: 20dp corners, a Fraunces title, confirm and cancel stacked at full width in the app's button styles. The month follows the web: a free day is a plain number, limited and unavailable days keep their tint with a faint rule of their own colour, today has an accent ring and the chosen day takes the theme's button colour. The legend uses dots. When availability cannot be loaded, the fallback Material picker takes the sheet's side padding too: on a 360dp phone its seventh column was otherwise cut in half. LoanRequestSheetUiTest covers the buttons, the pre-selected earliest day, the disabled states and the seventh column at 360dp (verified to fail without the fix). DESIGN.md documents the sheet. --- DESIGN.md | 9 + STATUS.md | 4 +- .../com/pinakes/app/LoanRequestSheetUiTest.kt | 116 ++++++++++++ .../app/ui/screens/detail/BookDetailScreen.kt | 176 ++++++++++++------ .../app/ui/screens/detail/LoanCalendar.kt | 69 ++++--- 5 files changed, 283 insertions(+), 91 deletions(-) create mode 100644 app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt diff --git a/DESIGN.md b/DESIGN.md index 6fd6602..9e05d96 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -224,6 +224,15 @@ The reference layout (top to bottom on a phone): - then dark "Copia negli appunti", outline "Scarica RIS" and outline "Cita" (all styles). 15. **Potrebbero interessarti:** white band with a top `line` border, Fraunces 40 heading, a horizontal row of book cards. +### Loan request sheet + +The sheet the "Richiedi prestito" button opens, as the web's loan request: + +- A plain dialog, not Material's `DatePickerDialog` (that one caps its height and cut the month in half): 20dp corners, 8dp from the screen edges, at most 480dp wide, a Fraunces title, scrolling only on a screen too short for it. +- The month as the web's loan calendar: a free day is a plain number in `onSurface`; only the days that need a word carry a tint, amber for limited and `errorContainer` for unavailable, each ringed by a faint rule of its own text colour. Today has an accent ring; the chosen day is filled with the theme's `button` colour and `buttonText`, as the button that opened the sheet. The legend uses dots, like the days it explains. +- Confirm (filled) and cancel (outline) stacked at full width, never Material's text-button row. Confirm waits for a day and shows a spinner while the request is sent. +- When availability cannot be loaded, Material's plain picker also takes the sheet's side padding, so its seven 48dp columns fit a 360dp phone. + ### Other screens Wishlist, loans and reservations, profile, events, periodicals (Emeroteca), archive, static pages: diff --git a/STATUS.md b/STATUS.md index 84df63d..32878d1 100644 --- a/STATUS.md +++ b/STATUS.md @@ -6,8 +6,8 @@ The app now uses the 2026 web design: Geist / Fraunces, warm neutrals, a server- - Debug APK: `pinakes-debug.apk`, copied from `app/build/outputs/apk/debug/app-debug.apk` (generated locally and ignored by Git). - Package: `com.pinakes.app`, version `1.6.2` (19), minSdk 26, target/compileSdk 35. -- Local verification: **200 debug and 200 release unit tests, 22 instrumented tests on Android 15, zero lint errors (101 warnings)**, and successful debug + R8 release builds. The 1.6.2 debug APK installs over 1.6.1 with the same signing certificate. -- Verification commands: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `assembleRelease`, `assembleDebugAndroidTest`; the 22 device tests run through `am instrument` on a separate Android 15 AVD, preserving the authenticated demo device. +- Local verification: **200 debug and 200 release unit tests, 26 instrumented tests on Android 15, zero lint errors (101 warnings)**, and successful debug + R8 release builds. The 1.6.2 debug APK installs over 1.6.1 with the same signing certificate. +- Verification commands: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `assembleRelease`, `assembleDebugAndroidTest`; the 26 device tests run through `am instrument` on a separate Android 15 AVD, preserving the authenticated demo device. - Unit tests cover the existing contracts and theme contrast/mixing. Compose device tests cover full tall artwork, missing metadata, grid actions, view selection, digital-file cards, narrow search placeholders, circulation actions theme pairings, reachable empty-home actions and query preservation during loading. - Release builds exercise R8 and resource shrinking. Without release credentials the output is unsigned; no store release is published by this change. - macOS standalone emulator startup: `tools/run-emulator.sh` keeps a power assertion scoped to the emulator PID. A control with the identical debug APK reproduced a 21-second startup timeout at host priority 4; three starts with the assertion completed in 2.3–3.1 seconds. After a VM restart through the launcher, five more cold starts completed in 1.66–1.80 seconds with no ANR events. The original startup ANR also coincided with system/launcher stalls. An older AVD still stalled on a later cold boot, and a snapshot retained a system ANR dialog: the assertion does not repair an unhealthy AVD. No Sentry events are suppressed, and no production-device ANR fix is claimed. diff --git a/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt b/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt new file mode 100644 index 0000000..1a22506 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt @@ -0,0 +1,116 @@ +package com.pinakes.app + +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.semantics.getOrNull +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.width +import androidx.test.platform.app.InstrumentationRegistry +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.AvailabilityDay +import com.pinakes.app.ui.screens.detail.LoanRequestSheet +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import java.time.LocalDate + +class LoanRequestSheetUiTest { + @get:Rule val compose = createComposeRule() + + private val today = LocalDate.now() + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + /** Six weeks from today: mostly free, a partial day and a full one in the first week. */ + private fun calendar(earliest: String? = today.plusDays(1).toString()) = AvailabilityCalendar( + totalCopies = 2, + earliestAvailable = earliest, + unavailableDates = listOf(today.plusDays(3).toString()), + days = (0L until 42L).map { offset -> + val date = today.plusDays(offset) + when (offset) { + 2L -> AvailabilityDay(date.toString(), available = 1, loaned = 1, state = "partial") + 3L -> AvailabilityDay(date.toString(), available = 0, loaned = 2, state = "full") + else -> AvailabilityDay(date.toString(), available = 2, state = "free") + } + }, + ) + + private fun sheet( + calendar: AvailabilityCalendar? = calendar(), + fallback: Boolean = false, + submitting: Boolean = false, + onDismiss: () -> Unit = {}, + onConfirm: (String) -> Unit = {}, + widthDp: Float? = null, + ) = compose.setContent { + // widthDp: lay the sheet out as on a phone that many dp wide, whatever the emulator. + val base = LocalDensity.current + val density = widthDp?.let { Density(context.resources.displayMetrics.widthPixels / it, base.fontScale) } ?: base + CompositionLocalProvider(LocalDensity provides density) { PinakesTheme { + LoanRequestSheet( + loading = false, + calendar = calendar, + fallback = fallback, + submitting = submitting, + onDismiss = onDismiss, + onConfirm = onConfirm, + ) + } } + } + + @Test fun bothButtonsSpanTheSheetAndConfirmSendsTheEarliestDay() { + var confirmed: String? = null + var dismissed = false + sheet(onDismiss = { dismissed = true }, onConfirm = { confirmed = it }) + + val sheet = compose.onNodeWithTag("loan-sheet").getBoundsInRoot() + val confirm = compose.onNodeWithTag("loan-confirm").assertIsDisplayed().assertIsEnabled().getBoundsInRoot() + val cancel = compose.onNodeWithTag("loan-cancel").assertIsDisplayed().getBoundsInRoot() + assertEquals("Confirm and cancel share one width", confirm.width.value, cancel.width.value, .5f) + assertTrue("Buttons stay inside the sheet", confirm.left >= sheet.left && confirm.right <= sheet.right) + assertTrue("Cancel sits under confirm", cancel.top >= confirm.bottom) + + compose.onNodeWithTag("loan-confirm").performClick() + compose.runOnIdle { assertEquals(today.plusDays(1).toString(), confirmed) } + compose.onNodeWithTag("loan-cancel").performClick() + compose.runOnIdle { assertTrue(dismissed) } + } + + @Test fun confirmWaitsForADay() { + sheet(calendar = calendar(earliest = null)) + compose.onNodeWithTag("loan-confirm").assertIsNotEnabled() + } + + @Test fun confirmIsDisabledWhileTheRequestIsSent() { + sheet(submitting = true) + compose.onNodeWithTag("loan-confirm").assertIsNotEnabled() + compose.onNodeWithTag("loan-cancel").assertIsEnabled() + } + + @Test fun fallbackPickerKeepsItsSeventhColumnInsideTheSheet() { + // 360dp, the narrowest common phone: Material's seven 48dp columns need the sheet's side padding. + sheet(calendar = null, fallback = true, widthDp = 360f) + // Pixels throughout: the dp helpers of the test rule use the device density, not the forced one. + val sheetRight = compose.onNodeWithTag("loan-sheet").fetchSemanticsNode().boundsInRoot.right + val days = compose.onAllNodes( + // Material announces each day as one button whose text is the full date. + SemanticsMatcher("a day of the month") { node -> + node.config.getOrNull(SemanticsProperties.Role) == Role.Button && + node.config.getOrNull(SemanticsProperties.Text)?.joinToString { it.text } + ?.let { Regex("\\b\\d{1,2}\\b").containsMatchIn(it) && it.contains(today.year.toString()) } == true + }, + ).fetchSemanticsNodes() + assertEquals("Every day of the month is laid out", today.lengthOfMonth(), days.size) + // boundsInRoot is clipped by the sheet: a cut seventh column shows up as narrow days. + // Each day must keep its 40dp circle in view (without the bleed, 24dp of it is left). + val px = context.resources.displayMetrics.widthPixels / 360f + val narrowest = days.minOf { it.boundsInRoot.width } / px + assertTrue("A day is cut by the sheet: ${narrowest}dp of its 40dp circle in view", narrowest >= 39.5f) + assertTrue("A day overflows the sheet", days.maxOf { it.boundsInRoot.right } <= sheetRight + 1f) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 5be2dab..21dde7d 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -39,7 +39,11 @@ import androidx.compose.material.icons.outlined.Schedule import androidx.compose.material.icons.outlined.LocalOffer import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DatePicker -import androidx.compose.material3.DatePickerDialog +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.window.Dialog import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -50,7 +54,6 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -210,7 +213,7 @@ fun BookDetailScreen( */ @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun LoanRequestSheet( +internal fun LoanRequestSheet( loading: Boolean, calendar: AvailabilityCalendar?, fallback: Boolean, @@ -224,76 +227,112 @@ private fun LoanRequestSheet( mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() }) } - DatePickerDialog( + // Same sheet as the web's loan request: the card shape of the availability box (20dp), a + // serif title, and two buttons stacked at full width in the page's own styles — the filled + // confirm and the outlined cancel — instead of Material's text-button row. + // A plain Dialog, not DatePickerDialog: that one caps its height at 568dp, which cut the + // month in half and pushed the legend and the buttons below the fold. This one is as tall + // as its content and scrolls only on a screen too short for it. + Dialog( onDismissRequest = onDismiss, - confirmButton = { - TextButton( - onClick = { selectedIso?.let(onConfirm) }, - enabled = selectedIso != null && !submitting, - ) { Text(stringResource(R.string.book_loan_confirm)) } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.book_loan_cancel)) } - }, + properties = DialogProperties(usePlatformDefaultWidth = false), ) { - Column(Modifier.padding(horizontal = Spacing.lg).fillMaxWidth()) { - Text( - stringResource(R.string.book_loan_dialog_title), - modifier = Modifier.padding(top = Spacing.lg, bottom = Spacing.sm), - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - when { - loading -> { - Row( - Modifier.fillMaxWidth().padding(vertical = Spacing.xxl), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(Spacing.md)) + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier + // 8dp from the screen edges, as the web sheet on a phone (100vw - 1rem). + .padding(horizontal = Spacing.sm, vertical = Spacing.lg) + .widthIn(max = 480.dp) + .fillMaxWidth() + .testTag("loan-sheet"), + ) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .fillMaxWidth(), + ) { + Text( + stringResource(R.string.book_loan_dialog_title), + modifier = Modifier.padding(top = Spacing.lg, bottom = Spacing.md), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + when { + loading -> { + Row( + Modifier.fillMaxWidth().padding(vertical = Spacing.xxl), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(Spacing.md)) + Text( + stringResource(R.string.book_loan_availability_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + calendar != null && !fallback -> { + LoanCalendar( + calendar = calendar, + selected = selectedIso, + onSelect = { selectedIso = it }, + ) + } + + else -> { + // Fallback: plain today-or-future picker. + FallbackDatePicker( + today = today, + selectedIso = selectedIso, + onSelect = { selectedIso = it }, + // Material's picker lays out seven 48dp columns: let it use the sheet's + // side padding too, so the last column is never clipped on a phone. + modifier = Modifier.bleedHorizontally(Spacing.lg), + ) Text( - stringResource(R.string.book_loan_availability_loading), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + stringResource(R.string.book_loan_availability_error), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = Spacing.sm), ) } } - calendar != null && !fallback -> { - LoanCalendar( - calendar = calendar, - selected = selectedIso, - onSelect = { selectedIso = it }, - ) - } + // Period hint / prompt. + val hint = selectedIso?.let { + stringResource(R.string.book_loan_period_hint, formatDisplayDate(it)) + } ?: stringResource(R.string.book_loan_pick_prompt) + Text( + text = hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = Spacing.md), + ) - else -> { - // Fallback: plain today-or-future picker. - FallbackDatePicker( - today = today, - selectedIso = selectedIso, - onSelect = { selectedIso = it }, + Column( + Modifier.padding(bottom = Spacing.lg), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + PrimaryButton( + label = stringResource(R.string.book_loan_confirm), + onClick = { selectedIso?.let(onConfirm) }, + enabled = selectedIso != null, + loading = submitting, + modifier = Modifier.fillMaxWidth().testTag("loan-confirm"), ) - Text( - stringResource(R.string.book_loan_availability_error), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = Spacing.sm), + SecondaryButton( + label = stringResource(R.string.book_loan_cancel), + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().testTag("loan-cancel"), ) } } - - // Period hint / prompt. - val hint = selectedIso?.let { - stringResource(R.string.book_loan_period_hint, formatDisplayDate(it)) - } ?: stringResource(R.string.book_loan_pick_prompt) - Text( - text = hint, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = Spacing.md), - ) } } } @@ -305,6 +344,7 @@ private fun FallbackDatePicker( today: LocalDate, selectedIso: String?, onSelect: (String) -> Unit, + modifier: Modifier = Modifier, ) { val todayUtcMillis = today.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() val initialMillis = selectedIso?.let { @@ -319,7 +359,19 @@ private fun FallbackDatePicker( LaunchedEffect(datePickerState.selectedDateMillis) { datePickerState.selectedDateMillis?.let { onSelect(millisToIsoDate(it)) } } - DatePicker(state = datePickerState, title = null, headline = null, showModeToggle = false) + DatePicker(state = datePickerState, modifier = modifier, title = null, headline = null, showModeToggle = false) +} + +/** Measures the content [amount] wider on each side and centres it, over the parent's padding. */ +private fun Modifier.bleedHorizontally(amount: Dp): Modifier = layout { measurable, constraints -> + val extra = amount.roundToPx() * 2 + val wide = constraints.copy( + minWidth = constraints.minWidth + extra, + maxWidth = if (constraints.hasBoundedWidth) constraints.maxWidth + extra else constraints.maxWidth, + ) + val placeable = measurable.measure(wide) + val width = (placeable.width - extra).coerceAtLeast(0) + layout(width, placeable.height) { placeable.place(-extra / 2, 0) } } @OptIn(ExperimentalLayoutApi::class) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt index d97352e..2678ea7 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/LoanCalendar.kt @@ -10,13 +10,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight @@ -39,15 +40,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.pinakes.app.R import com.pinakes.app.data.model.AvailabilityCalendar -import com.pinakes.app.ui.theme.AvailableContainerDark -import com.pinakes.app.ui.theme.AvailableContainerLight -import com.pinakes.app.ui.theme.AvailableOnContainerDark -import com.pinakes.app.ui.theme.AvailableOnContainerLight import com.pinakes.app.ui.theme.DueSoonContainerDark import com.pinakes.app.ui.theme.DueSoonContainerLight import com.pinakes.app.ui.theme.DueSoonOnContainerDark import com.pinakes.app.ui.theme.DueSoonOnContainerLight +import com.pinakes.app.ui.theme.LocalPinakesColors import com.pinakes.app.ui.theme.Spacing +import com.pinakes.app.ui.theme.mix import java.time.DayOfWeek import java.time.LocalDate import java.time.YearMonth @@ -55,12 +54,14 @@ import java.time.format.TextStyle import java.util.Locale // --------------------------------------------------------------------------- -// Day-cell tints. These reuse the ONLY allowed semantic colours: the availability -// tints from theme/Color.kt (green = free, amber = partial) and the error role -// (red = full). No invented hexes. (DESIGN.md.) +// Day-cell tints, as on the web loan calendar: a free day is a plain dot in the +// ink colour (a coloured month reads as noise), only the days that need a word +// carry a tint — amber = partial (theme/Color.kt), red = full (the error role) — +// each ringed by a faint rule of its own text colour. No invented hexes. +// (DESIGN.md.) // --------------------------------------------------------------------------- -private data class DayTint(val container: Color, val onContainer: Color) +private data class DayTint(val container: Color, val onContainer: Color, val rule: Color = Color.Transparent) /** Per-day availability state used to colour a calendar cell. */ private enum class DayKind { Free, Partial, Full, PastOrUnknown } @@ -68,17 +69,20 @@ private enum class DayKind { Free, Partial, Full, PastOrUnknown } @Composable private fun tintFor(kind: DayKind, dark: Boolean): DayTint = when (kind) { DayKind.Free -> - if (dark) DayTint(AvailableContainerDark, AvailableOnContainerDark) - else DayTint(AvailableContainerLight, AvailableOnContainerLight) + DayTint(Color.Transparent, MaterialTheme.colorScheme.onSurface) DayKind.Partial -> - if (dark) DayTint(DueSoonContainerDark, DueSoonOnContainerDark) - else DayTint(DueSoonContainerLight, DueSoonOnContainerLight) + if (dark) ruled(DueSoonContainerDark, DueSoonOnContainerDark) + else ruled(DueSoonContainerLight, DueSoonOnContainerLight) DayKind.Full -> - DayTint(MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer) + ruled(MaterialTheme.colorScheme.errorContainer, MaterialTheme.colorScheme.onErrorContainer) DayKind.PastOrUnknown -> DayTint(Color.Transparent, MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)) } +/** A tinted day: its rule is its own text colour, faded into the tint. */ +private fun ruled(container: Color, onContainer: Color) = + DayTint(container, onContainer, mix(onContainer, container, .22f)) + /** Index per-day data by ISO date for O(1) lookup, and the set of unavailable dates. */ private class CalendarModel(calendar: AvailabilityCalendar) { private val byDate: Map = @@ -124,7 +128,7 @@ fun LoanCalendar( onSelect: (isoDate: String) -> Unit, modifier: Modifier = Modifier, ) { - val dark = com.pinakes.app.ui.theme.LocalPinakesColors.current.darkMode + val dark = LocalPinakesColors.current.darkMode val today = remember { LocalDate.now() } val model = remember(calendar) { CalendarModel(calendar) } @@ -265,17 +269,25 @@ private fun DayCell( val selectable = model.isSelectable(date, today) val tint = tintFor(kind, dark) - val container = if (selected) MaterialTheme.colorScheme.primary else tint.container - val onContainer = if (selected) MaterialTheme.colorScheme.onPrimary else tint.onContainer + // The pick is the theme's button colour, as the "Request loan" button that opened the sheet. + val tokens = LocalPinakesColors.current + val container = if (selected) tokens.button else tint.container + val onContainer = if (selected) tokens.buttonText else tint.onContainer + val rule = when { + selected -> Color.Transparent + date == today -> MaterialTheme.colorScheme.primary + else -> tint.rule + } + // 40dp where the column allows it, the column itself on the narrowest screens, + // so the seven dots of a row never run into each other. val cellModifier = Modifier - .size(40.dp) + .sizeIn(maxWidth = 40.dp, maxHeight = 40.dp) + .fillMaxSize() .clip(CircleShape) .background(container) .then( - if (date == today && !selected) - Modifier.border(1.dp, MaterialTheme.colorScheme.primary, CircleShape) - else Modifier + if (rule != Color.Transparent) Modifier.border(1.dp, rule, CircleShape) else Modifier ) .then( if (selectable) Modifier.clickable { onSelect(iso) } else Modifier @@ -297,20 +309,23 @@ private fun Legend(dark: Boolean) { Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(Spacing.md), ) { - LegendItem(tintFor(DayKind.Free, dark).container, stringResource(R.string.book_loan_legend_available)) - LegendItem(tintFor(DayKind.Partial, dark).container, stringResource(R.string.book_loan_legend_limited)) - LegendItem(tintFor(DayKind.Full, dark).container, stringResource(R.string.book_loan_legend_unavailable)) + // A free day is an untinted dot, so its key is one too: the surface ringed by the rule. + LegendItem(MaterialTheme.colorScheme.surface, stringResource(R.string.book_loan_legend_available), MaterialTheme.colorScheme.outline) + tintFor(DayKind.Partial, dark).let { LegendItem(it.container, stringResource(R.string.book_loan_legend_limited), it.rule) } + tintFor(DayKind.Full, dark).let { LegendItem(it.container, stringResource(R.string.book_loan_legend_unavailable), it.rule) } } } @Composable -private fun LegendItem(color: Color, label: String) { +private fun LegendItem(color: Color, label: String, rule: Color = Color.Transparent) { Row(verticalAlignment = Alignment.CenterVertically) { + // Dots, like the days they explain. Box( Modifier .size(12.dp) - .clip(RoundedCornerShape(3.dp)) - .background(color), + .clip(CircleShape) + .background(color) + .border(1.dp, rule, CircleShape), ) Spacer(Modifier.width(Spacing.xs)) Text( From 3177b289706dc836c7b8fc2b375352884fc41064 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 13:43:33 +0200 Subject: [PATCH 2/6] Test the loan sheet's seventh column in a real 360dp layout The test forced a 360dp density around LoanRequestSheet, but a Dialog is a window of its own and takes the device density: on a 411dp emulator the days measured 42dp instead of 48dp, so the test only held on an emulator that was already 360dp wide. The sheet's content moves into LoanRequestSheetContent, which the Dialog wraps; the test lays it out in a 360dp Box at the device density. It fails without the bleed (24dp of a 40dp day in view) and passes with it on both a 360dp and a 411dp emulator. --- .../com/pinakes/app/LoanRequestSheetUiTest.kt | 38 ++-- .../app/ui/screens/detail/BookDetailScreen.kt | 197 ++++++++++-------- 2 files changed, 127 insertions(+), 108 deletions(-) diff --git a/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt b/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt index 1a22506..0c0db26 100644 --- a/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt @@ -1,18 +1,19 @@ package com.pinakes.app -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.platform.LocalDensity +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.getOrNull import androidx.compose.ui.test.* import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.width -import androidx.test.platform.app.InstrumentationRegistry import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.AvailabilityDay import com.pinakes.app.ui.screens.detail.LoanRequestSheet +import com.pinakes.app.ui.screens.detail.LoanRequestSheetContent import com.pinakes.app.ui.theme.PinakesTheme import org.junit.Assert.* import org.junit.Rule @@ -23,7 +24,6 @@ class LoanRequestSheetUiTest { @get:Rule val compose = createComposeRule() private val today = LocalDate.now() - private val context get() = InstrumentationRegistry.getInstrumentation().targetContext /** Six weeks from today: mostly free, a partial day and a full one in the first week. */ private fun calendar(earliest: String? = today.plusDays(1).toString()) = AvailabilityCalendar( @@ -46,12 +46,8 @@ class LoanRequestSheetUiTest { submitting: Boolean = false, onDismiss: () -> Unit = {}, onConfirm: (String) -> Unit = {}, - widthDp: Float? = null, ) = compose.setContent { - // widthDp: lay the sheet out as on a phone that many dp wide, whatever the emulator. - val base = LocalDensity.current - val density = widthDp?.let { Density(context.resources.displayMetrics.widthPixels / it, base.fontScale) } ?: base - CompositionLocalProvider(LocalDensity provides density) { PinakesTheme { + PinakesTheme { LoanRequestSheet( loading = false, calendar = calendar, @@ -60,7 +56,7 @@ class LoanRequestSheetUiTest { onDismiss = onDismiss, onConfirm = onConfirm, ) - } } + } } @Test fun bothButtonsSpanTheSheetAndConfirmSendsTheEarliestDay() { @@ -93,10 +89,16 @@ class LoanRequestSheetUiTest { } @Test fun fallbackPickerKeepsItsSeventhColumnInsideTheSheet() { - // 360dp, the narrowest common phone: Material's seven 48dp columns need the sheet's side padding. - sheet(calendar = null, fallback = true, widthDp = 360f) - // Pixels throughout: the dp helpers of the test rule use the device density, not the forced one. - val sheetRight = compose.onNodeWithTag("loan-sheet").fetchSemanticsNode().boundsInRoot.right + // The sheet's content in a 360dp column, the narrowest common phone, on any emulator: + // the Dialog itself is a window with the device density, so it cannot be narrowed here. + compose.setContent { + PinakesTheme { + Box(Modifier.width(360.dp)) { + LoanRequestSheetContent(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = {}) + } + } + } + val sheet = compose.onNodeWithTag("loan-sheet").getBoundsInRoot() val days = compose.onAllNodes( // Material announces each day as one button whose text is the full date. SemanticsMatcher("a day of the month") { node -> @@ -108,9 +110,9 @@ class LoanRequestSheetUiTest { assertEquals("Every day of the month is laid out", today.lengthOfMonth(), days.size) // boundsInRoot is clipped by the sheet: a cut seventh column shows up as narrow days. // Each day must keep its 40dp circle in view (without the bleed, 24dp of it is left). - val px = context.resources.displayMetrics.widthPixels / 360f - val narrowest = days.minOf { it.boundsInRoot.width } / px + val density = compose.density.density + val narrowest = days.minOf { it.boundsInRoot.width } / density assertTrue("A day is cut by the sheet: ${narrowest}dp of its 40dp circle in view", narrowest >= 39.5f) - assertTrue("A day overflows the sheet", days.maxOf { it.boundsInRoot.right } <= sheetRight + 1f) + assertTrue("A day overflows the sheet", days.maxOf { it.boundsInRoot.right } / density <= sheet.right.value + .5f) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 21dde7d..d5190af 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -221,12 +221,6 @@ internal fun LoanRequestSheet( onDismiss: () -> Unit, onConfirm: (isoDate: String) -> Unit, ) { - val today = LocalDate.now() - // Pre-select earliest_available when offered, else null until the user picks. - var selectedIso by remember(calendar) { - mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() }) - } - // Same sheet as the web's loan request: the card shape of the availability box (20dp), a // serif title, and two buttons stacked at full width in the page's own styles — the filled // confirm and the outlined cancel — instead of Material's text-button row. @@ -237,102 +231,125 @@ internal fun LoanRequestSheet( onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false), ) { - Surface( - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), - modifier = Modifier - // 8dp from the screen edges, as the web sheet on a phone (100vw - 1rem). - .padding(horizontal = Spacing.sm, vertical = Spacing.lg) - .widthIn(max = 480.dp) - .fillMaxWidth() - .testTag("loan-sheet"), - ) { - Column( - Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = Spacing.lg) - .fillMaxWidth(), - ) { - Text( - stringResource(R.string.book_loan_dialog_title), - modifier = Modifier.padding(top = Spacing.lg, bottom = Spacing.md), - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - when { - loading -> { - Row( - Modifier.fillMaxWidth().padding(vertical = Spacing.xxl), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - Spacer(Modifier.width(Spacing.md)) - Text( - stringResource(R.string.book_loan_availability_loading), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } + LoanRequestSheetContent(loading, calendar, fallback, submitting, onDismiss, onConfirm) + } +} - calendar != null && !fallback -> { - LoanCalendar( - calendar = calendar, - selected = selectedIso, - onSelect = { selectedIso = it }, - ) - } +/** + * The sheet itself, without its [Dialog]: a Dialog is a window of its own and takes the device + * density, so the tests lay this out in a fixed width to check a narrow phone on any emulator. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun LoanRequestSheetContent( + loading: Boolean, + calendar: AvailabilityCalendar?, + fallback: Boolean, + submitting: Boolean, + onDismiss: () -> Unit, + onConfirm: (isoDate: String) -> Unit, +) { + val today = LocalDate.now() + // Pre-select earliest_available when offered, else null until the user picks. + var selectedIso by remember(calendar) { + mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() }) + } - else -> { - // Fallback: plain today-or-future picker. - FallbackDatePicker( - today = today, - selectedIso = selectedIso, - onSelect = { selectedIso = it }, - // Material's picker lays out seven 48dp columns: let it use the sheet's - // side padding too, so the last column is never clipped on a phone. - modifier = Modifier.bleedHorizontally(Spacing.lg), - ) + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = Modifier + // 8dp from the screen edges, as the web sheet on a phone (100vw - 1rem). + .padding(horizontal = Spacing.sm, vertical = Spacing.lg) + .widthIn(max = 480.dp) + .fillMaxWidth() + .testTag("loan-sheet"), + ) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .fillMaxWidth(), + ) { + Text( + stringResource(R.string.book_loan_dialog_title), + modifier = Modifier.padding(top = Spacing.lg, bottom = Spacing.md), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + when { + loading -> { + Row( + Modifier.fillMaxWidth().padding(vertical = Spacing.xxl), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(Spacing.md)) Text( - stringResource(R.string.book_loan_availability_error), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = Spacing.sm), + stringResource(R.string.book_loan_availability_loading), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } - // Period hint / prompt. - val hint = selectedIso?.let { - stringResource(R.string.book_loan_period_hint, formatDisplayDate(it)) - } ?: stringResource(R.string.book_loan_pick_prompt) - Text( - text = hint, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(vertical = Spacing.md), - ) + calendar != null && !fallback -> { + LoanCalendar( + calendar = calendar, + selected = selectedIso, + onSelect = { selectedIso = it }, + ) + } - Column( - Modifier.padding(bottom = Spacing.lg), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - PrimaryButton( - label = stringResource(R.string.book_loan_confirm), - onClick = { selectedIso?.let(onConfirm) }, - enabled = selectedIso != null, - loading = submitting, - modifier = Modifier.fillMaxWidth().testTag("loan-confirm"), + else -> { + // Fallback: plain today-or-future picker. + FallbackDatePicker( + today = today, + selectedIso = selectedIso, + onSelect = { selectedIso = it }, + // Material's picker lays out seven 48dp columns: let it use the sheet's + // side padding too, so the last column is never clipped on a phone. + modifier = Modifier.bleedHorizontally(Spacing.lg), ) - SecondaryButton( - label = stringResource(R.string.book_loan_cancel), - onClick = onDismiss, - modifier = Modifier.fillMaxWidth().testTag("loan-cancel"), + Text( + stringResource(R.string.book_loan_availability_error), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = Spacing.sm), ) } } + + // Period hint / prompt. + val hint = selectedIso?.let { + stringResource(R.string.book_loan_period_hint, formatDisplayDate(it)) + } ?: stringResource(R.string.book_loan_pick_prompt) + Text( + text = hint, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(vertical = Spacing.md), + ) + + Column( + Modifier.padding(bottom = Spacing.lg), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + PrimaryButton( + label = stringResource(R.string.book_loan_confirm), + onClick = { selectedIso?.let(onConfirm) }, + enabled = selectedIso != null, + loading = submitting, + modifier = Modifier.fillMaxWidth().testTag("loan-confirm"), + ) + SecondaryButton( + label = stringResource(R.string.book_loan_cancel), + onClick = onDismiss, + modifier = Modifier.fillMaxWidth().testTag("loan-cancel"), + ) + } } } } From c8c283a5198f58a3411e19776cd7733bf2c8d617 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 20 Oct 2026 17:02:48 +0000 Subject: [PATCH 3/6] Say the loan request lasts the library's loan period, not about a month The loan-request dialog said the loan would last "about 1 month" from the chosen day. The server sets the return date from the loan duration in the library's loan settings (loan_duration_days, 30 by default), on POST /reservations as on the website, so a library with a 14 or 60 day period saw a wrong promise. Pinakes #485 made the website's return picker open on that same duration; the app now names the library's standard loan period in all four languages, the way the staff new-loan form already does. The comments that described the return as +1 month say the same. --- app/src/main/java/com/pinakes/app/data/model/Models.kt | 5 +++-- .../com/pinakes/app/data/repository/LibraryRepository.kt | 3 ++- .../com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt | 4 ++-- i18n/de.json | 2 +- i18n/en.json | 2 +- i18n/fr.json | 2 +- i18n/it.json | 2 +- 7 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index cc21cc9..4a8efaa 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -488,8 +488,9 @@ data class AvailabilityDay( @Serializable data class ReservationRequest( @SerialName("book_id") val bookId: Int, - // Single desired start date "yyyy-MM-dd"; the backend computes the return as +1 month - // (same as the website). This is the authoritative field the request flow sends. + // Single desired start date "yyyy-MM-dd"; the backend sets the return from the library's + // configured loan duration (same as the website). This is the authoritative field the + // request flow sends. @SerialName("desired_date") val desiredDate: String? = null, @SerialName("start_date") val startDate: String? = null, // legacy/compat, yyyy-MM-dd @SerialName("end_date") val endDate: String? = null, // legacy/compat, yyyy-MM-dd diff --git a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt index 42f1120..f433437 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/LibraryRepository.kt @@ -62,7 +62,8 @@ class LibraryRepository( /** * Request a loan/reservation for [bookId] starting on [desiredDate] (yyyy-MM-dd). The - * backend computes the return date as +1 month, matching the website behaviour. + * backend sets the return date from the library's configured loan duration + * (loan_duration_days), matching the website behaviour. */ suspend fun reserve( bookId: Int, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt index 084def9..549e768 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailViewModel.kt @@ -160,8 +160,8 @@ class BookDetailViewModel @Inject constructor( /** * Submit a loan/reservation request for [desiredDate] (yyyy-MM-dd, today-or-future). The - * backend computes the return as +1 month. On success we surface the chosen date so the UI - * can confirm exactly what was requested. + * backend sets the return from the library's configured loan duration. On success we surface + * the chosen date so the UI can confirm exactly what was requested. */ fun reserve(desiredDate: String) { if (_state.value.reserveBusy) return diff --git a/i18n/de.json b/i18n/de.json index da7c81b..3dd74ea 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -250,7 +250,7 @@ "cover_viewer_close": "Schließen", "book_loan_dialog_title": "Startdatum wählen", "book_loan_pick_date": "Datum wählen", - "book_loan_period_hint": "Ab %1$s, etwa 1 Monat", + "book_loan_period_hint": "Ab %1$s, für die übliche Leihfrist der Bibliothek", "book_loan_confirm": "Ausleihe anfragen", "book_loan_cancel": "Abbrechen", "book_loan_legend_available": "Verfügbar", diff --git a/i18n/en.json b/i18n/en.json index 24a1131..2cfbc62 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -250,7 +250,7 @@ "cover_viewer_close": "Close", "book_loan_dialog_title": "Choose a start date", "book_loan_pick_date": "Pick a date", - "book_loan_period_hint": "From %1$s, about 1 month", + "book_loan_period_hint": "From %1$s, for the library’s standard loan period", "book_loan_confirm": "Request loan", "book_loan_cancel": "Cancel", "book_loan_legend_available": "Available", diff --git a/i18n/fr.json b/i18n/fr.json index e7a4498..4b9746e 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -250,7 +250,7 @@ "cover_viewer_close": "Fermer", "book_loan_dialog_title": "Choisir une date de début", "book_loan_pick_date": "Choisir une date", - "book_loan_period_hint": "À partir du %1$s, environ 1 mois", + "book_loan_period_hint": "À partir du %1$s, pour la durée de prêt standard de la bibliothèque", "book_loan_confirm": "Demander le prêt", "book_loan_cancel": "Annuler", "book_loan_legend_available": "Disponible", diff --git a/i18n/it.json b/i18n/it.json index 8c3191f..19f5643 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -250,7 +250,7 @@ "cover_viewer_close": "Chiudi", "book_loan_dialog_title": "Scegli una data di inizio", "book_loan_pick_date": "Scegli una data", - "book_loan_period_hint": "Dal %1$s, circa 1 mese", + "book_loan_period_hint": "Dal %1$s, per la durata di prestito standard della biblioteca", "book_loan_confirm": "Richiedi prestito", "book_loan_cancel": "Annulla", "book_loan_legend_available": "Disponibile", From df26241738b33f27010710d10a3eab7fa3c3dff3 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 20 Oct 2026 17:17:13 +0000 Subject: [PATCH 4/6] Show the return date in the loan-request dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile API 1.8.1 (Pinakes #485) adds loan_duration_days to GET /catalog/books/{id}/availability: the days POST /reservations adds to the chosen start to set the return date. The dialog now reads "Start: · Return by: " for the selected day, in all four languages. A server that does not send the field keeps the wording about the library's standard loan period. LoanRequestReturnDateTest covers the date arithmetic, the parsing with and without the field, and both dialog texts. --- .../java/com/pinakes/app/data/model/Models.kt | 3 + .../app/ui/screens/detail/BookDetailScreen.kt | 16 +++- .../pinakes/app/LoanRequestReturnDateTest.kt | 77 +++++++++++++++++++ i18n/de.json | 1 + i18n/en.json | 1 + i18n/fr.json | 1 + i18n/it.json | 1 + 7 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index 4a8efaa..81c113e 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -474,6 +474,9 @@ data class AvailabilityCalendar( @SerialName("earliest_available") val earliestAvailable: String? = null, // "yyyy-MM-dd" @SerialName("unavailable_dates") val unavailableDates: List = emptyList(), val days: List = emptyList(), + // Days POST /reservations adds to the chosen start to set the return date (Mobile API + // 1.8.1). Null on older servers: the dialog then names the standard period without a date. + @SerialName("loan_duration_days") val loanDurationDays: Int? = null, ) @Serializable diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 6661e66..13f1bdd 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -303,9 +303,11 @@ internal fun LoanRequestSheet( } } - // Period hint / prompt. - val hint = selectedIso?.let { - stringResource(R.string.book_loan_period_hint, formatDisplayDate(it)) + // Period hint / prompt: the return date when the server gives the loan duration. + val hint = selectedIso?.let { start -> + calendar?.loanDurationDays?.let { loanReturnDate(start, it) }?.let { end -> + stringResource(R.string.book_loan_period_range, formatDisplayDate(start), formatDisplayDate(end)) + } ?: stringResource(R.string.book_loan_period_hint, formatDisplayDate(start)) } ?: stringResource(R.string.book_loan_pick_prompt) Text( text = hint, @@ -707,6 +709,14 @@ private fun digitalFilename(url: String, fallback: String): String = private val displayDateFormatter: DateTimeFormatter get() = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(com.pinakes.app.ui.common.AppLocale.current()) +/** + * Return date (yyyy-MM-dd) of a loan request starting on [startIso]: the server adds the + * library's loan duration to the start. Null for an unparseable start or a duration below 1. + */ +internal fun loanReturnDate(startIso: String, loanDurationDays: Int): String? = + if (loanDurationDays < 1) null + else runCatching { LocalDate.parse(startIso).plusDays(loanDurationDays.toLong()).toString() }.getOrNull() + /** "2026-06-20" (yyyy-MM-dd) → locale-formatted date for display. Falls back to the raw input. */ private fun formatDisplayDate(iso: String): String = runCatching { LocalDate.parse(iso).format(displayDateFormatter) }.getOrDefault(iso) diff --git a/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt b/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt new file mode 100644 index 0000000..1336d03 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt @@ -0,0 +1,77 @@ +package com.pinakes.app + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollTo +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.screens.detail.LoanRequestSheet +import com.pinakes.app.ui.screens.detail.loanReturnDate +import com.pinakes.app.ui.theme.PinakesTheme +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * The loan-request dialog names the return date the server will set: the chosen start plus + * the `loan_duration_days` of GET /catalog/books/{id}/availability (Mobile API 1.8.1). + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LoanRequestReturnDateTest { + private val compose = createComposeRule() + @get:Rule val rules: RuleChain = RuleChain.outerRule(object : TestWatcher() { + override fun starting(description: Description) { + val app = ApplicationProvider.getApplicationContext() + shadowOf(app.packageManager).addActivityIfNotPresent(ComponentName(app.packageName, ComponentActivity::class.java.name)) + } + }).around(compose) + + private fun show(calendar: AvailabilityCalendar) { + compose.setContent { PinakesTheme { + LoanRequestSheet(loading = false, calendar = calendar, fallback = false, submitting = false, + onDismiss = {}, onConfirm = {}) + } } + } + + @Test fun returnDateIsTheStartPlusTheLoanDuration() { + assertEquals("2030-03-18", loanReturnDate("2030-03-04", 14)) + assertEquals("2030-04-03", loanReturnDate("2030-03-04", 30)) + assertNull(loanReturnDate("2030-03-04", 0)) + assertNull(loanReturnDate("not a date", 14)) + } + + @Test fun availabilityCarriesTheLoanDurationAndOlderServersOmitIt() { + val json = Json { ignoreUnknownKeys = true } + assertEquals(21, json.decodeFromString("""{"total_copies":1,"loan_duration_days":21}""").loanDurationDays) + assertNull(json.decodeFromString("""{"total_copies":1}""").loanDurationDays) + } + + @Test fun dialogShowsTheReturnDateForThePreselectedStart() { + show(AvailabilityCalendar(totalCopies = 1, earliestAvailable = "2030-03-04", loanDurationDays = 14)) + compose.onNodeWithText("Start: ${DateFormat.date("2030-03-04")} · Return by: ${DateFormat.date("2030-03-18")}") + .performScrollTo().assertIsDisplayed() + } + + @Test fun withoutTheDurationTheDialogNamesTheStandardPeriod() { + show(AvailabilityCalendar(totalCopies = 1, earliestAvailable = "2030-03-04")) + compose.onNodeWithText("From ${DateFormat.date("2030-03-04")}, for the library’s standard loan period") + .performScrollTo().assertIsDisplayed() + } +} diff --git a/i18n/de.json b/i18n/de.json index 3dd74ea..fe85b96 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -251,6 +251,7 @@ "book_loan_dialog_title": "Startdatum wählen", "book_loan_pick_date": "Datum wählen", "book_loan_period_hint": "Ab %1$s, für die übliche Leihfrist der Bibliothek", + "book_loan_period_range": "Beginn: %1$s · Rückgabe: %2$s", "book_loan_confirm": "Ausleihe anfragen", "book_loan_cancel": "Abbrechen", "book_loan_legend_available": "Verfügbar", diff --git a/i18n/en.json b/i18n/en.json index 2cfbc62..bc6033d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -251,6 +251,7 @@ "book_loan_dialog_title": "Choose a start date", "book_loan_pick_date": "Pick a date", "book_loan_period_hint": "From %1$s, for the library’s standard loan period", + "book_loan_period_range": "Start: %1$s · Return by: %2$s", "book_loan_confirm": "Request loan", "book_loan_cancel": "Cancel", "book_loan_legend_available": "Available", diff --git a/i18n/fr.json b/i18n/fr.json index 4b9746e..3394b76 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -251,6 +251,7 @@ "book_loan_dialog_title": "Choisir une date de début", "book_loan_pick_date": "Choisir une date", "book_loan_period_hint": "À partir du %1$s, pour la durée de prêt standard de la bibliothèque", + "book_loan_period_range": "Début : %1$s · Retour : %2$s", "book_loan_confirm": "Demander le prêt", "book_loan_cancel": "Annuler", "book_loan_legend_available": "Disponible", diff --git a/i18n/it.json b/i18n/it.json index 19f5643..937b97f 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -251,6 +251,7 @@ "book_loan_dialog_title": "Scegli una data di inizio", "book_loan_pick_date": "Scegli una data", "book_loan_period_hint": "Dal %1$s, per la durata di prestito standard della biblioteca", + "book_loan_period_range": "Inizio: %1$s · Restituzione: %2$s", "book_loan_confirm": "Richiedi prestito", "book_loan_cancel": "Annulla", "book_loan_legend_available": "Disponibile", From bdb593c765825f45548a0a3fa11dcc342f1313df Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 20 Oct 2026 19:39:40 +0000 Subject: [PATCH 5/6] Document the loan-request return-date tests CodeRabbit's docstring check counted the test methods and the show() helper of LoanRequestReturnDateTest as undocumented (45% against the 80% threshold). Each now says what it pins down. Comments only. --- .../test/java/com/pinakes/app/LoanRequestReturnDateTest.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt b/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt index 1336d03..46dadd0 100644 --- a/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt +++ b/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt @@ -43,6 +43,7 @@ class LoanRequestReturnDateTest { } }).around(compose) + /** Renders the loan-request dialog for [calendar], with no-op callbacks. */ private fun show(calendar: AvailabilityCalendar) { compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = calendar, fallback = false, submitting = false, @@ -50,6 +51,7 @@ class LoanRequestReturnDateTest { } } } + /** The server adds the duration to the start; a duration below 1 or a bad start gives no date. */ @Test fun returnDateIsTheStartPlusTheLoanDuration() { assertEquals("2030-03-18", loanReturnDate("2030-03-04", 14)) assertEquals("2030-04-03", loanReturnDate("2030-03-04", 30)) @@ -57,18 +59,21 @@ class LoanRequestReturnDateTest { assertNull(loanReturnDate("not a date", 14)) } + /** Mobile API 1.8.1 sends loan_duration_days; an older server leaves it out and the field is null. */ @Test fun availabilityCarriesTheLoanDurationAndOlderServersOmitIt() { val json = Json { ignoreUnknownKeys = true } assertEquals(21, json.decodeFromString("""{"total_copies":1,"loan_duration_days":21}""").loanDurationDays) assertNull(json.decodeFromString("""{"total_copies":1}""").loanDurationDays) } + /** With the duration, the dialog names the start and the return date of the preselected day. */ @Test fun dialogShowsTheReturnDateForThePreselectedStart() { show(AvailabilityCalendar(totalCopies = 1, earliestAvailable = "2030-03-04", loanDurationDays = 14)) compose.onNodeWithText("Start: ${DateFormat.date("2030-03-04")} · Return by: ${DateFormat.date("2030-03-18")}") .performScrollTo().assertIsDisplayed() } + /** Without it, the dialog falls back to the library's standard loan period, with no date. */ @Test fun withoutTheDurationTheDialogNamesTheStandardPeriod() { show(AvailabilityCalendar(totalCopies = 1, earliestAvailable = "2030-03-04")) compose.onNodeWithText("From ${DateFormat.date("2030-03-04")}, for the library’s standard loan period") From b2e5c39e50cd02a39d88ac9b80e1201c05cf7b79 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 21 Oct 2026 05:45:52 +0000 Subject: [PATCH 6/6] Keep the 1.6.2 verification record at the 22 device tests it ran The merge of main had changed the historical 1.6.2 line and its repeat to 26. --- STATUS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/STATUS.md b/STATUS.md index 652c72d..c41e9d0 100644 --- a/STATUS.md +++ b/STATUS.md @@ -32,8 +32,8 @@ The app now uses the 2026 web design: Geist / Fraunces, warm neutrals, a server- - Debug APK: `pinakes-debug.apk`, copied from `app/build/outputs/apk/debug/app-debug.apk` (generated locally and ignored by Git). - Package: `com.pinakes.app`, version `1.6.2` (19), minSdk 26, target/compileSdk 35. -- Local verification: **200 debug and 200 release unit tests, 26 instrumented tests on Android 15, zero lint errors (101 warnings)**, and successful debug + R8 release builds. The 1.6.2 debug APK installs over 1.6.1 with the same signing certificate. -- Verification commands: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `assembleRelease`, `assembleDebugAndroidTest`; the 26 device tests run through `am instrument` on a separate Android 15 AVD, preserving the authenticated demo device. +- Local verification: **200 debug and 200 release unit tests, 22 instrumented tests on Android 15, zero lint errors (101 warnings)**, and successful debug + R8 release builds. The 1.6.2 debug APK installs over 1.6.1 with the same signing certificate. +- Verification commands: `assembleDebug`, `testDebugUnitTest`, `lintDebug`, `assembleRelease`, `assembleDebugAndroidTest`; the 22 device tests run through `am instrument` on a separate Android 15 AVD, preserving the authenticated demo device. - Unit tests cover the existing contracts and theme contrast/mixing. Compose device tests cover full tall artwork, missing metadata, grid actions, view selection, digital-file cards, narrow search placeholders, circulation actions theme pairings, reachable empty-home actions and query preservation during loading. - Release builds exercise R8 and resource shrinking. Without release credentials the output is unsigned; no store release is published by this change. - macOS standalone emulator startup: `tools/run-emulator.sh` keeps a power assertion scoped to the emulator PID. A control with the identical debug APK reproduced a 21-second startup timeout at host priority 4; three starts with the assertion completed in 2.3–3.1 seconds. After a VM restart through the launcher, five more cold starts completed in 1.66–1.80 seconds with no ANR events. The original startup ANR also coincided with system/launcher stalls. An older AVD still stalled on a later cold boot, and a snapshot retained a system ANR dialog: the assertion does not repair an unhealthy AVD. No Sentry events are suppressed, and no production-device ANR fix is claimed.