diff --git a/DESIGN.md b/DESIGN.md index 6fd6602..06e376c 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, the same responsive month grid lets the reader select any future day. It omits the availability legend and shows a warning: the server checks the request. Weekday and month names follow the chosen app language; the legend wraps on narrow screens. + ### Other screens Wishlist, loans and reservations, profile, events, periodicals (Emeroteca), archive, static pages: 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..0dac80d --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/LoanRequestSheetUiTest.kt @@ -0,0 +1,110 @@ +package com.pinakes.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.width +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 +import org.junit.Test +import java.time.LocalDate + +class LoanRequestSheetUiTest { + @get:Rule val compose = createComposeRule() + + private val today = LocalDate.now() + + /** 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 = {}, + ) = compose.setContent { + 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 fallbackGridKeepsItsSeventhColumnInsideTheSheet() { + // 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 month = java.time.YearMonth.from(today) + val days = (1..month.lengthOfMonth()).map { day -> + compose.onNodeWithTag("calendar-day-${month.atDay(day)}", useUnmergedTree = true).fetchSemanticsNode() + } + assertEquals("Every day of the month is laid out", today.lengthOfMonth(), days.size) + // The fallback now uses the same weighted grid as the availability calendar. + val density = compose.density.density + val narrowest = days.minOf { it.boundsInRoot.width } / density + assertTrue("A day is clipped by the sheet: ${narrowest}dp", narrowest >= 30f) + 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/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index cc21cc9..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 @@ -488,8 +491,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/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 6661e66..6a17191 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 @@ -222,13 +222,9 @@ fun BookDetailScreen( } /** - * Loan-request dialog. While [loading], shows a spinner. With a [calendar], renders the colored + * Loan-request sheet. While [loading], shows a spinner. With a [calendar], renders the colored * [LoanCalendar] (availability-tinted month grid). If [fallback] (availability fetch failed), - * the same month grid lets the user pick any future day, with a note. Returns yyyy-MM-dd. - * - * A plain [Dialog] sized to the screen (max 420dp, 16dp side margins, scrollable) replaces the - * Material DatePickerDialog, whose fixed 360dp width and capped height clipped the month on - * 320-360dp phones. + * uses the same month grid for any future day and a note. Returns the chosen date as yyyy-MM-dd. */ @Composable internal fun LoanRequestSheet( @@ -238,89 +234,136 @@ internal fun LoanRequestSheet( submitting: Boolean, onDismiss: () -> Unit, onConfirm: (isoDate: String) -> Unit, +) { + // 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, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box(Modifier.testTag("loan-dialog")) { + LoanRequestSheetContent(loading, calendar, fallback, submitting, onDismiss, onConfirm) + } + } +} + +/** + * 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. + */ +@Composable +internal fun LoanRequestSheetContent( + loading: Boolean, + calendar: AvailabilityCalendar?, + fallback: Boolean, + submitting: Boolean, + onDismiss: () -> Unit, + onConfirm: (isoDate: String) -> Unit, ) { // Pre-select earliest_available when offered, else null until the user picks. var selectedIso by remember(calendar) { mutableStateOf(calendar?.earliestAvailable?.takeIf { it.isNotBlank() }) } - Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surface, - modifier = Modifier - .padding(horizontal = Spacing.lg, vertical = Spacing.xl) - .widthIn(max = 420.dp) - .fillMaxWidth() - .testTag("loan-dialog"), + 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(), ) { - Column(Modifier.verticalScroll(rememberScrollState()).padding(Spacing.lg)) { - Text( - stringResource(R.string.book_loan_dialog_title), - modifier = Modifier.padding(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)) - 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 -> { - // Availability unknown: any future day, the server decides. - LoanCalendar( - calendar = AvailabilityCalendar(), - selected = selectedIso, - onSelect = { selectedIso = it }, - anyFutureDay = true, - ) + 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), - ) - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = onDismiss) { Text(stringResource(R.string.book_loan_cancel)) } - TextButton( - onClick = { selectedIso?.let(onConfirm) }, - enabled = selectedIso != null && !submitting, - ) { Text(stringResource(R.string.book_loan_confirm)) } + calendar != null && !fallback -> { + LoanCalendar( + calendar = calendar, + selected = selectedIso, + onSelect = { selectedIso = it }, + ) + } + + else -> { + // Unknown availability: preserve main's selectable, localised month grid. + LoanCalendar( + calendar = AvailabilityCalendar(), + selected = selectedIso, + onSelect = { selectedIso = it }, + anyFutureDay = true, + ) + Text( + stringResource(R.string.book_loan_availability_error), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = Spacing.sm), + ) } } + + // The return date uses the server's configured duration (#51). + 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, + 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"), + ) + } } } } @@ -707,6 +750,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/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/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 84d043a..e6317ec 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 @@ -21,7 +21,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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 @@ -44,15 +43,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 @@ -60,12 +57,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 } @@ -73,17 +72,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 = @@ -133,7 +135,7 @@ fun LoanCalendar( /** Availability could not be loaded: every future day is selectable, no colour legend. */ anyFutureDay: Boolean = false, ) { - 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) } @@ -282,8 +284,15 @@ private fun DayCell( // Unknown availability: future days look plain but selectable (no false "unavailable" tint). val tint = if (anyFutureDay && selectable) DayTint(Color.Transparent, MaterialTheme.colorScheme.onSurface) else 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 + } // Fills its weighted square cell (at most 44dp) instead of a fixed 40dp: on a 320-360dp // phone a dialog leaves ~38dp per column, and a fixed size overflowed into its neighbours. @@ -295,9 +304,7 @@ private fun DayCell( .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 @@ -322,20 +329,23 @@ private fun Legend(dark: Boolean) { horizontalArrangement = Arrangement.spacedBy(Spacing.md), verticalArrangement = Arrangement.spacedBy(Spacing.xs), ) { - 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( diff --git a/app/src/test/java/com/pinakes/app/LoanCalendarRegressionTest.kt b/app/src/test/java/com/pinakes/app/LoanCalendarRegressionTest.kt new file mode 100644 index 0000000..1758f1b --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LoanCalendarRegressionTest.kt @@ -0,0 +1,196 @@ +package com.pinakes.app + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.core.os.LocaleListCompat +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.AvailabilityDay +import com.pinakes.app.ui.screens.detail.LoanCalendar +import com.pinakes.app.ui.screens.detail.LoanRequestSheetContent +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.* +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 +import java.time.LocalDate +import java.time.YearMonth +import java.time.format.TextStyle +import java.util.Locale + +/** Behaviour shared by the styled sheet and main's availability/fallback grid after merging #46. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class, qualifiers = "w411dp-h891dp-xxhdpi") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class LoanCalendarRegressionTest { + 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 val month = YearMonth.now().plusMonths(1) + private val target = month.atDay(2) + private var picked: String? = null + private val context get() = ApplicationProvider.getApplicationContext() + + private fun calendar(day: AvailabilityDay = AvailabilityDay(target.toString(), available = 1)) = + AvailabilityCalendar(totalCopies = 2, earliestAvailable = target.toString(), days = listOf(day)) + + private fun grid(data: AvailabilityCalendar = calendar(), fallback: Boolean = false) { + compose.setContent { PinakesTheme { + Box(Modifier.width(320.dp)) { + LoanCalendar(data, selected = null, onSelect = { picked = it }, anyFutureDay = fallback) + } + } } + } + + private fun day(date: LocalDate = target) = compose.onNodeWithTag("calendar-day-$date", useUnmergedTree = true) + + @Test fun aFreeDayCanBeRequested() { + grid() + day().performClick() + compose.runOnIdle { assertEquals(target.toString(), picked) } + } + + @Test fun aPartlyBookedDayCanStillBeRequested() { + grid(calendar(AvailabilityDay(target.toString(), available = 1, loaned = 1, state = "partial"))) + day().performClick() + compose.runOnIdle { assertEquals(target.toString(), picked) } + } + + @Test fun aFullyBookedDayHasNoRequestAction() { + grid(calendar(AvailabilityDay(target.toString(), available = 0, loaned = 2, state = "full"))) + day().assertHasNoClickAction() + } + + @Test fun anUnavailableDateOverridesAStaleFreeEntry() { + grid(calendar().copy(unavailableDates = listOf(target.toString()))) + day().assertHasNoClickAction() + } + + @Test fun zeroFreeCopiesCannotBeRequestedEvenWhenLabelledFree() { + grid(calendar(AvailabilityDay(target.toString(), available = 0, state = "free"))) + day().assertHasNoClickAction() + } + + @Test fun aDayOutsideTheFetchedWindowHasNoRequestAction() { + grid(calendar().copy(days = emptyList())) + day().assertHasNoClickAction() + } + + @Test fun aPastDayCannotBeRequestedDespiteServerAvailability() { + val yesterday = LocalDate.now().minusDays(1) + grid(AvailabilityCalendar(earliestAvailable = yesterday.toString(), + days = listOf(AvailabilityDay(yesterday.toString(), available = 1)))) + day(yesterday).assertHasNoClickAction() + } + + @Test fun fallbackLetsTheReaderRequestAnUnknownFutureDay() { + grid(calendar().copy(days = emptyList()), fallback = true) + day().performClick() + compose.runOnIdle { assertEquals(target.toString(), picked) } + } + + @Test fun fallbackDoesNotAdvertiseUnverifiedAvailabilityInALegend() { + grid(fallback = true) + for (label in listOf(R.string.book_loan_legend_available, R.string.book_loan_legend_limited, R.string.book_loan_legend_unavailable)) { + compose.onNodeWithText(context.getString(label)).assertDoesNotExist() + } + } + + // ComponentActivity has no AppCompat delegate for Android 13's framework LocaleManager. + // Exercise the supported pre-33 locale path, where AppCompat stores the chosen language. + @Config(sdk = [32]) + @Test fun monthNamesFollowTheChosenAppLanguageInsteadOfTheDeviceLanguage() { + val before = AppCompatDelegate.getApplicationLocales() + try { + AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags("fr")) + grid() + val name = month.month.getDisplayName(TextStyle.FULL, Locale.FRENCH) + .replaceFirstChar { it.titlecase(Locale.FRENCH) } + compose.onNodeWithText("$name ${month.year}").assertIsDisplayed() + } finally { + AppCompatDelegate.setApplicationLocales(before) + } + } + + @Test fun loadingLeavesCancelAvailableAndPreventsAnEmptyRequest() { + compose.setContent { PinakesTheme { + LoanRequestSheetContent(true, null, false, false, {}, { picked = it }) + } } + compose.onNodeWithTag("loan-confirm").assertIsNotEnabled() + compose.onNodeWithTag("loan-cancel").assertIsEnabled() + compose.onNodeWithTag("calendar-grid").assertDoesNotExist() + assertNull(picked) + } + + @Test fun availabilityArrivingAfterLoadingPreselectsTheServerSuggestedDay() { + var data by mutableStateOf(null) + compose.setContent { PinakesTheme { + LoanRequestSheetContent(data == null, data, false, false, {}, { picked = it }) + } } + compose.runOnIdle { data = calendar() } + compose.onNodeWithTag("loan-confirm").performScrollTo().assertIsEnabled().performClick() + compose.runOnIdle { assertEquals(target.toString(), picked) } + } + + @Test fun confirmingSendsTheReadersChoiceInsteadOfTheInitialSuggestion() { + val next = target.plusDays(1) + val data = calendar().copy(days = listOf(AvailabilityDay(target.toString(), available = 1), + AvailabilityDay(next.toString(), available = 1))) + compose.setContent { PinakesTheme { + LoanRequestSheetContent(false, data, false, false, {}, { picked = it }) + } } + day(next).performScrollTo().performClick() + compose.onNodeWithTag("loan-confirm").performScrollTo().performClick() + compose.runOnIdle { assertEquals(next.toString(), picked) } + } + + @Test fun fallbackSelectionIsConfirmedAsAnIsoDate() { + val today = LocalDate.now() + compose.setContent { PinakesTheme { + LoanRequestSheetContent(false, null, true, false, {}, { picked = it }) + } } + compose.onNodeWithTag("loan-confirm").assertIsNotEnabled() + day(today).performScrollTo().performClick() + compose.onNodeWithTag("loan-confirm").performScrollTo().performClick() + compose.runOnIdle { assertEquals(today.toString(), picked) } + } + + @Test fun theButtonsStayReachableAndInsideTheSheetOnAShortNarrowScreen() { + var dismissed = false + compose.setContent { PinakesTheme { + Box(Modifier.width(320.dp).height(240.dp)) { + LoanRequestSheetContent(false, calendar(), false, false, { dismissed = true }, {}) + } + } } + compose.onNodeWithTag("loan-cancel").performScrollTo().assertIsDisplayed() + val sheet = compose.onNodeWithTag("loan-sheet").getBoundsInRoot() + val cancel = compose.onNodeWithTag("loan-cancel").getBoundsInRoot() + assertTrue(cancel.left >= sheet.left && cancel.right <= sheet.right) + compose.onNodeWithTag("loan-cancel").performClick() + compose.runOnIdle { assertTrue(dismissed) } + } +} 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..46dadd0 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LoanRequestReturnDateTest.kt @@ -0,0 +1,82 @@ +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) + + /** 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, + onDismiss = {}, onConfirm = {}) + } } + } + + /** 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)) + assertNull(loanReturnDate("2030-03-04", 0)) + 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") + .performScrollTo().assertIsDisplayed() + } +} diff --git a/i18n/de.json b/i18n/de.json index da7c81b..fe85b96 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -250,7 +250,8 @@ "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_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 24a1131..bc6033d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -250,7 +250,8 @@ "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_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 e7a4498..3394b76 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -250,7 +250,8 @@ "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_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 8c3191f..937b97f 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -250,7 +250,8 @@ "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_period_range": "Inizio: %1$s · Restituzione: %2$s", "book_loan_confirm": "Richiedi prestito", "book_loan_cancel": "Annulla", "book_loan_legend_available": "Disponibile",