From 6812826886bfcc2c1a260639c3de44bd0f8b77ce Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 16:57:55 +0200 Subject: [PATCH 01/62] Add CameraX and ZXing for the staff ISBN scanner CameraX 1.4.1 (camera2, lifecycle, view) drives the preview and frame analysis; ZXing core 3.5.3 decodes EAN-13 in pure Java, so scanning needs no Google Play services. --- app/build.gradle.kts | 7 +++++++ gradle/libs.versions.toml | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 66e715b..9d085f0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -317,6 +317,13 @@ dependencies { implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.work.runtime.ktx) + // Staff ISBN scanning: CameraX preview/analysis + ZXing EAN-13 decoding. + // ZXing is pure Java and needs no Google Play services. + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) + implementation(libs.zxing.core) + // Hilt — dependency injection (trial: Home slice migrated, ServiceLocator bridges the rest). implementation(libs.hilt.android) kapt(libs.hilt.compiler) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5858ad5..d304f6c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -17,6 +17,8 @@ appcompat = "1.7.0" media3 = "1.4.1" room = "2.6.1" work = "2.9.1" +camerax = "1.4.1" +zxing = "3.5.3" sentry = "4.14.1" hilt = "2.52" hiltNavigationCompose = "1.2.0" @@ -27,6 +29,10 @@ testCore = "1.6.1" testRunner = "1.6.2" [libraries] +androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } +androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" } +androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } +zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycle" } androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } From 554b853b8e0c8b9fd5279fb95cd383c5b97f6882 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 16:58:45 +0200 Subject: [PATCH 02/62] Add the data layer for staff management, offline catalogue and loans calendar Mobile API 1.6.0 support without any UI change yet: the staff_catalog, catalog_sync and calendar_feed health flags (off on older servers); the user role kept in SessionStore from login and GET /me, with StaffAccess deciding who sees the staff area; StaffApi and StaffRepository for every /staff route (a 403 drops the stored role); tolerant models for lookups, ISBN lookups, books, authors, publishers, genres and shelves; 409 duplicate and conflict context kept in ApiResult.Failure.meta. Pure, unit-tested logic: IsbnCodec (EAN-13 and ISBN-10 checksums, ISBN-10 to 13), ScanDebouncer (two identical reads, 3 s suppression, pause), StaffBookForm with StaffBookWrite (create body, true patch for edits that re-sends preserved contributors), SyncMerge (last change wins, full resync, weekly forced pass) and CalendarLinks. Room schema v2 replaces the 40-row snapshot with the whole catalogue plus cached loans, reservations, wishlist and sync bookkeeping. CatalogRepository.syncCatalog() pages through GET /catalog/sync, applies the result in one transaction and only then stores the watermark; servers without the endpoint keep the old snapshot. Loans and wishlist are written through on every fetch and purged on sign-out or a new sign-in. The sync worker refreshes the personal lists too, the cache database is excluded from backups and the foreground observer also refreshes the role. --- .../com/pinakes/app/PinakesApplication.kt | 4 +- .../app/data/calendar/CalendarLinks.kt | 37 ++ .../com/pinakes/app/data/local/AppDatabase.kt | 19 +- .../com/pinakes/app/data/local/CachedBook.kt | 113 ++++- .../com/pinakes/app/data/local/CatalogDao.kt | 65 ++- .../pinakes/app/data/local/LibraryCache.kt | 118 ++++++ .../com/pinakes/app/data/local/LibraryDao.kt | 72 ++++ .../pinakes/app/data/model/CalendarModels.kt | 15 + .../java/com/pinakes/app/data/model/Models.kt | 13 + .../com/pinakes/app/data/model/StaffModels.kt | 392 ++++++++++++++++++ .../com/pinakes/app/data/model/SyncModels.kt | 47 +++ .../com/pinakes/app/data/network/ApiResult.kt | 12 +- .../pinakes/app/data/network/NetworkModule.kt | 11 + .../pinakes/app/data/network/PinakesApi.kt | 21 + .../com/pinakes/app/data/network/StaffApi.kt | 127 ++++++ .../app/data/repository/AuthRepository.kt | 28 ++ .../app/data/repository/CalendarRepository.kt | 28 ++ .../app/data/repository/CatalogRepository.kt | 131 +++++- .../app/data/repository/LibraryRepository.kt | 42 +- .../app/data/repository/StaffRepository.kt | 146 +++++++ .../app/data/repository/WishlistRepository.kt | 25 +- .../app/data/staff/CoverImageEncoder.kt | 68 +++ .../com/pinakes/app/data/staff/IsbnCodec.kt | 138 ++++++ .../pinakes/app/data/staff/ScanDebouncer.kt | 67 +++ .../com/pinakes/app/data/staff/StaffAccess.kt | 16 + .../pinakes/app/data/staff/StaffBookForm.kt | 284 +++++++++++++ .../pinakes/app/data/store/FeatureStore.kt | 18 + .../pinakes/app/data/store/SessionStore.kt | 26 +- .../app/data/sync/CatalogSyncWorker.kt | 21 +- .../com/pinakes/app/data/sync/SyncMerge.kt | 71 ++++ .../main/java/com/pinakes/app/di/AppModule.kt | 30 +- .../com/pinakes/app/ui/common/AppViewModel.kt | 18 + app/src/main/res/xml/backup_rules.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 9 + .../pinakes/app/CachedBookMapperEdgeTest.kt | 24 +- .../com/pinakes/app/CachedBookMapperTest.kt | 62 ++- .../com/pinakes/app/CatalogDaoEdgeTest.kt | 51 ++- .../java/com/pinakes/app/CatalogDaoTest.kt | 136 ++++-- .../java/com/pinakes/app/LibraryDaoTest.kt | 103 +++++ .../pinakes/app/staff/CalendarLinksTest.kt | 36 ++ .../com/pinakes/app/staff/IsbnCodecTest.kt | 87 ++++ .../pinakes/app/staff/ScanDebouncerTest.kt | 61 +++ .../com/pinakes/app/staff/StaffAccessTest.kt | 28 ++ .../app/staff/StaffBookFormStateTest.kt | 179 ++++++++ .../app/staff/StaffModelsParsingTest.kt | 126 ++++++ .../com/pinakes/app/staff/SyncMergeTest.kt | 88 ++++ 46 files changed, 3092 insertions(+), 125 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/data/calendar/CalendarLinks.kt create mode 100644 app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt create mode 100644 app/src/main/java/com/pinakes/app/data/local/LibraryDao.kt create mode 100644 app/src/main/java/com/pinakes/app/data/model/CalendarModels.kt create mode 100644 app/src/main/java/com/pinakes/app/data/model/StaffModels.kt create mode 100644 app/src/main/java/com/pinakes/app/data/model/SyncModels.kt create mode 100644 app/src/main/java/com/pinakes/app/data/network/StaffApi.kt create mode 100644 app/src/main/java/com/pinakes/app/data/repository/CalendarRepository.kt create mode 100644 app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/CoverImageEncoder.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/IsbnCodec.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/ScanDebouncer.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/StaffAccess.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt create mode 100644 app/src/main/java/com/pinakes/app/data/sync/SyncMerge.kt create mode 100644 app/src/test/java/com/pinakes/app/LibraryDaoTest.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/CalendarLinksTest.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/IsbnCodecTest.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/ScanDebouncerTest.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/StaffAccessTest.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/StaffModelsParsingTest.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/SyncMergeTest.kt diff --git a/app/src/main/java/com/pinakes/app/PinakesApplication.kt b/app/src/main/java/com/pinakes/app/PinakesApplication.kt index 7dbcc24..25e6701 100644 --- a/app/src/main/java/com/pinakes/app/PinakesApplication.kt +++ b/app/src/main/java/com/pinakes/app/PinakesApplication.kt @@ -71,7 +71,9 @@ class PinakesApplication : Application(), ImageLoaderFactory { this@PinakesApplication, CatalogSyncWorker.Deps::class.java, ) if (!deps.session().isLoggedIn()) return - appScope.launch { deps.catalogRepository().refreshCatalog() } + appScope.launch { deps.catalogRepository().syncCatalog() } + // Pick up a role change made on the server (staff area visibility). + appScope.launch { deps.authRepository().refreshSession() } } }) diff --git a/app/src/main/java/com/pinakes/app/data/calendar/CalendarLinks.kt b/app/src/main/java/com/pinakes/app/data/calendar/CalendarLinks.kt new file mode 100644 index 0000000..0f800b3 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/calendar/CalendarLinks.kt @@ -0,0 +1,37 @@ +package com.pinakes.app.data.calendar + +/** + * URL helpers for the personal loans calendar feed. Pure, unit-tested. + * + * Calendar apps subscribe to `webcal://` URLs (the same resource over HTTPS); Google Calendar + * on Android cannot subscribe from the device at all, so the sheet leads with copying the + * HTTPS link. + */ +object CalendarLinks { + + /** `https://host/x.ics` → `webcal://host/x.ics`; `http://` (loopback dev) likewise. Other input is returned unchanged. */ + fun toWebcal(url: String): String { + val trimmed = url.trim() + return when { + trimmed.startsWith("https://", ignoreCase = true) -> "webcal://" + trimmed.substring("https://".length) + trimmed.startsWith("http://", ignoreCase = true) -> "webcal://" + trimmed.substring("http://".length) + else -> trimmed + } + } + + /** `webcal://` → `https://`, for the browser fallback. Other input is returned unchanged. */ + fun toHttps(url: String): String { + val trimmed = url.trim() + return if (trimmed.startsWith("webcal://", ignoreCase = true)) "https://" + trimmed.substring("webcal://".length) else trimmed + } + + /** + * The URL to open in a calendar app: the server's `webcal_url` when present, else the + * feed URL with its scheme swapped. Null when the feed is disabled. + */ + fun subscribeUrl(feedUrl: String?, webcalUrl: String?): String? = when { + !webcalUrl.isNullOrBlank() -> webcalUrl.trim() + !feedUrl.isNullOrBlank() -> toWebcal(feedUrl) + else -> null + } +} diff --git a/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt index 2995974..2a115f8 100644 --- a/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt +++ b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt @@ -5,12 +5,25 @@ import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase -/** Local cache database. The cache is disposable, so destructive migration is fine. */ -@Database(entities = [CachedBook::class], version = 1, exportSchema = false) +/** + * Local cache database. Everything in it can be rebuilt from the server, so schema changes use + * destructive migration: v1 (40-row snapshot) → v2 (whole catalogue + loans + wishlist + sync + * bookkeeping) simply starts empty and the next sync refills it. Excluded from backups + * (`backup_rules.xml`, `data_extraction_rules.xml`): it belongs to one instance and one user. + */ +@Database( + entities = [CachedBook::class, CachedLoan::class, CachedWishlistItem::class, SyncMeta::class], + version = 2, + exportSchema = false, +) abstract class AppDatabase : RoomDatabase() { abstract fun catalogDao(): CatalogDao + abstract fun libraryDao(): LibraryDao + abstract fun syncMetaDao(): SyncMetaDao companion object { + const val FILE_NAME = "pinakes-cache.db" + @Volatile private var INSTANCE: AppDatabase? = null @@ -19,7 +32,7 @@ abstract class AppDatabase : RoomDatabase() { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, - "pinakes-cache.db", + FILE_NAME, ).fallbackToDestructiveMigration().build().also { INSTANCE = it } } } diff --git a/app/src/main/java/com/pinakes/app/data/local/CachedBook.kt b/app/src/main/java/com/pinakes/app/data/local/CachedBook.kt index 9768eec..c27a23c 100644 --- a/app/src/main/java/com/pinakes/app/data/local/CachedBook.kt +++ b/app/src/main/java/com/pinakes/app/data/local/CachedBook.kt @@ -1,31 +1,51 @@ package com.pinakes.app.data.local import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey +import com.pinakes.app.data.model.Availability +import com.pinakes.app.data.model.AuthorRef +import com.pinakes.app.data.model.BookDetail +import com.pinakes.app.data.model.BookLocation import com.pinakes.app.data.model.BookSummary +import com.pinakes.app.data.model.GenreLabel +import com.pinakes.app.data.model.SyncBook /** - * A catalog list item cached locally so the catalog renders instantly and works - * offline (no per-open network round-trip for the list or its covers). Mirrors - * [BookSummary]; [position] preserves the server's ordering for the snapshot. + * One catalogue title cached locally for offline browsing (Room schema v2). + * + * v1 held only the first 40 summaries in server order (`position`). v2 holds the whole + * catalogue, kept current by the delta sync (`GET /catalog/sync`), plus the fields an offline + * detail page needs. Ordering is `updatedAt DESC, id DESC` (newest first) or by title. + * [updatedAt] is the server's `Y-m-d H:i:s` string, which sorts chronologically as text; + * rows cached from a plain search page (older servers) keep it empty. */ -@Entity(tableName = "cached_books") +@Entity( + tableName = "cached_books", + indices = [Index("title"), Index("updatedAt")], +) data class CachedBook( @PrimaryKey val id: Int, - val position: Int, val title: String, - val subtitle: String?, - val author: String?, - val publisher: String?, - val genre: String?, - val year: Int?, - val language: String?, - val mediaType: String?, - val isbn13: String?, - val coverUrl: String?, - val copiesTotal: Int, - val copiesAvailable: Int, - val loanableNow: Boolean, + val subtitle: String? = null, + val author: String? = null, + val publisher: String? = null, + val genre: String? = null, + val year: Int? = null, + val language: String? = null, + val mediaType: String? = null, + val isbn13: String? = null, + val isbn10: String? = null, + val ean: String? = null, + val pages: Int? = null, + val description: String? = null, + val locationLabel: String? = null, + val coverUrl: String? = null, + val copiesTotal: Int = 0, + val copiesAvailable: Int = 0, + val loanableNow: Boolean = false, + val isWanted: Boolean = false, + val updatedAt: String = "", ) fun CachedBook.toSummary(): BookSummary = BookSummary( @@ -45,9 +65,9 @@ fun CachedBook.toSummary(): BookSummary = BookSummary( loanableNow = loanableNow, ) -fun BookSummary.toCached(position: Int): CachedBook = CachedBook( +/** A summary from a search page: no offline-only fields, no change timestamp. */ +fun BookSummary.toCached(): CachedBook = CachedBook( id = id, - position = position, title = title, subtitle = subtitle, author = author, @@ -62,3 +82,58 @@ fun BookSummary.toCached(position: Int): CachedBook = CachedBook( copiesAvailable = copiesAvailable, loanableNow = loanableNow, ) + +fun SyncBook.toCached(): CachedBook = CachedBook( + id = id, + title = title, + subtitle = subtitle, + author = author, + publisher = publisher, + genre = genre, + year = year, + language = language, + mediaType = mediaType, + isbn13 = isbn13, + isbn10 = isbn10, + ean = ean, + pages = pages, + description = description, + locationLabel = locationLabel, + coverUrl = coverUrl, + copiesTotal = copiesTotal, + copiesAvailable = copiesAvailable, + loanableNow = loanableNow || copiesAvailable > 0, + isWanted = isWanted, + updatedAt = updatedAt.orEmpty(), +) + +/** + * A summary-level book page built from the cache when the server is unreachable. Personal + * history, digital files, reviews and the circulation calendar need the server and are left + * empty; the screen shows that actions are unavailable offline. + */ +fun CachedBook.toOfflineDetail(): BookDetail = BookDetail( + id = id, + title = title, + subtitle = subtitle, + year = year, + language = language, + pages = pages, + description = description, + isbn13 = isbn13, + isbn10 = isbn10, + ean = ean, + mediaType = mediaType, + coverUrl = coverUrl, + genre = genre?.let { GenreLabel(name = it) }, + publisher = publisher, + // The cached label is the server's joined author string; it stays one entry rather than + // being split on commas, which would break "Surname, Name" forms. + authors = listOfNotNull(author?.takeIf { it.isNotBlank() }?.let { AuthorRef(name = it) }), + availability = Availability( + copiesTotal = copiesTotal, + copiesAvailable = copiesAvailable, + loanableNow = loanableNow, + ), + location = locationLabel?.takeIf { it.isNotBlank() }?.let { BookLocation(label = it) }, +) diff --git a/app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt b/app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt index bd5adbb..73a0640 100644 --- a/app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt +++ b/app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt @@ -10,23 +10,76 @@ import kotlinx.coroutines.flow.Flow @Dao interface CatalogDao { - /** Reactive cached catalog snapshot, in the server's order. */ - @Query("SELECT * FROM cached_books ORDER BY position ASC") - fun observeAll(): Flow> + /** Newest titles first (sync change time, then id): the offline Home shelf. */ + @Query("SELECT * FROM cached_books ORDER BY updatedAt DESC, id DESC LIMIT :limit") + fun observeNewest(limit: Int): Flow> @Query("SELECT COUNT(*) FROM cached_books") suspend fun count(): Int + @Query("SELECT * FROM cached_books WHERE id = :id") + suspend fun byId(id: Int): CachedBook? + + /** Insert or replace by id. */ @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertAll(books: List) + suspend fun upsertAll(books: List) + + @Query("DELETE FROM cached_books WHERE id IN (:ids)") + suspend fun deleteIds(ids: List) @Query("DELETE FROM cached_books") suspend fun clear() - /** Atomically replace the whole cache with a fresh catalog snapshot. */ + /** Atomically replace the whole cache with a fresh catalogue snapshot (full resync). */ @Transaction suspend fun replaceAll(books: List) { clear() - insertAll(books) + books.chunked(CHUNK).forEach { upsertAll(it) } } + + /** Apply one delta sync in a single transaction. */ + @Transaction + suspend fun applyDelta(upserts: List, deleteIds: List) { + deleteIds.chunked(CHUNK).forEach { deleteIds(it) } + upserts.chunked(CHUNK).forEach { upsertAll(it) } + } + + /** + * Offline catalogue search. [q] matches title, subtitle, author, publisher and the + * identifiers (LIKE is case-insensitive for ASCII in SQLite); empty strings disable a + * filter. [sort] is `title_asc`, `title_desc` or `newest`. + */ + @Query( + """ + SELECT * FROM cached_books + WHERE (:q = '' OR title LIKE '%' || :q || '%' OR subtitle LIKE '%' || :q || '%' + OR author LIKE '%' || :q || '%' OR publisher LIKE '%' || :q || '%' + OR isbn13 LIKE '%' || :q || '%' OR isbn10 LIKE '%' || :q || '%' OR ean LIKE '%' || :q || '%') + AND (:author = '' OR author LIKE '%' || :author || '%') + AND (:publisher = '' OR publisher LIKE '%' || :publisher || '%') + AND (:language = '' OR language = :language) + AND (:genre = '' OR genre = :genre) + AND (:availableOnly = 0 OR loanableNow = 1 OR copiesAvailable > 0) + ORDER BY + CASE WHEN :sort = 'newest' THEN updatedAt END DESC, + CASE WHEN :sort = 'title_desc' THEN title END COLLATE NOCASE DESC, + title COLLATE NOCASE ASC, + id DESC + LIMIT :limit OFFSET :offset + """ + ) + suspend fun search( + q: String, + author: String, + publisher: String, + language: String, + genre: String, + availableOnly: Boolean, + sort: String, + limit: Int, + offset: Int, + ): List } + +/** Stay well under SQLite's bound-variable limit on large catalogues. */ +private const val CHUNK = 500 diff --git a/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt b/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt new file mode 100644 index 0000000..245947f --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/local/LibraryCache.kt @@ -0,0 +1,118 @@ +package com.pinakes.app.data.local + +import androidx.room.Entity +import com.pinakes.app.data.model.LoanItem +import com.pinakes.app.data.model.LoansData +import com.pinakes.app.data.model.ReservationItem +import com.pinakes.app.data.model.WishlistItem +import androidx.room.PrimaryKey + +/** + * The signed-in user's loans and reservations, cached for offline reading. One table for all + * four lists: [bucket] is `pending`, `active`, `history` or `reservation`, and [position] + * keeps the server's order inside a bucket. Reservation-only columns stay null for loans. + */ +@Entity(tableName = "cached_loans", primaryKeys = ["bucket", "id"]) +data class CachedLoan( + val bucket: String, + val id: Int, + val position: Int, + val bookId: Int, + val title: String, + val coverUrl: String? = null, + val status: String = "", + val statusLabel: String? = null, + val requestedAt: String? = null, + val loanedAt: String? = null, + val dueAt: String? = null, + val dueAttention: Boolean = false, + val returnedAt: String? = null, + val renewals: Int? = null, + val cancellable: Boolean = false, + val queuePosition: Int? = null, + val requestedFrom: String? = null, + val requestedTo: String? = null, + val reservedAt: String? = null, + val expiresAt: String? = null, +) { + companion object { + const val PENDING = "pending" + const val ACTIVE = "active" + const val HISTORY = "history" + const val RESERVATION = "reservation" + val LOAN_BUCKETS = listOf(PENDING, ACTIVE, HISTORY) + } +} + +/** The signed-in user's wishlist, in server order. */ +@Entity(tableName = "cached_wishlist") +data class CachedWishlistItem( + @PrimaryKey val bookId: Int, + val position: Int, + val title: String, + val author: String? = null, + val year: Int? = null, + val coverUrl: String? = null, + val copiesAvailable: Int = 0, + val loanableNow: Boolean = false, +) + +/** Small key/value store for sync bookkeeping (watermark, last full resync). */ +@Entity(tableName = "sync_meta") +data class SyncMeta( + @PrimaryKey val key: String, + val value: String, +) { + companion object { + /** `server_time` of the last completed catalogue sync: the next `since`. */ + const val CATALOG_SERVER_TIME = "catalog_server_time" + + /** Epoch millis of the last completed full resync (forced again after 7 days). */ + const val CATALOG_LAST_FULL_AT = "catalog_last_full_at" + } +} + +fun LoanItem.toCached(bucket: String, position: Int): CachedLoan = CachedLoan( + bucket = bucket, id = id, position = position, bookId = bookId, title = title, coverUrl = coverUrl, + status = status, statusLabel = statusLabel, requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, + dueAttention = dueAttention, returnedAt = returnedAt, renewals = renewals, cancellable = cancellable, +) + +fun CachedLoan.toLoanItem(): LoanItem = LoanItem( + id = id, bookId = bookId, title = title, coverUrl = coverUrl, status = status, statusLabel = statusLabel, + requestedAt = requestedAt, loanedAt = loanedAt, dueAt = dueAt, dueAttention = dueAttention, + returnedAt = returnedAt, renewals = renewals, cancellable = cancellable, +) + +fun ReservationItem.toCached(position: Int): CachedLoan = CachedLoan( + bucket = CachedLoan.RESERVATION, id = id, position = position, bookId = bookId, title = title, + coverUrl = coverUrl, status = status, queuePosition = queuePosition, requestedFrom = requestedFrom, + requestedTo = requestedTo, reservedAt = reservedAt, expiresAt = expiresAt, +) + +fun CachedLoan.toReservationItem(): ReservationItem = ReservationItem( + id = id, bookId = bookId, title = title, coverUrl = coverUrl, status = status, queuePosition = queuePosition, + requestedFrom = requestedFrom, requestedTo = requestedTo, reservedAt = reservedAt, expiresAt = expiresAt, +) + +fun LoansData.toCached(): List = + pending.mapIndexed { i, l -> l.toCached(CachedLoan.PENDING, i) } + + active.mapIndexed { i, l -> l.toCached(CachedLoan.ACTIVE, i) } + + history.mapIndexed { i, l -> l.toCached(CachedLoan.HISTORY, i) } + +/** Rebuild the three loan lists from cached rows (rows are already in bucket/position order). */ +fun List.toLoansData(): LoansData = LoansData( + pending = filter { it.bucket == CachedLoan.PENDING }.map { it.toLoanItem() }, + active = filter { it.bucket == CachedLoan.ACTIVE }.map { it.toLoanItem() }, + history = filter { it.bucket == CachedLoan.HISTORY }.map { it.toLoanItem() }, +) + +fun WishlistItem.toCached(position: Int): CachedWishlistItem = CachedWishlistItem( + bookId = bookId, position = position, title = title, author = author, year = year, coverUrl = coverUrl, + copiesAvailable = copiesAvailable, loanableNow = loanableNow, +) + +fun CachedWishlistItem.toWishlistItem(): WishlistItem = WishlistItem( + bookId = bookId, title = title, author = author, year = year, coverUrl = coverUrl, + copiesAvailable = copiesAvailable, loanableNow = loanableNow, +) diff --git a/app/src/main/java/com/pinakes/app/data/local/LibraryDao.kt b/app/src/main/java/com/pinakes/app/data/local/LibraryDao.kt new file mode 100644 index 0000000..87abea8 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/local/LibraryDao.kt @@ -0,0 +1,72 @@ +package com.pinakes.app.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow + +/** The signed-in user's loans, reservations and wishlist, cached for offline reading. */ +@Dao +interface LibraryDao { + + @Query("SELECT * FROM cached_loans ORDER BY bucket ASC, position ASC") + fun observeLoans(): Flow> + + @Query("SELECT * FROM cached_loans WHERE bucket IN (:buckets) ORDER BY bucket ASC, position ASC") + suspend fun loans(buckets: List): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertLoans(rows: List) + + @Query("DELETE FROM cached_loans WHERE bucket IN (:buckets)") + suspend fun deleteBuckets(buckets: List) + + /** Replace the given buckets with [rows] atomically (other buckets are untouched). */ + @Transaction + suspend fun replaceLoans(buckets: List, rows: List) { + deleteBuckets(buckets) + insertLoans(rows) + } + + @Query("SELECT * FROM cached_wishlist ORDER BY position ASC") + fun observeWishlist(): Flow> + + @Query("SELECT * FROM cached_wishlist ORDER BY position ASC") + suspend fun wishlist(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertWishlist(rows: List) + + @Query("DELETE FROM cached_wishlist") + suspend fun clearWishlist() + + @Transaction + suspend fun replaceWishlist(rows: List) { + clearWishlist() + insertWishlist(rows) + } + + @Query("DELETE FROM cached_loans") + suspend fun clearLoans() + + /** Drop everything personal: called on sign-out and before a different user signs in. */ + @Transaction + suspend fun clearAll() { + clearLoans() + clearWishlist() + } +} + +@Dao +interface SyncMetaDao { + @Query("SELECT value FROM sync_meta WHERE `key` = :key") + suspend fun get(key: String): String? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun put(meta: SyncMeta) + + @Query("DELETE FROM sync_meta") + suspend fun clear() +} diff --git a/app/src/main/java/com/pinakes/app/data/model/CalendarModels.kt b/app/src/main/java/com/pinakes/app/data/model/CalendarModels.kt new file mode 100644 index 0000000..6b399f6 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/CalendarModels.kt @@ -0,0 +1,15 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// ---------- Personal loans calendar (`/me/calendar`, Mobile API 1.6.0) ---------- + +@Serializable +data class CalendarStatus( + val enabled: Boolean = false, + @SerialName("feed_url") val feedUrl: String? = null, + @SerialName("webcal_url") val webcalUrl: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("rotated_at") val rotatedAt: String? = null, +) 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 3cdef28..86b3fc7 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 @@ -21,6 +21,14 @@ data class Meta( // the list was cut short. Nullable with a null default on purpose: servers older than // the field simply omit it, and absent must read as "not truncated", never as "true". val truncated: Boolean? = null, + // Keyset pagination extras (staff list, catalogue sync). Absent on older endpoints. + val count: Int? = null, + val limit: Int? = null, + @SerialName("has_more") val hasMore: Boolean? = null, + // Error context: 409 `duplicate` carries the existing record, 409 `conflict` a reason + // (e.g. `active_circulation`). Kept as raw JSON so each caller decodes its own shape. + val existing: kotlinx.serialization.json.JsonElement? = null, + val reason: String? = null, ) @Serializable @@ -61,6 +69,11 @@ data class HealthFeatures( val reviews: Boolean = false, val archives: Boolean = false, val desiderata: Boolean = false, + // Mobile API 1.6.0: staff content management, delta catalogue sync and the personal + // loans calendar feed. Older servers omit them, so they default to off. + @SerialName("staff_catalog") val staffCatalog: Boolean = false, + @SerialName("catalog_sync") val catalogSync: Boolean = false, + @SerialName("calendar_feed") val calendarFeed: Boolean = false, ) // ---------- Auth ---------- diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt new file mode 100644 index 0000000..1312b42 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt @@ -0,0 +1,392 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +// Staff content management (Mobile API 1.6.0, `/staff/...`). Field names mirror the frozen +// server contract; every field has a default so an older or newer server never breaks +// decoding (the shared Json has ignoreUnknownKeys + coerceInputValues + isLenient, so +// numbers sent as strings and the reverse are both accepted). + +/** `{id, name}` pair used for genres, publishers and pickers. */ +@Serializable +data class IdName(val id: Int = 0, val name: String = "") + +// ---------- Lookups ---------- + +/** One selectable value of a lookup list (`{value,label}` or `{code,label}`). */ +data class LookupOption(val value: String, val label: String) + +/** + * `GET /staff/lookups`. Each list is kept as raw JSON and normalised by [options]: the server + * sends `{value,label}` objects (languages use `{code,label}`), but a plain string list is + * accepted too. + */ +@Serializable +data class StaffLookups( + val languages: List = emptyList(), + @SerialName("media_types") val mediaTypes: List = emptyList(), + val formats: List = emptyList(), + @SerialName("acquisition_types") val acquisitionTypes: List = emptyList(), + @SerialName("contributor_roles") val contributorRoles: List = emptyList(), + @SerialName("copy_states") val copyStates: List = emptyList(), +) { + val languageOptions: List get() = options(languages) + val mediaTypeOptions: List get() = options(mediaTypes) + val formatOptions: List get() = options(formats) + val acquisitionTypeOptions: List get() = options(acquisitionTypes) + val copyStateOptions: List get() = options(copyStates) + + companion object { + fun options(raw: List): List = raw.mapNotNull { it.toLookupOption() } + } +} + +internal fun JsonElement.primitiveText(): String? = + (this as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull?.takeIf { it.isNotBlank() } + +internal fun JsonElement.toLookupOption(): LookupOption? = when (this) { + is JsonPrimitive -> primitiveText()?.let { LookupOption(it, it) } + is JsonObject -> { + val value = (this["value"] ?: this["code"] ?: this["id"])?.primitiveText() + val label = (this["label"] ?: this["name"])?.primitiveText() + when { + value != null -> LookupOption(value, label ?: value) + label != null -> LookupOption(label, label) + else -> null + } + } + else -> null +} + +/** A display label for JSON that may be a string, a `{name}`/`{label}` object or a list of either. */ +internal fun JsonElement?.displayText(): String? = when (this) { + null, is JsonNull -> null + is JsonPrimitive -> primitiveText() + is JsonObject -> (this["label"] ?: this["name"] ?: this["title"])?.primitiveText() + is JsonArray -> mapNotNull { it.displayText() }.joinToString(", ").ifBlank { null } +} + +// ---------- ISBN lookup ---------- + +@Serializable +data class IsbnIdentifier( + val input: String = "", + val isbn10: String? = null, + val isbn13: String? = null, + val ean: String? = null, + val kind: String = "isbn", // isbn | ean +) + +/** A catalogue record that already carries the scanned identifier (duplicate). */ +@Serializable +data class ExistingBook( + val id: Int = 0, + val title: String = "", + val isbn10: String? = null, + val isbn13: String? = null, + val ean: String? = null, + @SerialName("copies_total") val copiesTotal: Int = 0, + // `location` is a label on the lookup, an object elsewhere: keep it raw. + val location: JsonElement? = null, +) { + val locationLabel: String? get() = location.displayText() +} + +/** Normalised scraper result (all hooks applied server-side). */ +@Serializable +data class IsbnPreview( + val title: String = "", + val subtitle: String? = null, + val authors: List = emptyList(), + val publisher: String? = null, + val year: String? = null, + @SerialName("pub_date") val pubDate: String? = null, + val pages: String? = null, + val language: String? = null, + val description: String? = null, + @SerialName("cover_url") val coverUrl: String? = null, + val isbn10: String? = null, + val isbn13: String? = null, + val ean: String? = null, + val series: String? = null, + val edition: String? = null, + val place: String? = null, + val keywords: String? = null, + val price: String? = null, + val format: String? = null, + @SerialName("media_type") val mediaType: String? = null, + val dewey: String? = null, + val translator: String? = null, + val illustrator: String? = null, + @SerialName("author_bio") val authorBio: String? = null, + val source: String? = null, +) { + val authorNames: List get() = authors.mapNotNull { it.displayText() }.distinct() +} + +@Serializable +data class IsbnLookup( + val identifier: IsbnIdentifier = IsbnIdentifier(), + val found: Boolean = false, + val existing: ExistingBook? = null, + val preview: IsbnPreview? = null, + @SerialName("sources_checked") val sourcesChecked: List = emptyList(), +) + +// ---------- Staff book (edit model) ---------- + +@Serializable +data class StaffAuthorRef( + val id: Int = 0, + val name: String = "", + // principale | co-autore | traduttore | illustratore | curatore | colorista + val role: String = "principale", +) + +@Serializable +data class StaffLocation( + @SerialName("shelf_id") val shelfId: Int? = null, + @SerialName("shelf_code") val shelfCode: String? = null, + @SerialName("level_id") val levelId: Int? = null, + @SerialName("level_number") val levelNumber: Int? = null, + val position: Int? = null, + val label: String? = null, +) + +@Serializable +data class StaffCopy( + val id: Int = 0, + @SerialName("inventory_number") val inventoryNumber: String? = null, + val status: String? = null, + val notes: String? = null, +) + +@Serializable +data class StaffBook( + val id: Int = 0, + val title: String = "", + val subtitle: String? = null, + val isbn10: String? = null, + val isbn13: String? = null, + val ean: String? = null, + val issn: String? = null, + val year: String? = null, + @SerialName("pub_date") val pubDate: String? = null, + val edition: String? = null, + val place: String? = null, + val pages: String? = null, + val language: String? = null, + val format: String? = null, + @SerialName("media_type") val mediaType: String? = null, + val description: String? = null, + val keywords: String? = null, + val notes: String? = null, + val series: String? = null, + @SerialName("series_number") val seriesNumber: String? = null, + val dewey: String? = null, + val price: String? = null, + @SerialName("acquisition_date") val acquisitionDate: String? = null, + @SerialName("acquisition_type") val acquisitionType: String? = null, + @SerialName("inventory_base") val inventoryBase: String? = null, + @SerialName("copies_total") val copiesTotal: Int = 0, + @SerialName("copies_available") val copiesAvailable: Int = 0, + @SerialName("cover_url") val coverUrl: String? = null, + @SerialName("cover_is_local") val coverIsLocal: Boolean = false, + val genre: IdName? = null, + val subgenre: IdName? = null, + val authors: List = emptyList(), + val publishers: List = emptyList(), + val location: StaffLocation? = null, + val copies: List = emptyList(), + @SerialName("is_wanted") val isWanted: Boolean = false, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, +) + +/** Row of `GET /staff/books`: the public summary plus operator-only fields. */ +@Serializable +data class StaffBookRow( + val id: Int = 0, + val title: String = "", + val subtitle: String? = null, + val author: String? = null, + val publisher: String? = null, + val year: Int? = null, + @SerialName("media_type") val mediaType: String? = null, + val isbn13: String? = null, + @SerialName("cover_url") val coverUrl: String? = null, + @SerialName("copies_total") val copiesTotal: Int = 0, + @SerialName("copies_available") val copiesAvailable: Int = 0, + @SerialName("is_wanted") val isWanted: Boolean = false, + @SerialName("updated_at") val updatedAt: String? = null, +) + +// ---------- Copies / cover / delete ---------- + +@Serializable +data class AddCopiesRequest( + val quantity: Int, + val status: String? = null, + val notes: String? = null, +) + +@Serializable +data class CreatedCopy(val id: Int = 0, @SerialName("inventory_number") val inventoryNumber: String? = null) + +@Serializable +data class AddCopiesResult( + @SerialName("copies_total") val copiesTotal: Int = 0, + @SerialName("copies_available") val copiesAvailable: Int = 0, + val created: List = emptyList(), +) + +@Serializable +data class CoverUrlRequest(@SerialName("cover_url") val coverUrl: String) + +@Serializable +data class CoverResult(@SerialName("cover_url") val coverUrl: String? = null) + +@Serializable +data class DeleteResult(val id: Int = 0, val deleted: Boolean = false) + +// ---------- Authors / publishers ---------- + +/** Search row of `GET /staff/authors`. */ +@Serializable +data class StaffAuthorRow( + val id: Int = 0, + val name: String = "", + val pseudonym: String? = null, + val label: String? = null, + val born: String? = null, + val died: String? = null, +) { + val display: String get() = label?.takeIf { it.isNotBlank() } ?: name +} + +@Serializable +data class StaffAuthor( + val id: Int = 0, + val name: String = "", + val pseudonym: String? = null, + val born: String? = null, + val died: String? = null, + val nationality: String? = null, + val biography: String? = null, + val website: String? = null, + @SerialName("photo_url") val photoUrl: String? = null, + @SerialName("gnd_id") val gndId: String? = null, +) + +@Serializable +data class StaffAuthorWrite( + val name: String, + val pseudonym: String? = null, + val born: String? = null, + val died: String? = null, + val nationality: String? = null, + val biography: String? = null, + val website: String? = null, + @SerialName("gnd_id") val gndId: String? = null, +) + +@Serializable +data class StaffPublisher( + val id: Int = 0, + val name: String = "", + val website: String? = null, + val address: String? = null, + val phone: String? = null, + val email: String? = null, +) + +@Serializable +data class StaffPublisherWrite( + val name: String, + val website: String? = null, + val address: String? = null, + val phone: String? = null, + val email: String? = null, +) + +// ---------- Genres ---------- + +@Serializable +data class StaffGenreNode( + val id: Int = 0, + val name: String = "", + @SerialName("parent_id") val parentId: Int? = null, + val children: List = emptyList(), +) + +@Serializable +data class StaffGenreWrite( + val name: String, + @SerialName("parent_id") val parentId: Int? = null, +) + +@Serializable +data class StaffGenreCreated( + val id: Int = 0, + val name: String = "", + @SerialName("parent_id") val parentId: Int? = null, + val created: Boolean = true, +) + +/** A genre flattened for pickers, with its ancestor path for the label. */ +data class FlatGenre(val id: Int, val name: String, val path: String, val depth: Int, val parentId: Int?) + +fun List.flatten(): List { + val out = ArrayList() + fun walk(nodes: List, prefix: String, depth: Int, parent: Int?) { + for (n in nodes) { + val path = if (prefix.isEmpty()) n.name else "$prefix › ${n.name}" + out += FlatGenre(n.id, n.name, path, depth, parent) + walk(n.children, path, depth + 1, n.id) + } + } + walk(this, "", 0, null) + return out +} + +// ---------- Shelves ---------- + +@Serializable +data class ShelfLevel( + val id: Int = 0, + val number: Int = 0, + val description: String? = null, + val order: Int? = null, +) + +@Serializable +data class Shelf( + val id: Int = 0, + val code: String = "", + val name: String? = null, + val order: Int? = null, + val levels: List = emptyList(), +) { + val display: String get() = listOfNotNull(code.takeIf { it.isNotBlank() }, name?.takeIf { it.isNotBlank() }).joinToString(" · ") +} + +@Serializable +data class ShelfWrite(val code: String, val name: String? = null, val order: Int? = null) + +@Serializable +data class ShelfLevelWrite(val number: Int, val description: String? = null, val order: Int? = null) + +@Serializable +data class NextPosition( + val position: Int? = null, + val label: String? = null, + @SerialName("level_number") val levelNumber: Int? = null, + @SerialName("shelf_code") val shelfCode: String? = null, +) diff --git a/app/src/main/java/com/pinakes/app/data/model/SyncModels.kt b/app/src/main/java/com/pinakes/app/data/model/SyncModels.kt new file mode 100644 index 0000000..9bd5384 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/SyncModels.kt @@ -0,0 +1,47 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// ---------- Delta catalogue sync (`GET /catalog/sync`, Mobile API 1.6.0) ---------- + +/** A catalogue row as the sync endpoint sends it: the summary plus offline-detail fields. */ +@Serializable +data class SyncBook( + val id: Int = 0, + val title: String = "", + val subtitle: String? = null, + val author: String? = null, + val publisher: String? = null, + val genre: String? = null, + val year: Int? = null, + val language: String? = null, + @SerialName("media_type") val mediaType: String? = null, + val isbn13: String? = null, + val isbn10: String? = null, + val ean: String? = null, + val pages: Int? = null, + val description: String? = null, + @SerialName("location_label") val locationLabel: String? = null, + @SerialName("cover_url") val coverUrl: String? = null, + @SerialName("copies_total") val copiesTotal: Int = 0, + @SerialName("copies_available") val copiesAvailable: Int = 0, + @SerialName("loanable_now") val loanableNow: Boolean = false, + @SerialName("is_wanted") val isWanted: Boolean = false, + @SerialName("updated_at") val updatedAt: String? = null, +) + +@Serializable +data class SyncItem( + val id: Int = 0, + val change: String = "upsert", // upsert | delete + @SerialName("changed_at") val changedAt: String? = null, + val book: SyncBook? = null, +) + +@Serializable +data class SyncPage( + val items: List = emptyList(), + @SerialName("server_time") val serverTime: String? = null, + @SerialName("full_resync") val fullResync: Boolean = false, +) diff --git a/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt b/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt index 93c15d3..d259294 100644 --- a/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt +++ b/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt @@ -23,6 +23,8 @@ sealed interface ApiResult { val message: String, val httpStatus: Int = 0, val retryAfterSeconds: Long? = null, + /** Envelope meta of the error body (e.g. `existing` on a 409 duplicate), when present. */ + val meta: Meta? = null, ) : ApiResult val isSuccess: Boolean get() = this is Success @@ -38,6 +40,9 @@ object ErrorCodes { const val FORBIDDEN = "forbidden" const val NOT_FOUND = "not_found" const val CONFLICT = "conflict" // 409 overlap / unavailable / cannot-cancel + const val DUPLICATE = "duplicate" // 409 staff create: identifier/name already exists + const val LOCK_ERROR = "lock_error" // 503 staff create: duplicate-check lock busy + const val SCRAPE_UNAVAILABLE = "scrape_unavailable" // 503 ISBN lookup: no scraper reachable const val VALIDATION = "validation" // 400 / 422 const val SERVER_ERROR = "server_error" // 5xx const val NETWORK = "network" // no connectivity / timeout @@ -127,12 +132,13 @@ internal fun HttpException.toFailure(): ApiResult.Failure { /** Parse an error envelope body if present, otherwise derive a code from the HTTP status. */ internal fun parseErrorBody(body: String?, status: Int): ApiResult.Failure { - val parsedError: ApiError? = body?.takeIf { it.isNotBlank() }?.let { - runCatching { errorJson.decodeFromString>(it).error }.getOrNull() + val envelope: Envelope? = body?.takeIf { it.isNotBlank() }?.let { + runCatching { errorJson.decodeFromString>(it) }.getOrNull() } + val parsedError: ApiError? = envelope?.error val code = parsedError?.code ?: codeForStatus(status) val message = parsedError?.message?.takeIf { it.isNotBlank() } ?: defaultMessageForStatus(status) - return ApiResult.Failure(code = code, message = message, httpStatus = status) + return ApiResult.Failure(code = code, message = message, httpStatus = status, meta = envelope?.meta) } private fun codeForStatus(status: Int): String = when (status) { diff --git a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt index bd2f82a..0ecbde0 100644 --- a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt +++ b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt @@ -97,6 +97,8 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { private var cachedPeriodicalsApi: PeriodicalsApi? = null @Volatile private var cachedCollectionsApi: CollectionsApi? = null + @Volatile + private var cachedStaffApi: StaffApi? = null /** Shared Retrofit for a given base URL, rebuilt only when the instance URL changes. */ @Synchronized @@ -119,6 +121,7 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { cachedBookClubApi = null cachedPeriodicalsApi = null cachedCollectionsApi = null + cachedStaffApi = null return retrofit } @@ -159,6 +162,13 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { return cachedCollectionsApi ?: retrofit.create(CollectionsApi::class.java).also { cachedCollectionsApi = it } } + /** Staff content management (`/staff/...`), same base URL and bearer token as [api]. */ + @Synchronized + fun staffApi(baseUrl: String? = null): StaffApi { + val retrofit = retrofit(baseUrl) + return cachedStaffApi ?: retrofit.create(StaffApi::class.java).also { cachedStaffApi = it } + } + /** Drop the cached Retrofit so the next [api] call rebuilds against a new instance URL. */ @Synchronized fun invalidate() { @@ -168,6 +178,7 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { cachedBookClubApi = null cachedPeriodicalsApi = null cachedCollectionsApi = null + cachedStaffApi = null } /** diff --git a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt index aa5ae67..9b78e0b 100644 --- a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt @@ -4,6 +4,7 @@ import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.BookDetail import com.pinakes.app.data.model.BookReviews import com.pinakes.app.data.model.BookSummary +import com.pinakes.app.data.model.CalendarStatus import com.pinakes.app.data.model.CatalogLanguage import com.pinakes.app.data.model.ChangePasswordRequest import com.pinakes.app.data.model.CirculationRequestResult @@ -27,6 +28,7 @@ import com.pinakes.app.data.model.RegisterRequest import com.pinakes.app.data.model.RegistrationFieldsPayload import com.pinakes.app.data.model.ReservationItem import com.pinakes.app.data.model.ReservationRequest +import com.pinakes.app.data.model.SyncPage import com.pinakes.app.data.model.UpdateProfileRequest import com.pinakes.app.data.model.UserProfile import com.pinakes.app.data.model.WishlistAddRequest @@ -199,6 +201,25 @@ interface PinakesApi { @PUT("me/push/prefs") suspend fun setPushPrefs(@Body body: PushPrefs): Envelope + // ---- Delta catalogue sync (Mobile API 1.6.0, `catalog_sync`) ---- + @GET("catalog/sync") + suspend fun catalogSync( + @Query("since") since: String? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): Envelope + + // ---- Personal loans calendar feed (Mobile API 1.6.0, `calendar_feed`) ---- + @GET("me/calendar") + suspend fun calendarStatus(): Envelope + + /** Creates the feed or rotates it: the previous URL stops working immediately. */ + @POST("me/calendar/token") + suspend fun rotateCalendarToken(): Envelope + + @DELETE("me/calendar/token") + suspend fun revokeCalendarToken(): Envelope + companion object { /** Header marker (value-less) the [AuthInterceptor] uses to skip bearer injection. */ const val NO_AUTH = "X-Pinakes-No-Auth: true" diff --git a/app/src/main/java/com/pinakes/app/data/network/StaffApi.kt b/app/src/main/java/com/pinakes/app/data/network/StaffApi.kt new file mode 100644 index 0000000..37a52be --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/StaffApi.kt @@ -0,0 +1,127 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.AddCopiesRequest +import com.pinakes.app.data.model.AddCopiesResult +import com.pinakes.app.data.model.CoverResult +import com.pinakes.app.data.model.CoverUrlRequest +import com.pinakes.app.data.model.DeleteResult +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.IsbnLookup +import com.pinakes.app.data.model.NextPosition +import com.pinakes.app.data.model.Shelf +import com.pinakes.app.data.model.ShelfLevel +import com.pinakes.app.data.model.ShelfLevelWrite +import com.pinakes.app.data.model.ShelfWrite +import com.pinakes.app.data.model.StaffAuthor +import com.pinakes.app.data.model.StaffAuthorRow +import com.pinakes.app.data.model.StaffAuthorWrite +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffBookRow +import com.pinakes.app.data.model.StaffGenreCreated +import com.pinakes.app.data.model.StaffGenreNode +import com.pinakes.app.data.model.StaffGenreWrite +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.model.StaffPublisher +import com.pinakes.app.data.model.StaffPublisherWrite +import kotlinx.serialization.json.JsonObject +import okhttp3.MultipartBody +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Staff content management (Mobile API 1.6.0). Every route requires a bearer token of an + * `admin` or `staff` user; anyone else gets 403 `forbidden`. Same base URL and token as + * [PinakesApi]. + */ +interface StaffApi { + + @GET("staff/lookups") + suspend fun lookups(): Envelope + + @GET("staff/isbn/{isbn}") + suspend fun isbnLookup(@Path("isbn") isbn: String): Envelope + + @GET("staff/books") + suspend fun books( + @Query("q") q: String? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + @Query("sort") sort: String? = null, + ): Envelope> + + @GET("staff/books/{id}") + suspend fun book(@Path("id") id: Int): Envelope + + /** Body is built by [com.pinakes.app.data.staff.StaffBookWrite] (only the sent keys change). */ + @POST("staff/books") + suspend fun createBook(@Body body: JsonObject): Envelope + + @PUT("staff/books/{id}") + suspend fun updateBook(@Path("id") id: Int, @Body body: JsonObject): Envelope + + @DELETE("staff/books/{id}") + suspend fun deleteBook(@Path("id") id: Int): Envelope + + @POST("staff/books/{id}/copies") + suspend fun addCopies(@Path("id") id: Int, @Body body: AddCopiesRequest): Envelope + + @Multipart + @POST("staff/books/{id}/cover") + suspend fun uploadCover(@Path("id") id: Int, @Part cover: MultipartBody.Part): Envelope + + @POST("staff/books/{id}/cover") + suspend fun coverFromUrl(@Path("id") id: Int, @Body body: CoverUrlRequest): Envelope + + @GET("staff/authors") + suspend fun authors(@Query("q") q: String? = null, @Query("limit") limit: Int? = null): Envelope> + + @GET("staff/authors/{id}") + suspend fun author(@Path("id") id: Int): Envelope + + @POST("staff/authors") + suspend fun createAuthor(@Body body: StaffAuthorWrite): Envelope + + @PUT("staff/authors/{id}") + suspend fun updateAuthor(@Path("id") id: Int, @Body body: StaffAuthorWrite): Envelope + + @GET("staff/publishers") + suspend fun publishers(@Query("q") q: String? = null, @Query("limit") limit: Int? = null): Envelope> + + @GET("staff/publishers/{id}") + suspend fun publisher(@Path("id") id: Int): Envelope + + @POST("staff/publishers") + suspend fun createPublisher(@Body body: StaffPublisherWrite): Envelope + + @PUT("staff/publishers/{id}") + suspend fun updatePublisher(@Path("id") id: Int, @Body body: StaffPublisherWrite): Envelope + + @GET("staff/genres") + suspend fun genres(): Envelope> + + @POST("staff/genres") + suspend fun createGenre(@Body body: StaffGenreWrite): Envelope + + @GET("staff/shelves") + suspend fun shelves(): Envelope> + + @POST("staff/shelves") + suspend fun createShelf(@Body body: ShelfWrite): Envelope + + @POST("staff/shelves/{id}/levels") + suspend fun createLevel(@Path("id") shelfId: Int, @Body body: ShelfLevelWrite): Envelope + + @GET("staff/shelves/next-position") + suspend fun nextPosition( + @Query("shelf_id") shelfId: Int, + @Query("level_id") levelId: Int, + @Query("book_id") bookId: Int? = null, + ): Envelope +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt index 71c58f7..ca35b79 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/AuthRepository.kt @@ -1,6 +1,7 @@ package com.pinakes.app.data.repository import android.os.Build +import com.pinakes.app.data.local.LibraryDao import com.pinakes.app.data.model.ForgotRequest import com.pinakes.app.data.model.HealthPayload import com.pinakes.app.data.model.LoginRequest @@ -25,6 +26,8 @@ class AuthRepository( private val bookClub: BookClubRepository, private val periodicals: PeriodicalsRepository, private val catalog: CatalogRepository, + /** Personal offline cache (loans, wishlist); purged whenever the signed-in user changes. */ + private val libraryCache: LibraryDao? = null, ) { /** @@ -118,13 +121,36 @@ class AuthRepository( ) return when (val res = apiCall { api.login(request) }) { is ApiResult.Success -> { + // A different account may sign in on this device: never show the previous + // user's cached loans or wishlist. + runCatching { libraryCache?.clearAll() } session.saveToken(res.data.token) + session.saveRole(res.data.user.tipoUtente) ApiResult.Success(Unit, res.meta) } is ApiResult.Failure -> res } } + /** + * Re-read the signed-in user (`GET /me`) to pick up a role change made on the server + * (promoted to staff, or demoted). A 401 is already handled app-wide by the + * AuthInterceptor; other failures keep the last known role. + */ + suspend fun refreshSession() { + if (!session.isLoggedIn()) return + when (val res = apiCall { network.api().me() }) { + is ApiResult.Success -> session.saveRole(res.data.tipoUtente) + is ApiResult.Failure -> Unit + } + } + + /** Remember the role from a profile fetched elsewhere (Profile screen). */ + fun rememberRole(role: String?) = session.saveRole(role) + + /** The server refused a staff call (403): the role was revoked, hide the staff area. */ + fun forgetStaffRole() = session.saveRole(null) + /** * Public discovery of the sign-up form schema (built-in required flags + custom fields) for * the committed instance. No token required. @@ -173,6 +199,7 @@ class AuthRepository( val api = network.api() val result = apiCall { api.logout() } session.clearToken() + runCatching { libraryCache?.clearAll() } // Drop the cached HTTP responses of the session that just ended: they were fetched // with a bearer token that no longer exists, and the cache is keyed by URL only. network.clearHttpCache() @@ -186,6 +213,7 @@ class AuthRepository( // Purge the offline catalog cache (Room + in-memory ETags): it belongs to the old // instance and must never surface under the next library's name. catalog.clearCache() + runCatching { libraryCache?.clearAll() } network.invalidate() // Same reasoning as the Room purge: a URL-keyed HTTP cache entry from the old // instance could otherwise be revalidated against the next library's server. diff --git a/app/src/main/java/com/pinakes/app/data/repository/CalendarRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/CalendarRepository.kt new file mode 100644 index 0000000..adbf64d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/CalendarRepository.kt @@ -0,0 +1,28 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.CalendarStatus +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.network.apiCall + +/** + * The personal loans calendar feed (`/me/calendar`): status, create/rotate (the old URL stops + * working immediately) and revoke. The feed itself is fetched by the user's calendar app. + */ +class CalendarRepository(private val network: NetworkModule) { + + suspend fun status(): ApiResult = apiCall { network.api().calendarStatus() } + + suspend fun rotate(): ApiResult = apiCall { network.api().rotateCalendarToken() } + + /** Disables the feed. The server answers `{enabled:false}`; an empty body means the same. */ + suspend fun revoke(): ApiResult = + when (val res = apiCall { network.api().revokeCalendarToken() }) { + is ApiResult.Success -> { + // apiCall maps an empty `data` to Unit at runtime: never trust the static type here. + val body: Any? = res.data + ApiResult.Success((body as? CalendarStatus) ?: CalendarStatus(enabled = false), res.meta) + } + is ApiResult.Failure -> res + } +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt index ea9fa6e..50f98e7 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt @@ -1,20 +1,26 @@ package com.pinakes.app.data.repository import com.pinakes.app.data.local.CatalogDao +import com.pinakes.app.data.local.SyncMeta +import com.pinakes.app.data.local.SyncMetaDao import com.pinakes.app.data.local.toCached +import com.pinakes.app.data.local.toOfflineDetail import com.pinakes.app.data.local.toSummary import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.BookDetail import com.pinakes.app.data.model.BookSummary import com.pinakes.app.data.model.CatalogLanguage import com.pinakes.app.data.model.GenreNode +import com.pinakes.app.data.model.SyncPage import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.network.NetworkModule import com.pinakes.app.data.network.apiCall import com.pinakes.app.data.network.apiResponse +import com.pinakes.app.data.sync.SyncMerge import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.sync.Mutex /** Filters for a catalog search; nulls are omitted from the query. */ data class SearchFilters( @@ -43,8 +49,13 @@ data class SearchPage( class CatalogRepository( private val network: NetworkModule, private val catalogDao: CatalogDao, + private val syncMeta: SyncMetaDao, + private val nowMs: () -> Long = System::currentTimeMillis, ) { + /** Serialises sync runs: the foreground observer, Home and the worker may all ask at once. */ + private val syncMutex = Mutex() + // Small in-memory ETag cache for book detail: id -> (etag, payload). private val detailCache = HashMap>() @@ -53,8 +64,8 @@ class CatalogRepository( * including offline — so the UI can render instantly without a network round-trip * for the list or, via Coil's disk cache, its covers. Empty until the first refresh. */ - fun observeCachedCatalog(): Flow> = - catalogDao.observeAll().map { rows -> rows.map { it.toSummary() } } + fun observeCachedCatalog(limit: Int = HOME_SHELF_SIZE): Flow> = + catalogDao.observeNewest(limit).map { rows -> rows.map { it.toSummary() } } /** True once a catalog snapshot has been cached at least once. */ suspend fun hasCachedCatalog(): Boolean = catalogDao.count() > 0 @@ -67,22 +78,117 @@ class CatalogRepository( suspend fun clearCache() { detailCache.clear() catalogDao.clear() + syncMeta.clear() } + /** Transitional alias: every caller now gets the delta sync. */ + suspend fun refreshCatalog(): ApiResult = syncCatalog() + /** - * Refresh the cached catalog from the network (first page, unfiltered) and replace - * the Room snapshot atomically. On network failure the existing cache is kept, so a - * refresh-on-open that fails never wipes the offline catalog. + * Bring the offline catalogue up to date with `GET /catalog/sync`. + * + * Uses the stored watermark as `since`; omits it (full pass) when there is none or the + * last full pass is older than seven days ([SyncMerge.shouldForceFull]). Every page is + * fetched first, then merged and applied in one transaction, and only then is the new + * watermark stored. Any failure keeps the cache and the old watermark untouched. + * A server without the endpoint (404) falls back to the legacy first-page snapshot. + * Concurrent calls collapse: a run already in progress makes the second call a no-op. */ - suspend fun refreshCatalog(limit: Int = 40): ApiResult = - when (val res = search(SearchFilters(), limit = limit)) { + suspend fun syncCatalog(): ApiResult { + if (!syncMutex.tryLock()) return ApiResult.Success(Unit) + try { + val since = syncMeta.get(SyncMeta.CATALOG_SERVER_TIME) + val lastFullAt = syncMeta.get(SyncMeta.CATALOG_LAST_FULL_AT)?.toLongOrNull() + val requestFull = SyncMerge.shouldForceFull(since, lastFullAt, nowMs()) + val api = network.api() + val pages = ArrayList() + var cursor: String? = null + var guard = 0 + do { + val res = apiCall { + api.catalogSync( + since = if (requestFull || cursor != null) null else since, + cursor = cursor, + limit = SYNC_PAGE_SIZE, + ) + } + when (res) { + is ApiResult.Success -> { + pages += res.data + val next = res.meta?.nextCursor?.takeIf { it.isNotBlank() } + val more = res.meta?.hasMore ?: (next != null) + cursor = if (more) next else null + } + is ApiResult.Failure -> { + return if (res.httpStatus == 404 && pages.isEmpty()) legacySnapshot() else res + } + } + } while (cursor != null && ++guard < MAX_SYNC_PAGES) + if (cursor != null) { + // The server kept paging past the safety cap: do not trust a partial run. + return ApiResult.Failure(ErrorCodes.SERVER_ERROR, "Catalogue sync did not finish", 0) + } + + val result = SyncMerge.merge(pages, requestedFull = requestFull) + if (result.fullResync) { + catalogDao.replaceAll(result.upserts.map { it.toCached() }) + syncMeta.put(SyncMeta(SyncMeta.CATALOG_LAST_FULL_AT, nowMs().toString())) + } else { + catalogDao.applyDelta(result.upserts.map { it.toCached() }, result.deleteIds) + } + result.serverTime?.let { syncMeta.put(SyncMeta(SyncMeta.CATALOG_SERVER_TIME, it)) } + // Book pages served from memory may now be stale. + result.upserts.forEach { detailCache.remove(it.id) } + result.deleteIds.forEach { detailCache.remove(it) } + return ApiResult.Success(Unit) + } finally { + syncMutex.unlock() + } + } + + /** Servers before Mobile API 1.6.0: cache the first unfiltered page, as 1.6.x did. */ + private suspend fun legacySnapshot(): ApiResult = + when (val res = search(SearchFilters(), limit = HOME_SHELF_SIZE)) { is ApiResult.Success -> { - catalogDao.replaceAll(res.data.items.mapIndexed { i, b -> b.toCached(i) }) + catalogDao.replaceAll(res.data.items.map { it.toCached() }) ApiResult.Success(Unit, res.meta) } is ApiResult.Failure -> res } + /** + * Search the offline catalogue (used when the server is unreachable). Filters mirror the + * online ones by value: genre and language match the cached labels. [sort] accepts + * `title_asc`, `title_desc` or `newest` (anything else falls back to title). + */ + suspend fun searchOffline( + filters: SearchFilters, + genreName: String?, + sort: String?, + offset: Int, + limit: Int = 20, + ): List = catalogDao.search( + q = filters.query?.trim().orEmpty(), + author = filters.author?.trim().orEmpty(), + publisher = filters.publisher?.trim().orEmpty(), + language = filters.language?.trim().orEmpty(), + genre = genreName?.trim().orEmpty(), + availableOnly = filters.availableOnly == true, + sort = when (sort) { + "title_desc" -> "title_desc" + "newest" -> "newest" + else -> "title_asc" + }, + limit = limit, + offset = offset, + ).map { it.toSummary() } + + /** Server time of the last completed sync (`Y-m-d H:i:s`), or null when never synced. */ + suspend fun lastSyncedAt(): String? = syncMeta.get(SyncMeta.CATALOG_SERVER_TIME) + + /** A summary-level detail page from the cache, or null when the title is not cached. */ + suspend fun bookOffline(id: Int): BookDetail? = catalogDao.byId(id)?.toOfflineDetail() + suspend fun search( filters: SearchFilters, cursor: String? = null, @@ -169,4 +275,13 @@ class CatalogRepository( val api = network.api() return apiCall { api.bookAvailability(id) } } + + private companion object { + /** Home shelf and legacy snapshot size (the server page cap is 50). */ + const val HOME_SHELF_SIZE = 40 + const val SYNC_PAGE_SIZE = 200 + + /** 200 × 2 000 = 400 000 titles: far beyond any target catalogue, still finite. */ + const val MAX_SYNC_PAGES = 2_000 + } } 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 b9f84a1..aaf389d 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 @@ -1,5 +1,10 @@ package com.pinakes.app.data.repository +import com.pinakes.app.data.local.CachedLoan +import com.pinakes.app.data.local.LibraryDao +import com.pinakes.app.data.local.toCached +import com.pinakes.app.data.local.toLoansData +import com.pinakes.app.data.local.toReservationItem import com.pinakes.app.data.model.CirculationRequestResult import com.pinakes.app.data.model.LoansData import com.pinakes.app.data.model.ReservationItem @@ -12,16 +17,47 @@ import com.pinakes.app.data.network.apiCall * The user's loans and reservations: list active/pending/history loans, list reservations, * create a reservation (409 on overlap/unavailable) and cancel a pending one (409 if picked up). */ -class LibraryRepository(private val network: NetworkModule) { +class LibraryRepository( + private val network: NetworkModule, + /** Offline copy of the lists; null in tests that only exercise the network path. */ + private val cache: LibraryDao? = null, +) { + /** Loans; a successful fetch is written through to the offline cache. */ suspend fun loans(): ApiResult { val api = network.api() - return apiCall { api.loans() } + val res = apiCall { api.loans() } + if (res is ApiResult.Success) { + runCatching { cache?.replaceLoans(CachedLoan.LOAN_BUCKETS, res.data.toCached()) } + } + return res } + /** Reservations; a successful fetch is written through to the offline cache. */ suspend fun reservations(): ApiResult> { val api = network.api() - return apiCall { api.reservations() } + val res = apiCall { api.reservations() } + if (res is ApiResult.Success) { + runCatching { + cache?.replaceLoans(listOf(CachedLoan.RESERVATION), res.data.mapIndexed { i, r -> r.toCached(i) }) + } + } + return res + } + + /** The last loans the server returned, or null when nothing was ever cached. */ + suspend fun loansCached(): LoansData? { + val rows = cache?.loans(CachedLoan.LOAN_BUCKETS) ?: return null + return if (rows.isEmpty()) null else rows.toLoansData() + } + + suspend fun reservationsCached(): List = + cache?.loans(listOf(CachedLoan.RESERVATION))?.map { it.toReservationItem() }.orEmpty() + + /** Background refresh of both lists (sync worker); failures are ignored. */ + suspend fun refreshCache() { + loans() + reservations() } /** diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt new file mode 100644 index 0000000..561fcd7 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt @@ -0,0 +1,146 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.AddCopiesRequest +import com.pinakes.app.data.model.AddCopiesResult +import com.pinakes.app.data.model.CoverResult +import com.pinakes.app.data.model.CoverUrlRequest +import com.pinakes.app.data.model.DeleteResult +import com.pinakes.app.data.model.ExistingBook +import com.pinakes.app.data.model.IsbnLookup +import com.pinakes.app.data.model.NextPosition +import com.pinakes.app.data.model.Shelf +import com.pinakes.app.data.model.ShelfLevel +import com.pinakes.app.data.model.ShelfLevelWrite +import com.pinakes.app.data.model.ShelfWrite +import com.pinakes.app.data.model.StaffAuthor +import com.pinakes.app.data.model.StaffAuthorRow +import com.pinakes.app.data.model.StaffAuthorWrite +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffBookRow +import com.pinakes.app.data.model.StaffGenreCreated +import com.pinakes.app.data.model.StaffGenreNode +import com.pinakes.app.data.model.StaffGenreWrite +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.model.StaffPublisher +import com.pinakes.app.data.model.StaffPublisherWrite +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.network.apiCall +import com.pinakes.app.data.store.SessionStore +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody + +/** One page of the staff book list. */ +data class StaffBooksPage(val items: List, val nextCursor: String?) + +/** + * Staff content management (`/staff/...`). One method per endpoint, each wrapped in [apiCall]. + * + * A 403 `forbidden` means the server no longer considers this user staff (role changed while + * the app was open): the stored role is dropped so the staff entry points disappear, and the + * failure is returned for the screen to leave. + */ +class StaffRepository( + private val network: NetworkModule, + private val session: SessionStore, +) { + private val api get() = network.staffApi() + + private suspend fun staffCall(block: suspend () -> com.pinakes.app.data.model.Envelope): ApiResult { + val res = apiCall(block) + if (res is ApiResult.Failure && res.isForbidden()) session.saveRole(null) + return res + } + + suspend fun lookups(): ApiResult = staffCall { api.lookups() } + + suspend fun isbnLookup(code: String): ApiResult = staffCall { api.isbnLookup(code) } + + suspend fun books(query: String?, cursor: String?, limit: Int = 30): ApiResult = + when (val res = staffCall { api.books(q = query?.takeIf { it.isNotBlank() }, cursor = cursor, limit = limit) }) { + is ApiResult.Success -> { + val meta = res.meta + val next = meta?.nextCursor?.takeIf { it.isNotBlank() && meta.hasMore != false } + ApiResult.Success(StaffBooksPage(res.data, next), meta) + } + is ApiResult.Failure -> res + } + + suspend fun book(id: Int): ApiResult = staffCall { api.book(id) } + + suspend fun createBook(body: JsonObject): ApiResult = staffCall { api.createBook(body) } + + suspend fun updateBook(id: Int, body: JsonObject): ApiResult = staffCall { api.updateBook(id, body) } + + suspend fun deleteBook(id: Int): ApiResult = staffCall { api.deleteBook(id) } + + suspend fun addCopies(id: Int, quantity: Int): ApiResult = + staffCall { api.addCopies(id, AddCopiesRequest(quantity = quantity.coerceIn(1, 100))) } + + /** Multipart cover upload (`cover` field, JPEG or PNG, at most 2 MB server-side). */ + suspend fun uploadCover(id: Int, bytes: ByteArray, mime: String): ApiResult { + val type = if (mime == "image/png") "image/png" else "image/jpeg" + val ext = if (type == "image/png") "png" else "jpg" + val part = MultipartBody.Part.createFormData("cover", "cover.$ext", bytes.toRequestBody(type.toMediaType())) + return staffCall { api.uploadCover(id, part) } + } + + suspend fun coverFromUrl(id: Int, url: String): ApiResult = + staffCall { api.coverFromUrl(id, CoverUrlRequest(url.trim())) } + + suspend fun authors(query: String?, limit: Int = 20): ApiResult> = + staffCall { api.authors(q = query?.takeIf { it.isNotBlank() }, limit = limit) } + + suspend fun author(id: Int): ApiResult = staffCall { api.author(id) } + + suspend fun saveAuthor(id: Int?, body: StaffAuthorWrite): ApiResult = + staffCall { if (id == null || id <= 0) api.createAuthor(body) else api.updateAuthor(id, body) } + + suspend fun publishers(query: String?, limit: Int = 20): ApiResult> = + staffCall { api.publishers(q = query?.takeIf { it.isNotBlank() }, limit = limit) } + + suspend fun publisher(id: Int): ApiResult = staffCall { api.publisher(id) } + + suspend fun savePublisher(id: Int?, body: StaffPublisherWrite): ApiResult = + staffCall { if (id == null || id <= 0) api.createPublisher(body) else api.updatePublisher(id, body) } + + suspend fun genres(): ApiResult> = staffCall { api.genres() } + + suspend fun createGenre(name: String, parentId: Int?): ApiResult = + staffCall { api.createGenre(StaffGenreWrite(name.trim(), parentId)) } + + suspend fun shelves(): ApiResult> = staffCall { api.shelves() } + + suspend fun createShelf(code: String, name: String?, order: Int?): ApiResult = + staffCall { api.createShelf(ShelfWrite(code.trim(), name?.trim()?.takeIf { it.isNotEmpty() }, order)) } + + suspend fun createLevel(shelfId: Int, number: Int, description: String?): ApiResult = + staffCall { api.createLevel(shelfId, ShelfLevelWrite(number, description?.trim()?.takeIf { it.isNotEmpty() })) } + + suspend fun nextPosition(shelfId: Int, levelId: Int, bookId: Int?): ApiResult = + staffCall { api.nextPosition(shelfId, levelId, bookId?.takeIf { it > 0 }) } + + /** Decode `meta.existing` of a 409 `duplicate` as a book, if it is one. */ + fun existingBook(failure: ApiResult.Failure): ExistingBook? = decodeExisting(failure.meta?.existing) + + /** Decode `meta.existing` of a 409 `duplicate` on authors/publishers (`{id,name}`). */ + fun existingEntity(failure: ApiResult.Failure): com.pinakes.app.data.model.IdName? = + failure.meta?.existing?.let { runCatching { network.json.decodeFromJsonElement(it) }.getOrNull() } + ?.takeIf { it.id > 0 } + + private fun decodeExisting(raw: JsonElement?): ExistingBook? = + raw?.let { runCatching { network.json.decodeFromJsonElement(it) }.getOrNull() }?.takeIf { it.id > 0 } +} + +/** 403 on a staff route: either the role guard or app access was switched off. */ +fun ApiResult.Failure.isForbidden(): Boolean = + code == ErrorCodes.FORBIDDEN || (httpStatus == 403 && code != ErrorCodes.APP_ACCESS_DISABLED) + +/** 409 `duplicate` (identifier or name already used). */ +fun ApiResult.Failure.isDuplicate(): Boolean = + code == ErrorCodes.DUPLICATE || (httpStatus == 409 && meta?.existing != null) diff --git a/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt index c629876..f4b07a9 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt @@ -1,5 +1,8 @@ package com.pinakes.app.data.repository +import com.pinakes.app.data.local.LibraryDao +import com.pinakes.app.data.local.toCached +import com.pinakes.app.data.local.toWishlistItem import com.pinakes.app.data.model.WishlistAddRequest import com.pinakes.app.data.model.WishlistItem import com.pinakes.app.data.network.ApiResult @@ -7,11 +10,29 @@ import com.pinakes.app.data.network.NetworkModule import com.pinakes.app.data.network.apiCall /** The user's wishlist: list, add (idempotent, 201) and remove (idempotent) a book. */ -class WishlistRepository(private val network: NetworkModule) { +class WishlistRepository( + private val network: NetworkModule, + /** Offline copy of the list; null in tests that only exercise the network path. */ + private val cache: LibraryDao? = null, +) { + /** The wishlist; a successful fetch is written through to the offline cache. */ suspend fun wishlist(): ApiResult> { val api = network.api() - return apiCall { api.wishlist() } + val res = apiCall { api.wishlist() } + if (res is ApiResult.Success) { + runCatching { cache?.replaceWishlist(res.data.mapIndexed { i, w -> w.toCached(i) }) } + } + return res + } + + /** The last wishlist the server returned, or null when nothing was ever cached. */ + suspend fun wishlistCached(): List? = + cache?.wishlist()?.takeIf { it.isNotEmpty() }?.map { it.toWishlistItem() } + + /** Background refresh (sync worker); failures are ignored. */ + suspend fun refreshCache() { + wishlist() } suspend fun add(bookId: Int): ApiResult { diff --git a/app/src/main/java/com/pinakes/app/data/staff/CoverImageEncoder.kt b/app/src/main/java/com/pinakes/app/data/staff/CoverImageEncoder.kt new file mode 100644 index 0000000..44e90f9 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/CoverImageEncoder.kt @@ -0,0 +1,68 @@ +package com.pinakes.app.data.staff + +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.media.ExifInterface +import android.net.Uri +import java.io.ByteArrayOutputStream +import kotlin.math.max + +/** + * Prepares a photo or picked image for `POST /staff/books/{id}/cover`: decoded with + * sub-sampling (never the full camera resolution in memory), rotated per EXIF, scaled to at + * most [MAX_SIDE] px and re-encoded as JPEG under the server's 2 MB limit. Re-encoding also + * drops any metadata (location) the original file carried. + */ +// android.media.ExifInterface is reliable from API 24 with streams; minSdk is 26, so the +// AndroidX copy (an extra dependency) is not needed. +@SuppressLint("ExifInterface") +object CoverImageEncoder { + private const val MAX_SIDE = 1600 + private const val MAX_BYTES = 1_900_000 + + /** JPEG bytes, or null when the image cannot be read. Call off the main thread. */ + fun encode(context: Context, uri: Uri): ByteArray? = runCatching { + val resolver = context.contentResolver + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + resolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } ?: return null + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + var sample = 1 + while (max(bounds.outWidth, bounds.outHeight) / (sample * 2) >= MAX_SIDE) sample *= 2 + val decoded = resolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample }) + } ?: return null + + val rotation = resolver.openInputStream(uri)?.use { stream -> + when (ExifInterface(stream).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) { + ExifInterface.ORIENTATION_ROTATE_90 -> 90f + ExifInterface.ORIENTATION_ROTATE_180 -> 180f + ExifInterface.ORIENTATION_ROTATE_270 -> 270f + else -> 0f + } + } ?: 0f + + val longest = max(decoded.width, decoded.height) + val scale = if (longest > MAX_SIDE) MAX_SIDE.toFloat() / longest else 1f + val matrix = Matrix().apply { + if (scale != 1f) postScale(scale, scale) + if (rotation != 0f) postRotate(rotation) + } + val bitmap = if (matrix.isIdentity) decoded else Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true) + + var quality = 88 + var bytes: ByteArray + do { + val out = ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, out) + bytes = out.toByteArray() + quality -= 10 + } while (bytes.size > MAX_BYTES && quality >= 40) + if (bitmap !== decoded) bitmap.recycle() + decoded.recycle() + if (bytes.size > MAX_BYTES) null else bytes + }.getOrNull() +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/IsbnCodec.kt b/app/src/main/java/com/pinakes/app/data/staff/IsbnCodec.kt new file mode 100644 index 0000000..7437e4c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/IsbnCodec.kt @@ -0,0 +1,138 @@ +package com.pinakes.app.data.staff + +/** + * A validated book or product identifier, ready for `GET /staff/isbn/{code}`. + * + * [lookupCode] is what the app sends: the ISBN-13 for books (the server derives every + * variant), the EAN-13 for other media. + */ +sealed interface Identifier { + val lookupCode: String + + /** A Bookland EAN (978/979) or a converted ISBN-10. [isbn10] exists only for 978 codes. */ + data class Isbn13(val isbn13: String, val isbn10: String?) : Identifier { + override val lookupCode: String get() = isbn13 + } + + /** A non-book EAN-13 (CD, DVD, games). Still looked up: plugins such as Discogs accept them. */ + data class Ean(val ean: String) : Identifier { + override val lookupCode: String get() = ean + } +} + +/** + * Pure ISBN/EAN validation and conversion used by the scanner and the manual entry field. + * No Android dependencies, fully unit-tested. + */ +object IsbnCodec { + + /** + * A raw barcode decode. Only 13-digit codes with a valid EAN checksum pass, which filters + * out the partial reads a camera produces while it focuses. + */ + fun fromScan(raw: String?): Identifier? { + val code = raw?.trim().orEmpty() + if (code.length != 13 || !code.all { it.isDigit() }) return null + if (!checkEan13(code)) return null + return classify13(code) + } + + /** + * Manually typed input: dashes and spaces are ignored, ISBN-10 (with a trailing X) is + * converted to ISBN-13 and both forms are kept, a 13-digit code must pass the EAN checksum. + */ + fun fromManual(input: String?): Identifier? { + val cleaned = input.orEmpty().replace(SEPARATORS, "").uppercase() + return when { + cleaned.length == 10 && checkIsbn10(cleaned) -> + Identifier.Isbn13(isbn13 = isbn10To13(cleaned), isbn10 = cleaned) + cleaned.length == 13 && cleaned.all { it.isDigit() } && checkEan13(cleaned) -> + classify13(cleaned) + else -> null + } + } + + /** True when [code] is a 13-digit string whose last digit is the EAN-13 check digit. */ + fun checkEan13(code: String): Boolean { + if (code.length != 13 || !code.all { it.isDigit() }) return false + return ean13CheckDigit(code.substring(0, 12)) == code[12].digitToInt() + } + + /** True when [code] is a valid ISBN-10 (mod 11; `X` allowed only as the check digit). */ + fun checkIsbn10(code: String): Boolean { + val c = code.uppercase() + if (c.length != 10) return false + if (!c.substring(0, 9).all { it.isDigit() }) return false + val last = c[9] + if (!last.isDigit() && last != 'X') return false + var sum = 0 + for (i in 0 until 9) sum += (10 - i) * c[i].digitToInt() + sum += if (last == 'X') 10 else last.digitToInt() + return sum % 11 == 0 + } + + /** ISBN-10 → ISBN-13 (978 prefix, recomputed check digit). Expects a valid ISBN-10. */ + fun isbn10To13(isbn10: String): String { + val core = "978" + isbn10.substring(0, 9) + return core + ean13CheckDigit(core) + } + + /** ISBN-13 (978 only) → ISBN-10, or null for 979 codes, which have no ISBN-10. */ + fun isbn13To10(isbn13: String): String? { + if (isbn13.length != 13 || !isbn13.startsWith("978")) return null + val core = isbn13.substring(3, 12) + var sum = 0 + for (i in 0 until 9) sum += (10 - i) * core[i].digitToInt() + val check = (11 - sum % 11) % 11 + return core + if (check == 10) "X" else check.toString() + } + + /** Light validation for the edit form: blank is fine, anything typed must be a real code. */ + fun isValidIsbn13OrBlank(value: String): Boolean { + val c = value.replace(SEPARATORS, "") + return c.isEmpty() || (c.length == 13 && checkEan13(c) && (c.startsWith("978") || c.startsWith("979"))) + } + + fun isValidIsbn10OrBlank(value: String): Boolean { + val c = value.replace(SEPARATORS, "") + return c.isEmpty() || checkIsbn10(c) + } + + fun isValidEanOrBlank(value: String): Boolean { + val c = value.replace(SEPARATORS, "") + return c.isEmpty() || checkEan13(c) + } + + /** ISSN: 8 characters, mod-11 check digit, `X` allowed last; the hyphen is optional. */ + fun isValidIssnOrBlank(value: String): Boolean { + val c = value.replace(SEPARATORS, "").uppercase() + if (c.isEmpty()) return true + if (c.length != 8 || !c.substring(0, 7).all { it.isDigit() }) return false + var sum = 0 + for (i in 0 until 7) sum += (8 - i) * c[i].digitToInt() + val check = (11 - sum % 11) % 11 + val expected = if (check == 10) 'X' else ('0' + check) + return c[7] == expected + } + + /** Strip the separators people type or paste into identifier fields. */ + fun normalize(value: String): String = value.replace(SEPARATORS, "").uppercase() + + private fun classify13(code: String): Identifier = + if (code.startsWith("978") || code.startsWith("979")) { + Identifier.Isbn13(isbn13 = code, isbn10 = isbn13To10(code)) + } else { + Identifier.Ean(code) + } + + private fun ean13CheckDigit(first12: String): Int { + var sum = 0 + for (i in first12.indices) { + val d = first12[i].digitToInt() + sum += if (i % 2 == 0) d else d * 3 + } + return (10 - sum % 10) % 10 + } + + private val SEPARATORS = Regex("[\\s-]") +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/ScanDebouncer.kt b/app/src/main/java/com/pinakes/app/data/staff/ScanDebouncer.kt new file mode 100644 index 0000000..2b9f8eb --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/ScanDebouncer.kt @@ -0,0 +1,67 @@ +package com.pinakes.app.data.staff + +/** + * Turns a noisy stream of barcode decodes into deliberate scans. + * + * - A code is accepted only after [requiredReads] consecutive identical decodes, so a single + * misread while the camera focuses never triggers a lookup. + * - Once accepted, the same code is suppressed for [suppressMs] milliseconds, so holding the + * book under the camera does not add it twice. + * - [pause] ignores every decode (a sheet is open); [resume] starts counting afresh. + * + * Pure and clock-injected ([offer] takes `nowMs`) so it is unit-testable. Not thread-safe: + * the analyzer delivers decodes on one executor and the caller hops to the main thread. + */ +class ScanDebouncer( + private val requiredReads: Int = 2, + private val suppressMs: Long = 3_000, +) { + private var candidate: String? = null + private var candidateCount = 0 + private var lastAccepted: String? = null + private var lastAcceptedAt = Long.MIN_VALUE + var paused: Boolean = false + private set + + /** Feed one decode; returns the code when it should be acted on, else null. */ + fun offer(code: String, nowMs: Long): String? { + if (paused) return null + if (code == lastAccepted && nowMs - lastAcceptedAt < suppressMs) { + // Keep the streak empty so the code needs fresh confirmation after the window. + candidate = null + candidateCount = 0 + return null + } + if (code == candidate) { + candidateCount++ + } else { + candidate = code + candidateCount = 1 + } + if (candidateCount < requiredReads) return null + candidate = null + candidateCount = 0 + lastAccepted = code + lastAcceptedAt = nowMs + return code + } + + fun pause() { + paused = true + candidate = null + candidateCount = 0 + } + + fun resume() { + paused = false + candidate = null + candidateCount = 0 + } + + /** Forget the last accepted code, so it can be scanned again immediately (e.g. "Rescan"). */ + fun reset() { + resume() + lastAccepted = null + lastAcceptedAt = Long.MIN_VALUE + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffAccess.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffAccess.kt new file mode 100644 index 0000000..719c365 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffAccess.kt @@ -0,0 +1,16 @@ +package com.pinakes.app.data.staff + +/** + * Who sees the staff area. Mirrors the server's `AdminAuthMiddleware::ALLOWED_ROLES` + * (`admin`, `staff`): the server re-checks the role on every `/staff/...` call, so this only + * decides whether the entry points are shown. + */ +object StaffAccess { + private val STAFF_ROLES = setOf("admin", "staff") + + fun isStaff(role: String?): Boolean = role?.trim()?.lowercase() in STAFF_ROLES + + /** The staff area needs both a staff role and a server that advertises `staff_catalog`. */ + fun canManageCatalog(role: String?, serverSupportsStaff: Boolean): Boolean = + serverSupportsStaff && isStaff(role) +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt new file mode 100644 index 0000000..7e44b5d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt @@ -0,0 +1,284 @@ +package com.pinakes.app.data.staff + +import com.pinakes.app.data.model.IdName +import com.pinakes.app.data.model.IsbnPreview +import com.pinakes.app.data.model.StaffBook +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** An author or publisher chosen in the form: an existing record (id > 0) or a new name. */ +data class PickedEntity(val id: Int, val name: String) { + val isNew: Boolean get() = id <= 0 +} + +/** Form fields that can fail validation, so the screen can point at the right one. */ +enum class FormField { TITLE, ISBN13, ISBN10, EAN, ISSN, YEAR, PAGES, COPIES, PRICE, COVER_URL } + +/** Roles the edit form treats as "authors"; the others are preserved contributors. */ +private val AUTHOR_ROLES = setOf("principale", "co-autore", "autore", "") + +/** + * The editable state of the staff book form (create and edit share it). Every text field is + * a plain string so the screen can bind it directly; [validate] and [StaffBookWrite] turn it + * into a request. Pure: no Android, no network. + */ +data class StaffBookForm( + val title: String = "", + val subtitle: String = "", + val isbn13: String = "", + val isbn10: String = "", + val ean: String = "", + val issn: String = "", + val year: String = "", + val pubDate: String = "", + val edition: String = "", + val place: String = "", + val pages: String = "", + val language: String = "", + val format: String = "", + val mediaType: String = "", + val description: String = "", + val keywords: String = "", + val notes: String = "", + val series: String = "", + val seriesNumber: String = "", + val dewey: String = "", + val price: String = "", + val acquisitionDate: String = "", + val acquisitionType: String = "", + val inventoryBase: String = "", + /** Create only: the edit screen adds copies through the copies endpoint. */ + val copies: Int = 1, + val authors: List = emptyList(), + val publishers: List = emptyList(), + val genre: IdName? = null, + val subgenre: IdName? = null, + val shelfId: Int? = null, + val levelId: Int? = null, + /** Create only: a remote cover (scraper or typed URL) the server downloads. */ + val coverUrl: String = "", + /** Create only: author biography from the scraper (`scraped.author_bio`). */ + val authorBio: String = "", +) { + /** Every field that currently blocks saving. [isEdit] skips create-only checks. */ + fun validate(isEdit: Boolean = false): Set = buildSet { + if (title.isBlank()) add(FormField.TITLE) + if (!IsbnCodec.isValidIsbn13OrBlank(isbn13)) add(FormField.ISBN13) + if (!IsbnCodec.isValidIsbn10OrBlank(isbn10)) add(FormField.ISBN10) + if (!IsbnCodec.isValidEanOrBlank(ean)) add(FormField.EAN) + if (!IsbnCodec.isValidIssnOrBlank(issn)) add(FormField.ISSN) + if (year.isNotBlank() && !YEAR.matches(year.trim())) add(FormField.YEAR) + if (pages.isNotBlank() && (pages.trim().toIntOrNull()?.let { it <= 0 } != false)) add(FormField.PAGES) + if (price.isNotBlank() && !PRICE.matches(price.trim())) add(FormField.PRICE) + if (!isEdit && copies !in COPIES_RANGE) add(FormField.COPIES) + if (!isEdit && coverUrl.isNotBlank() && !isHttpUrl(coverUrl)) add(FormField.COVER_URL) + } + + fun canSave(isEdit: Boolean = false): Boolean = validate(isEdit).isEmpty() + + companion object { + val COPIES_RANGE = 1..100 + private val YEAR = Regex("^-?\\d{1,4}$") + private val PRICE = Regex("^\\d{1,7}([.,]\\d{1,2})?$") + + fun isHttpUrl(value: String): Boolean { + val v = value.trim().lowercase() + return (v.startsWith("https://") || v.startsWith("http://")) && v.length > 10 && ' ' !in v + } + + /** The form for an existing record (edit screen). */ + fun from(book: StaffBook): StaffBookForm = StaffBookForm( + title = book.title, + subtitle = book.subtitle.orEmpty(), + isbn13 = book.isbn13.orEmpty(), + isbn10 = book.isbn10.orEmpty(), + ean = book.ean.orEmpty(), + issn = book.issn.orEmpty(), + year = book.year.orEmpty(), + pubDate = book.pubDate.orEmpty(), + edition = book.edition.orEmpty(), + place = book.place.orEmpty(), + pages = book.pages.orEmpty(), + language = book.language.orEmpty(), + format = book.format.orEmpty(), + mediaType = book.mediaType.orEmpty(), + description = book.description.orEmpty(), + keywords = book.keywords.orEmpty(), + notes = book.notes.orEmpty(), + series = book.series.orEmpty(), + seriesNumber = book.seriesNumber.orEmpty(), + dewey = book.dewey.orEmpty(), + price = book.price.orEmpty(), + acquisitionDate = book.acquisitionDate.orEmpty(), + acquisitionType = book.acquisitionType.orEmpty(), + inventoryBase = book.inventoryBase.orEmpty(), + copies = book.copiesTotal.coerceAtLeast(1), + authors = book.authors.filter { it.role.lowercase() in AUTHOR_ROLES }.map { PickedEntity(it.id, it.name) }, + publishers = book.publishers.map { PickedEntity(it.id, it.name) }, + genre = book.genre?.takeIf { it.id > 0 }, + subgenre = book.subgenre?.takeIf { it.id > 0 }, + shelfId = book.location?.shelfId, + levelId = book.location?.levelId, + ) + + /** + * A new record prefilled from the scraper. A non-book EAN goes into [ean]; a + * Bookland code into the ISBN fields. Scraped authors and publisher are sent as new + * names: the server matches them to existing records (find-or-create), like the web form. + */ + fun fromPreview(preview: IsbnPreview?, identifier: Identifier?): StaffBookForm { + val p = preview + val isbn13 = (identifier as? Identifier.Isbn13)?.isbn13 ?: p?.isbn13.orEmpty() + val isbn10 = (identifier as? Identifier.Isbn13)?.isbn10 ?: p?.isbn10.orEmpty() + val ean = (identifier as? Identifier.Ean)?.ean ?: p?.ean.orEmpty() + return StaffBookForm( + title = p?.title.orEmpty(), + subtitle = p?.subtitle.orEmpty(), + isbn13 = isbn13, + isbn10 = isbn10, + ean = ean, + year = p?.year?.takeIf { YEAR.matches(it.trim()) }.orEmpty(), + pubDate = p?.pubDate.orEmpty(), + edition = p?.edition.orEmpty(), + place = p?.place.orEmpty(), + pages = p?.pages?.trim()?.takeIf { it.toIntOrNull()?.let { n -> n > 0 } == true }.orEmpty(), + language = p?.language.orEmpty(), + format = p?.format.orEmpty(), + mediaType = p?.mediaType.orEmpty(), + description = p?.description.orEmpty(), + keywords = p?.keywords.orEmpty(), + series = p?.series.orEmpty(), + dewey = p?.dewey.orEmpty(), + price = p?.price?.trim()?.takeIf { PRICE.matches(it) }.orEmpty(), + authors = p?.authorNames.orEmpty().map { PickedEntity(0, it) }, + publishers = listOfNotNull(p?.publisher?.takeIf { it.isNotBlank() }?.let { PickedEntity(0, it) }), + coverUrl = p?.coverUrl?.takeIf { isHttpUrl(it) }.orEmpty(), + authorBio = p?.authorBio.orEmpty(), + ) + } + + /** A new manual record seeded with a typed or scanned identifier only. */ + fun fromIdentifier(identifier: Identifier?): StaffBookForm = fromPreview(null, identifier) + } +} + +/** + * Builds the JSON bodies of `POST /staff/books` and `PUT /staff/books/{id}` (the + * `StaffBookWrite` contract, English keys; the server maps them to the admin form). + * + * - [create] sends every non-blank field plus copies, people, classification, location and + * the remote cover. + * - [update] is a true patch: only fields that differ from [StaffBookForm.from] of the + * original record are sent. A cleared text field is sent as `""`, a cleared number as + * `null`; the server merges the patch over the current record, so nothing else moves. + * When the authors change, the original translators/illustrators/editors/colorists are + * re-sent in `contributors` so the rebuild of the author links keeps them. + */ +object StaffBookWrite { + + fun create(form: StaffBookForm): JsonObject = buildJsonObject { + textFields(form).forEach { (key, value) -> if (value.isNotBlank()) put(key, value.trim()) } + numberField(form.year)?.let { put("year", it) } + numberField(form.pages)?.let { put("pages", it) } + put("copies", form.copies.coerceIn(StaffBookForm.COPIES_RANGE)) + putPeople("author_ids", "authors_new", form.authors) + putPeople("publisher_ids", "publishers_new", form.publishers) + form.genre?.let { put("genre_id", it.id) } + form.subgenre?.let { put("subgenre_id", it.id) } + if (form.shelfId != null && form.levelId != null) { + put("shelf_id", form.shelfId) + put("level_id", form.levelId) + put("position", 0) // next free position, computed server-side + } + if (StaffBookForm.isHttpUrl(form.coverUrl)) put("cover_url", form.coverUrl.trim()) + if (form.authorBio.isNotBlank()) put("scraped", buildJsonObject { put("author_bio", form.authorBio.trim()) }) + } + + fun update(original: StaffBook, form: StaffBookForm): JsonObject { + val before = StaffBookForm.from(original) + return buildJsonObject { + val old = textFields(before).toMap() + textFields(form).forEach { (key, value) -> + if (value.trim() != old[key].orEmpty().trim()) put(key, value.trim()) + } + if (form.year.trim() != before.year.trim()) put("year", numberField(form.year) ?: JsonNull) + if (form.pages.trim() != before.pages.trim()) put("pages", numberField(form.pages) ?: JsonNull) + if (form.authors != before.authors) { + putPeople("author_ids", "authors_new", form.authors, alwaysBoth = true) + put("contributors", preservedContributors(original)) + } + if (form.publishers != before.publishers) { + putPeople("publisher_ids", "publishers_new", form.publishers, alwaysBoth = true) + } + if (form.genre?.id != before.genre?.id) put("genre_id", form.genre?.id?.let { JsonPrimitive(it) } ?: JsonNull) + if (form.subgenre?.id != before.subgenre?.id) put("subgenre_id", form.subgenre?.id?.let { JsonPrimitive(it) } ?: JsonNull) + if (form.shelfId != before.shelfId || form.levelId != before.levelId) { + if (form.shelfId != null && form.levelId != null) { + put("shelf_id", form.shelfId) + put("level_id", form.levelId) + put("position", 0) + } else { + put("shelf_id", JsonNull) + put("level_id", JsonNull) + } + } + } + } + + private fun textFields(f: StaffBookForm): List> = listOf( + "title" to f.title, + "subtitle" to f.subtitle, + "isbn13" to IsbnCodec.normalize(f.isbn13), + "isbn10" to IsbnCodec.normalize(f.isbn10), + "ean" to IsbnCodec.normalize(f.ean), + "issn" to f.issn.trim().uppercase(), + "pub_date" to f.pubDate, + "edition" to f.edition, + "place" to f.place, + "language" to f.language, + "format" to f.format, + "media_type" to f.mediaType, + "description" to f.description, + "keywords" to f.keywords, + "notes" to f.notes, + "series" to f.series, + "series_number" to f.seriesNumber, + "dewey" to f.dewey, + "price" to f.price.replace(',', '.'), + "acquisition_date" to f.acquisitionDate, + "acquisition_type" to f.acquisitionType, + "inventory_base" to f.inventoryBase, + ) + + private fun numberField(raw: String): JsonPrimitive? = + raw.trim().toIntOrNull()?.let { JsonPrimitive(it) } + + private fun kotlinx.serialization.json.JsonObjectBuilder.putPeople( + idsKey: String, + newKey: String, + people: List, + alwaysBoth: Boolean = false, + ) { + val ids = people.filter { !it.isNew }.map { it.id }.distinct() + val names = people.filter { it.isNew }.map { it.name.trim() }.filter { it.isNotEmpty() }.distinct() + if (alwaysBoth || ids.isNotEmpty()) put(idsKey, JsonArray(ids.map { JsonPrimitive(it) })) + if (alwaysBoth || names.isNotEmpty()) put(newKey, JsonArray(names.map { JsonPrimitive(it) })) + } + + private fun preservedContributors(original: StaffBook): JsonObject { + fun ids(vararg roles: String) = buildJsonArray { + original.authors.filter { it.role.lowercase() in roles && it.id > 0 }.map { it.id }.distinct().forEach { add(JsonPrimitive(it)) } + } + return buildJsonObject { + put("translator_ids", ids("traduttore")) + put("illustrator_ids", ids("illustratore")) + put("editor_ids", ids("curatore")) + put("colorist_ids", ids("colorista")) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt index dbec685..c9f462f 100644 --- a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt +++ b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt @@ -48,6 +48,12 @@ data class InstanceFeatures( val periodicalsAvailable: Boolean = false, val archivesAvailable: Boolean = false, val desiderataAvailable: Boolean = false, + /** Staff content management (Mobile API 1.6.0 `staff_catalog`); hidden until advertised. */ + val staffCatalogAvailable: Boolean = false, + /** Delta catalogue sync (`catalog_sync`); without it the app keeps the first-page snapshot. */ + val catalogSyncAvailable: Boolean = false, + /** Personal loans ICS feed (`calendar_feed`, false in catalogue mode). */ + val calendarFeedAvailable: Boolean = false, ) { /** Library tab (loans + reservations) is shown only when at least one of them is enabled. */ val showLibrary: Boolean get() = loans || reservations @@ -106,6 +112,9 @@ class FeatureStore(context: Context) { periodicalsAvailable = prefs.getBoolean(KEY_PERIODICALS, false), archivesAvailable = f.archives && health.appAccessEnabled, desiderataAvailable = f.desiderata && health.appAccessEnabled, + staffCatalogAvailable = f.staffCatalog && health.appAccessEnabled, + catalogSyncAvailable = f.catalogSync && health.appAccessEnabled, + calendarFeedAvailable = f.calendarFeed && health.appAccessEnabled && !health.catalogueMode, ) prefs.edit() .putBoolean(KEY_KNOWN, true) @@ -121,6 +130,9 @@ class FeatureStore(context: Context) { .putBoolean(KEY_REGISTRATION_ENABLED, value.registrationEnabled) .putBoolean("archives", value.archivesAvailable) .putBoolean("desiderata", value.desiderataAvailable) + .putBoolean(KEY_STAFF_CATALOG, value.staffCatalogAvailable) + .putBoolean(KEY_CATALOG_SYNC, value.catalogSyncAvailable) + .putBoolean(KEY_CALENDAR_FEED, value.calendarFeedAvailable) .apply() _features.value = value } @@ -163,6 +175,9 @@ class FeatureStore(context: Context) { periodicalsAvailable = prefs.getBoolean(KEY_PERIODICALS, false), archivesAvailable = prefs.getBoolean("archives", false), desiderataAvailable = prefs.getBoolean("desiderata", false), + staffCatalogAvailable = prefs.getBoolean(KEY_STAFF_CATALOG, false), + catalogSyncAvailable = prefs.getBoolean(KEY_CATALOG_SYNC, false), + calendarFeedAvailable = prefs.getBoolean(KEY_CALENDAR_FEED, false), ) } @@ -181,5 +196,8 @@ class FeatureStore(context: Context) { private const val KEY_REGISTRATION_ENABLED = "registration_enabled" private const val KEY_BOOK_CLUB = "f_book_club" private const val KEY_PERIODICALS = "f_periodicals" + private const val KEY_STAFF_CATALOG = "f_staff_catalog" + private const val KEY_CATALOG_SYNC = "f_catalog_sync" + private const val KEY_CALENDAR_FEED = "f_calendar_feed" } } diff --git a/app/src/main/java/com/pinakes/app/data/store/SessionStore.kt b/app/src/main/java/com/pinakes/app/data/store/SessionStore.kt index f73cb7a..2e0ed4a 100644 --- a/app/src/main/java/com/pinakes/app/data/store/SessionStore.kt +++ b/app/src/main/java/com/pinakes/app/data/store/SessionStore.kt @@ -34,6 +34,16 @@ class SessionStore(context: Context) { private val _authState = MutableStateFlow(readAuthState()) val authState: StateFlow = _authState.asStateFlow() + private val _role = MutableStateFlow(prefs.getString(KEY_ROLE, null)) + + /** + * The signed-in user's `tipo_utente` (utente | premium | staff | admin), or null when it + * is unknown or the user is signed out. Refreshed from `/auth/login` and `GET /me`; the + * server stays authoritative (every staff route re-checks the role), so this only decides + * which entry points the app shows. + */ + val role: StateFlow = _role.asStateFlow() + /** Instance base URL, normalized to end with `/api/v1/` (or null before onboarding). */ val instanceUrl: String? get() = prefs.getString(KEY_INSTANCE_URL, null) @@ -79,10 +89,21 @@ class SessionStore(context: Context) { .putString(KEY_LIBRARY_NAME, libraryName) .putBoolean(KEY_ALLOW_INSECURE, allowInsecure) .remove(KEY_TOKEN) + .remove(KEY_ROLE) .apply() + _role.value = null _authState.value = readAuthState() } + /** Persist the user's role; null forgets it (signed out, or revoked server-side). */ + fun saveRole(role: String?) { + val normalized = role?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } + val editor = prefs.edit() + if (normalized == null) editor.remove(KEY_ROLE) else editor.putString(KEY_ROLE, normalized) + editor.apply() + _role.value = normalized + } + fun saveToken(token: String) { prefs.edit().putString(KEY_TOKEN, token).apply() _authState.value = readAuthState() @@ -90,7 +111,8 @@ class SessionStore(context: Context) { /** Clear only the token (logout); keep the instance so the user lands on the login screen. */ fun clearToken() { - prefs.edit().remove(KEY_TOKEN).apply() + prefs.edit().remove(KEY_TOKEN).remove(KEY_ROLE).apply() + _role.value = null _authState.value = readAuthState() } @@ -99,6 +121,7 @@ class SessionStore(context: Context) { val savedDevice = deviceId prefs.edit().clear().putString(KEY_DEVICE_ID, savedDevice).apply() pendingAllowInsecureHttp = false + _role.value = null _authState.value = readAuthState() } @@ -116,6 +139,7 @@ class SessionStore(context: Context) { private const val KEY_LIBRARY_NAME = "library_name" private const val KEY_ALLOW_INSECURE = "allow_insecure_http" private const val KEY_DEVICE_ID = "device_id" + private const val KEY_ROLE = "user_role" } } diff --git a/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt b/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt index 698023f..4d02559 100644 --- a/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt +++ b/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt @@ -10,7 +10,11 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.data.repository.AuthRepository import com.pinakes.app.data.repository.CatalogRepository +import com.pinakes.app.data.repository.LibraryRepository +import com.pinakes.app.data.repository.WishlistRepository +import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore import dagger.hilt.EntryPoint import dagger.hilt.InstallIn @@ -19,7 +23,8 @@ import dagger.hilt.components.SingletonComponent import java.util.concurrent.TimeUnit /** - * Periodic background refresh of the offline catalog snapshot. Complements the + * Periodic background refresh of the offline catalogue (delta sync, weekly full resync) and of + * the cached loans and wishlist. Complements the * foreground refresh in [com.pinakes.app.PinakesApplication]: even if the user doesn't * open the app, WorkManager keeps the cached "Available now" shelf reasonably fresh so the * catalog works offline without hammering the server. @@ -39,6 +44,10 @@ class CatalogSyncWorker( interface Deps { fun session(): SessionStore fun catalogRepository(): CatalogRepository + fun libraryRepository(): LibraryRepository + fun wishlistRepository(): WishlistRepository + fun featureStore(): FeatureStore + fun authRepository(): AuthRepository } override suspend fun doWork(): Result { @@ -46,7 +55,15 @@ class CatalogSyncWorker( // Nothing to sync for a signed-out user; succeed so the periodic chain continues. if (!deps.session().isLoggedIn()) return Result.success() - return when (val res = deps.catalogRepository().refreshCatalog()) { + val res = deps.catalogRepository().syncCatalog() + // Best effort: keep the personal lists readable offline too. Their failures never + // decide the worker's result (the catalogue sync does). + if (res is ApiResult.Success) { + val features = deps.featureStore().features.value + if (features.showLibrary) runCatching { deps.libraryRepository().refreshCache() } + if (features.showWishlist) runCatching { deps.wishlistRepository().refreshCache() } + } + return when (res) { is ApiResult.Success -> Result.success() // Only retry TRANSIENT failures; give up (success) on permanent // ones so the periodic chain keeps running without burning diff --git a/app/src/main/java/com/pinakes/app/data/sync/SyncMerge.kt b/app/src/main/java/com/pinakes/app/data/sync/SyncMerge.kt new file mode 100644 index 0000000..ae33b05 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/sync/SyncMerge.kt @@ -0,0 +1,71 @@ +package com.pinakes.app.data.sync + +import com.pinakes.app.data.model.SyncBook +import com.pinakes.app.data.model.SyncPage + +/** What one completed sync run does to the local catalogue. */ +data class SyncResult( + /** True: replace the whole cache with [upserts]; false: apply [upserts] and [deleteIds]. */ + val fullResync: Boolean, + val upserts: List, + val deleteIds: List, + /** The watermark to store as the next `since`, or null when no page carried one. */ + val serverTime: String?, +) + +/** + * Pure delta-sync bookkeeping (no Room, no network), unit-tested in isolation. + * + * The repository fetches every page of `GET /catalog/sync`, then calls [merge] once, applies + * the result in one transaction and only then stores [SyncResult.serverTime]. A run that + * fails half-way therefore leaves the old watermark in place and the next run repeats it; + * repeated upserts are harmless. + */ +object SyncMerge { + + /** The client forces an unfiltered pass at least this often (link-only edits on the server). */ + const val FULL_RESYNC_INTERVAL_MS: Long = 7L * 24 * 60 * 60 * 1000 + + /** + * Whether the next run must ask for everything (`since` omitted): no watermark yet, no + * recorded full pass, or the last full pass is older than [FULL_RESYNC_INTERVAL_MS]. + * A clock that moved backwards also forces a full pass rather than trusting the gap. + */ + fun shouldForceFull(since: String?, lastFullAtMs: Long?, nowMs: Long): Boolean { + if (since.isNullOrBlank() || lastFullAtMs == null) return true + val age = nowMs - lastFullAtMs + return age < 0 || age >= FULL_RESYNC_INTERVAL_MS + } + + /** + * Fold the pages of one run, in order, into a single result. The last change for an id + * wins (an upsert followed by a delete on a later page is a delete, and the reverse). + * [requestedFull] is true when the run omitted `since`; the server can also demand a + * full resync (`full_resync: true`, e.g. a watermark older than 30 days), in which case + * deletes are irrelevant: the cache becomes exactly the upserted set. + */ + fun merge(pages: List, requestedFull: Boolean): SyncResult { + val full = requestedFull || pages.any { it.fullResync } + val latest = LinkedHashMap() // null = delete + for (page in pages) { + for (item in page.items) { + if (item.id <= 0) continue + when (item.change.lowercase()) { + "delete" -> { + latest.remove(item.id) + latest[item.id] = null + } + else -> { + val book = item.book ?: continue + latest.remove(item.id) + latest[item.id] = if (book.id == item.id) book else book.copy(id = item.id) + } + } + } + } + val upserts = latest.values.filterNotNull() + val deletes = if (full) emptyList() else latest.filterValues { it == null }.keys.toList() + val serverTime = pages.firstNotNullOfOrNull { it.serverTime?.takeIf { t -> t.isNotBlank() } } + return SyncResult(fullResync = full, upserts = upserts, deleteIds = deletes, serverTime = serverTime) + } +} diff --git a/app/src/main/java/com/pinakes/app/di/AppModule.kt b/app/src/main/java/com/pinakes/app/di/AppModule.kt index b102d54..cd6bc31 100644 --- a/app/src/main/java/com/pinakes/app/di/AppModule.kt +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -3,9 +3,12 @@ package com.pinakes.app.di import android.content.Context import com.pinakes.app.data.local.AppDatabase import com.pinakes.app.data.local.CatalogDao +import com.pinakes.app.data.local.LibraryDao +import com.pinakes.app.data.local.SyncMetaDao import com.pinakes.app.data.network.NetworkModule import com.pinakes.app.data.repository.AuthRepository import com.pinakes.app.data.repository.BookClubRepository +import com.pinakes.app.data.repository.CalendarRepository import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.repository.CollectionsSource import com.pinakes.app.data.repository.CollectionsRepository @@ -15,6 +18,7 @@ import com.pinakes.app.data.repository.NotificationsRepository import com.pinakes.app.data.repository.PeriodicalsRepository import com.pinakes.app.data.repository.ProfileRepository import com.pinakes.app.data.repository.ReviewsRepository +import com.pinakes.app.data.repository.StaffRepository import com.pinakes.app.data.repository.WishlistRepository import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore @@ -57,8 +61,14 @@ object AppModule { fun catalogDao(database: AppDatabase): CatalogDao = database.catalogDao() @Provides @Singleton - fun catalogRepository(network: NetworkModule, dao: CatalogDao): CatalogRepository = - CatalogRepository(network, dao) + fun libraryDao(database: AppDatabase): LibraryDao = database.libraryDao() + + @Provides @Singleton + fun syncMetaDao(database: AppDatabase): SyncMetaDao = database.syncMetaDao() + + @Provides @Singleton + fun catalogRepository(network: NetworkModule, dao: CatalogDao, syncMeta: SyncMetaDao): CatalogRepository = + CatalogRepository(network, dao, syncMeta) @Provides @Singleton fun bookClubRepository(network: NetworkModule, features: FeatureStore, session: SessionStore): BookClubRepository = @@ -79,13 +89,23 @@ object AppModule { bookClub: BookClubRepository, periodicals: PeriodicalsRepository, catalog: CatalogRepository, - ): AuthRepository = AuthRepository(network, session, features, bookClub, periodicals, catalog) + libraryDao: LibraryDao, + ): AuthRepository = AuthRepository(network, session, features, bookClub, periodicals, catalog, libraryDao) + + @Provides @Singleton + fun libraryRepository(network: NetworkModule, libraryDao: LibraryDao): LibraryRepository = + LibraryRepository(network, libraryDao) + + @Provides @Singleton + fun wishlistRepository(network: NetworkModule, libraryDao: LibraryDao): WishlistRepository = + WishlistRepository(network, libraryDao) @Provides @Singleton - fun libraryRepository(network: NetworkModule): LibraryRepository = LibraryRepository(network) + fun staffRepository(network: NetworkModule, session: SessionStore): StaffRepository = + StaffRepository(network, session) @Provides @Singleton - fun wishlistRepository(network: NetworkModule): WishlistRepository = WishlistRepository(network) + fun calendarRepository(network: NetworkModule): CalendarRepository = CalendarRepository(network) @Provides @Singleton fun reviewsRepository(network: NetworkModule): ReviewsRepository = ReviewsRepository(network) diff --git a/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt index f25e029..886a0bf 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt @@ -1,12 +1,18 @@ package com.pinakes.app.ui.common import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.data.staff.StaffAccess import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore import com.pinakes.app.data.store.ThemeStore import com.pinakes.app.ui.theme.ThemeMode import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn /** * App-wide reactive state that several Composables need regardless of the current screen: @@ -29,5 +35,17 @@ class AppViewModel @Inject constructor( val features = featureStore.features val themeMode = themeStore.mode + /** + * Staff area visibility: an `admin`/`staff` role AND a server that advertises + * `staff_catalog`. The server re-checks the role on every staff call. + */ + val isStaff: StateFlow = combine(session.role, featureStore.features) { role, f -> + StaffAccess.canManageCatalog(role, f.staffCatalogAvailable) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + StaffAccess.canManageCatalog(session.role.value, featureStore.features.value.staffCatalogAvailable), + ) + fun setThemeMode(mode: ThemeMode) = themeStore.setMode(mode) } diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 5739231..827d5a2 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -9,4 +9,8 @@ + + + + diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index 37ed268..ef2509f 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -11,14 +11,23 @@ crash). We therefore exclude both files from both backup paths; the user simply re-onboards + logs in after a restore, which re-creates a valid local session and re-fetches the feature flags. + + The Room cache (pinakes-cache.db: offline catalogue, loans, wishlist) is excluded + too: it belongs to one instance and one user and the next sync rebuilds it. --> + + + + + + diff --git a/app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt b/app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt index 2e6ec75..e979b33 100644 --- a/app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt +++ b/app/src/test/java/com/pinakes/app/CachedBookMapperEdgeTest.kt @@ -3,17 +3,18 @@ package com.pinakes.app import com.pinakes.app.data.local.toCached import com.pinakes.app.data.local.toSummary import com.pinakes.app.data.model.BookSummary +import com.pinakes.app.data.model.SyncBook import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test -/** Extra round-trip/edge cases for the offline-cache entity↔summary mapping (touched: #9). */ +/** Extra round-trip/edge cases for the offline-cache entity↔summary mapping. */ class CachedBookMapperEdgeTest { @Test fun allOptionalNullsRoundTrip() { val s = BookSummary(id = 1, title = "Bare") // every optional left null/default - assertEquals(s, s.toCached(0).toSummary()) + assertEquals(s, s.toCached().toSummary()) } @Test fun fullyPopulatedRoundTrips() { @@ -22,19 +23,12 @@ class CachedBookMapperEdgeTest { year = 2000, language = "it", mediaType = "audiobook", isbn13 = "9781234567897", coverUrl = "https://x/y.jpg", copiesTotal = 5, copiesAvailable = 2, loanableNow = false, ) - assertEquals(s, s.toCached(3).toSummary()) - } - - @Test fun positionIsCarriedAndIndependentOfId() { - val s = BookSummary(id = 7, title = "X") - assertEquals(0, s.toCached(0).position) - assertEquals(999, s.toCached(999).position) - assertEquals(7, s.toCached(0).id) // id stays the book id, not the position + assertEquals(s, s.toCached().toSummary()) } @Test fun availabilityFlagsSurviveTheRoundTrip() { val notLoanableButCopies = BookSummary(id = 1, loanableNow = false, copiesAvailable = 3) - val back = notLoanableButCopies.toCached(0).toSummary() + val back = notLoanableButCopies.toCached().toSummary() assertFalse(back.loanableNow) assertEquals(3, back.copiesAvailable) assertTrue(back.available) // derived: copies > 0 @@ -42,8 +36,14 @@ class CachedBookMapperEdgeTest { @Test fun loanableNowTrueSurvives() { val loanable = BookSummary(id = 2, loanableNow = true, copiesAvailable = 0) - val back = loanable.toCached(1).toSummary() + val back = loanable.toCached().toSummary() assertTrue(back.loanableNow) assertTrue(back.available) } + + @Test fun syncRowWithFreeCopiesIsLoanable() { + // The sync payload may omit loanable_now; free copies still make it loanable. + val c = SyncBook(id = 3, title = "X", copiesAvailable = 1, loanableNow = false).toCached() + assertTrue(c.loanableNow) + } } diff --git a/app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt b/app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt index 5b9fe40..962eb2e 100644 --- a/app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt +++ b/app/src/test/java/com/pinakes/app/CachedBookMapperTest.kt @@ -1,12 +1,17 @@ package com.pinakes.app import com.pinakes.app.data.local.toCached +import com.pinakes.app.data.local.toOfflineDetail import com.pinakes.app.data.local.toSummary import com.pinakes.app.data.model.BookSummary +import com.pinakes.app.data.model.SyncBook import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test -/** Entity ↔ summary mapping that backs the offline catalog cache. */ +/** Entity ↔ model mapping behind the offline catalogue (Room schema v2). */ class CachedBookMapperTest { private val sample = BookSummary( @@ -26,15 +31,58 @@ class CachedBookMapperTest { loanableNow = true, ) - @Test fun roundTripPreservesAllFields() { - assertEquals(sample, sample.toCached(0).toSummary()) - } + private val syncBook = SyncBook( + id = 9, title = "Le onde", author = "Virginia Woolf", publisher = "Einaudi", genre = "Romanzo", + year = 1931, language = "Italiano", mediaType = "libro", isbn13 = "9788806219130", isbn10 = "8806219138", + ean = null, pages = 260, description = "Sei voci.", locationLabel = "A.2.14", + coverUrl = "https://lib.example.org/c/9.jpg", copiesTotal = 2, copiesAvailable = 0, + loanableNow = false, isWanted = true, updatedAt = "2026-10-01 10:00:00", + ) - @Test fun cachedKeepsPosition() { - assertEquals(5, sample.toCached(5).position) + @Test fun summaryRoundTripPreservesAllFields() { + assertEquals(sample, sample.toCached().toSummary()) } @Test fun cachedKeepsPrimaryKey() { - assertEquals(7, sample.toCached(0).id) + assertEquals(7, sample.toCached().id) + } + + @Test fun summaryRowsHaveNoChangeTimestamp() { + assertEquals("", sample.toCached().updatedAt) + } + + @Test fun syncBookKeepsOfflineDetailFields() { + val c = syncBook.toCached() + assertEquals("8806219138", c.isbn10) + assertEquals(260, c.pages) + assertEquals("Sei voci.", c.description) + assertEquals("A.2.14", c.locationLabel) + assertTrue(c.isWanted) + assertEquals("2026-10-01 10:00:00", c.updatedAt) + } + + @Test fun syncBookWithoutTimestampStoresEmpty() { + assertEquals("", syncBook.copy(updatedAt = null).toCached().updatedAt) + } + + @Test fun offlineDetailCarriesSummaryLevelData() { + val d = syncBook.toCached().toOfflineDetail() + assertEquals(9, d.id) + assertEquals("Le onde", d.title) + assertEquals("Virginia Woolf", d.authorsLabel) + assertEquals("Einaudi", d.publisher) + assertEquals("Romanzo", d.genreLabel) + assertEquals(260, d.pages) + assertEquals("A.2.14", d.locationLabel) + assertEquals(2, d.copiesTotal) + assertFalse(d.available) + // Server-only data stays empty offline. + assertNull(d.personalHistory) + assertTrue(d.digitalFiles.isEmpty()) + } + + @Test fun offlineDetailWithoutAuthorHasNoAuthorEntry() { + val d = syncBook.copy(author = null).toCached().toOfflineDetail() + assertTrue(d.authors.isEmpty()) } } diff --git a/app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt b/app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt index 2ea000b..299a4d9 100644 --- a/app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt +++ b/app/src/test/java/com/pinakes/app/CatalogDaoEdgeTest.kt @@ -15,7 +15,7 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -/** Extra edge cases for the offline-catalog Room cache (touched: offline cache #9). */ +/** Extra edge cases for the offline-catalogue Room cache. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [34], application = android.app.Application::class) class CatalogDaoEdgeTest { @@ -23,11 +23,7 @@ class CatalogDaoEdgeTest { private lateinit var db: AppDatabase private lateinit var dao: CatalogDao - private fun book(id: Int, position: Int) = CachedBook( - id = id, position = position, title = "Book $id", subtitle = null, author = null, - publisher = null, genre = null, year = null, language = null, mediaType = null, - isbn13 = null, coverUrl = null, copiesTotal = 1, copiesAvailable = 1, loanableNow = true, - ) + private fun book(id: Int) = CachedBook(id = id, title = "Book $id", copiesTotal = 1, copiesAvailable = 1, loanableNow = true) @Before fun setup() { db = Room.inMemoryDatabaseBuilder( @@ -39,37 +35,38 @@ class CatalogDaoEdgeTest { @After fun teardown() = db.close() @Test fun replaceAllWithEmptyListClears() = runBlocking { - dao.insertAll(listOf(book(1, 0), book(2, 1))) + dao.upsertAll(listOf(book(1), book(2))) dao.replaceAll(emptyList()) assertEquals(0, dao.count()) - assertEquals(emptyList(), dao.observeAll().first()) - } - - @Test fun observeOrdersByPositionNotByInsertionOrId() = runBlocking { - // Insert out of order with non-sequential positions; observeAll sorts by position. - dao.insertAll(listOf(book(10, 5), book(20, 2), book(30, 9))) - assertEquals(listOf(20, 10, 30), dao.observeAll().first().map { it.id }) + assertEquals(emptyList(), dao.observeNewest(40).first()) } @Test fun replaceAllTwiceKeepsOnlyTheLatestSnapshot() = runBlocking { - dao.replaceAll(listOf(book(1, 0), book(2, 1), book(3, 2))) - dao.replaceAll(listOf(book(7, 0))) - assertEquals(listOf(7), dao.observeAll().first().map { it.id }) + dao.replaceAll(listOf(book(1), book(2), book(3))) + dao.replaceAll(listOf(book(7))) + assertEquals(listOf(7), dao.observeNewest(40).first().map { it.id }) assertEquals(1, dao.count()) } - @Test fun countReflectsClear() = runBlocking { - dao.insertAll(listOf(book(1, 0), book(2, 1), book(3, 2))) - assertEquals(3, dao.count()) - dao.clear() - assertEquals(0, dao.count()) + @Test fun deleteIdsWithEmptyListIsANoOp() = runBlocking { + dao.upsertAll(listOf(book(1))) + dao.applyDelta(upserts = emptyList(), deleteIds = emptyList()) + assertEquals(1, dao.count()) } - @Test fun largeBatchRoundTripsInPositionOrder() = runBlocking { - val books = (0 until 50).map { book(id = 1000 - it, position = it) } + @Test fun largeCatalogueReplacesInChunks() = runBlocking { + // Larger than the 500-row chunk: the whole snapshot still lands in one transaction. + val books = (1..1_250).map { book(it) } dao.replaceAll(books) - val out = dao.observeAll().first() - assertEquals(50, out.size) - assertEquals((0 until 50).toList(), out.map { it.position }) + assertEquals(1_250, dao.count()) + dao.applyDelta(upserts = emptyList(), deleteIds = (1..1_100).toList()) + assertEquals(150, dao.count()) + } + + @Test fun emptyQueryListsAllAndUnknownQueryListsNone() = runBlocking { + dao.upsertAll(listOf(book(1))) + // An empty query lists everything; a query with no match lists nothing. + assertEquals(1, dao.search("", "", "", "", "", false, "title_asc", 10, 0).size) + assertEquals(0, dao.search("zzz", "", "", "", "", false, "title_asc", 10, 0).size) } } diff --git a/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt index c14e017..7f6256a 100644 --- a/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt +++ b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt @@ -9,13 +9,14 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config -/** The offline catalog cache (Room). Runs on the JVM via Robolectric's SQLite. */ +/** The offline catalogue (Room v2). Runs on the JVM via Robolectric's SQLite. */ @RunWith(RobolectricTestRunner::class) // API 34: Robolectric 4.13 supports up to API 34 (app compiles against 35). // Plain Application: skip PinakesApplication.onCreate() (it builds EncryptedSharedPreferences @@ -26,18 +27,32 @@ class CatalogDaoTest { private lateinit var db: AppDatabase private lateinit var dao: CatalogDao - private fun book(id: Int, position: Int, title: String = "Book $id") = CachedBook( - id = id, position = position, title = title, subtitle = null, author = null, - publisher = null, genre = null, year = null, language = null, mediaType = null, - isbn13 = null, coverUrl = null, copiesTotal = 1, copiesAvailable = 1, loanableNow = true, + private fun book( + id: Int, + title: String = "Book $id", + updatedAt: String = "", + author: String? = null, + publisher: String? = null, + isbn13: String? = null, + language: String? = null, + genre: String? = null, + available: Int = 1, + ) = CachedBook( + id = id, title = title, author = author, publisher = publisher, isbn13 = isbn13, + language = language, genre = genre, copiesTotal = 1, copiesAvailable = available, + loanableNow = available > 0, updatedAt = updatedAt, ) + private suspend fun search( + q: String = "", author: String = "", publisher: String = "", language: String = "", + genre: String = "", availableOnly: Boolean = false, sort: String = "title_asc", + limit: Int = 50, offset: Int = 0, + ) = dao.search(q, author, publisher, language, genre, availableOnly, sort, limit, offset).map { it.id } + @Before fun setup() { - db = Room.inMemoryDatabaseBuilder( - ApplicationProvider.getApplicationContext(), - AppDatabase::class.java, - ).allowMainThreadQueries().build() + db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java) + .allowMainThreadQueries().build() dao = db.catalogDao() } @@ -47,40 +62,109 @@ class CatalogDaoTest { @Test fun emptyByDefault() = runBlocking { assertEquals(0, dao.count()) - assertEquals(emptyList(), dao.observeAll().first()) + assertEquals(emptyList(), dao.observeNewest(40).first()) + } + + @Test + fun upsertAllReplacesById() = runBlocking { + dao.upsertAll(listOf(book(1, title = "old"))) + dao.upsertAll(listOf(book(1, title = "new"))) + assertEquals(1, dao.count()) + assertEquals("new", dao.byId(1)?.title) } @Test - fun insertAndObserve() = runBlocking { - dao.insertAll(listOf(book(1, 0), book(2, 1))) - assertEquals(2, dao.count()) - assertEquals(2, dao.observeAll().first().size) + fun deleteIdsRemovesOnlyThoseRows() = runBlocking { + dao.upsertAll(listOf(book(1), book(2), book(3))) + dao.deleteIds(listOf(1, 3, 99)) + assertEquals(listOf(2), search()) } @Test - fun observeIsOrderedByPosition() = runBlocking { - dao.insertAll(listOf(book(2, 1), book(1, 0), book(3, 2))) - assertEquals(listOf(1, 2, 3), dao.observeAll().first().map { it.id }) + fun byIdIsNullForUnknown() = runBlocking { + assertNull(dao.byId(5)) } @Test - fun insertReplacesOnConflict() = runBlocking { - dao.insertAll(listOf(book(1, 0, title = "old"))) - dao.insertAll(listOf(book(1, 0, title = "new"))) - assertEquals("new", dao.observeAll().first().single().title) + fun observeNewestOrdersByChangeTimeThenId() = runBlocking { + dao.upsertAll( + listOf( + book(1, updatedAt = "2026-01-01 10:00:00"), + book(2, updatedAt = "2026-03-01 10:00:00"), + book(3, updatedAt = "2026-03-01 10:00:00"), + book(4, updatedAt = ""), + ) + ) + assertEquals(listOf(3, 2, 1, 4), dao.observeNewest(10).first().map { it.id }) + assertEquals(listOf(3, 2), dao.observeNewest(2).first().map { it.id }) } @Test fun replaceAllSwapsTheSnapshotAtomically() = runBlocking { - dao.insertAll(listOf(book(1, 0), book(2, 1))) - dao.replaceAll(listOf(book(9, 0))) - assertEquals(listOf(9), dao.observeAll().first().map { it.id }) - assertEquals(1, dao.count()) + dao.upsertAll(listOf(book(1), book(2))) + dao.replaceAll(listOf(book(9))) + assertEquals(listOf(9), search()) + } + + @Test + fun applyDeltaUpsertsAndDeletesTogether() = runBlocking { + dao.upsertAll(listOf(book(1), book(2, title = "before"))) + dao.applyDelta(upserts = listOf(book(2, title = "after"), book(3)), deleteIds = listOf(1)) + assertEquals(listOf(2, 3), search().sorted()) + assertEquals("after", dao.byId(2)?.title) + } + + @Test + fun searchMatchesTitleAuthorPublisherAndIsbn() = runBlocking { + dao.upsertAll( + listOf( + book(1, title = "Orlando", author = "Virginia Woolf"), + book(2, title = "Ulysses", author = "James Joyce", publisher = "Shakespeare and Co"), + book(3, title = "Dubliners", isbn13 = "9780140186475"), + ) + ) + assertEquals(listOf(1), search(q = "orlan")) // case-insensitive + assertEquals(listOf(1), search(q = "woolf")) + assertEquals(listOf(2), search(q = "shakespeare")) + assertEquals(listOf(3), search(q = "0140186")) + assertEquals(emptyList(), search(q = "proust")) + } + + @Test + fun searchFiltersByLanguageGenreAuthorAndAvailability() = runBlocking { + dao.upsertAll( + listOf( + book(1, title = "A", language = "Italiano", genre = "Romanzo", author = "Calvino", available = 1), + book(2, title = "B", language = "Deutsch", genre = "Romanzo", author = "Mann", available = 0), + book(3, title = "C", language = "Italiano", genre = "Poesia", author = "Montale", available = 0), + ) + ) + assertEquals(listOf(1, 3), search(language = "Italiano")) + assertEquals(listOf(1, 2), search(genre = "Romanzo")) + assertEquals(listOf(2), search(author = "mann")) + assertEquals(listOf(1), search(availableOnly = true)) + assertEquals(listOf(1), search(language = "Italiano", genre = "Romanzo")) + } + + @Test + fun searchSortsAndPages() = runBlocking { + dao.upsertAll( + listOf( + book(1, title = "beta", updatedAt = "2026-01-02 00:00:00"), + book(2, title = "Alpha", updatedAt = "2026-01-01 00:00:00"), + book(3, title = "gamma", updatedAt = "2026-01-03 00:00:00"), + ) + ) + assertEquals(listOf(2, 1, 3), search(sort = "title_asc")) + assertEquals(listOf(3, 1, 2), search(sort = "title_desc")) + assertEquals(listOf(3, 1, 2), search(sort = "newest")) + assertEquals(listOf(2, 1), search(limit = 2)) + assertEquals(listOf(3), search(limit = 2, offset = 2)) } @Test fun clearEmptiesTheCache() = runBlocking { - dao.insertAll(listOf(book(1, 0))) + dao.upsertAll(listOf(book(1))) dao.clear() assertEquals(0, dao.count()) } diff --git a/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt new file mode 100644 index 0000000..3a057a7 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt @@ -0,0 +1,103 @@ +package com.pinakes.app + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import com.pinakes.app.data.local.AppDatabase +import com.pinakes.app.data.local.CachedLoan +import com.pinakes.app.data.local.LibraryDao +import com.pinakes.app.data.local.SyncMeta +import com.pinakes.app.data.local.SyncMetaDao +import com.pinakes.app.data.local.toCached +import com.pinakes.app.data.local.toLoansData +import com.pinakes.app.data.local.toReservationItem +import com.pinakes.app.data.local.toWishlistItem +import com.pinakes.app.data.model.LoanItem +import com.pinakes.app.data.model.LoansData +import com.pinakes.app.data.model.ReservationItem +import com.pinakes.app.data.model.WishlistItem +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Offline copies of the personal lists and the sync bookkeeping (Room v2). */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = android.app.Application::class) +class LibraryDaoTest { + + private lateinit var db: AppDatabase + private lateinit var dao: LibraryDao + private lateinit var meta: SyncMetaDao + + @Before fun setup() { + db = Room.inMemoryDatabaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java) + .allowMainThreadQueries().build() + dao = db.libraryDao() + meta = db.syncMetaDao() + } + + @After fun teardown() = db.close() + + private val loans = LoansData( + pending = listOf(LoanItem(id = 3, bookId = 30, title = "Pending", status = "pendente", cancellable = true)), + active = listOf( + LoanItem(id = 1, bookId = 10, title = "First", status = "in_corso", dueAt = "2026-11-01"), + LoanItem(id = 2, bookId = 20, title = "Second", status = "in_ritardo", dueAttention = true), + ), + history = listOf(LoanItem(id = 4, bookId = 40, title = "Old", status = "restituito", returnedAt = "2026-01-01")), + ) + + @Test fun loansRoundTripPerBucketInServerOrder() = runBlocking { + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, loans.toCached()) + val back = dao.loans(CachedLoan.LOAN_BUCKETS).toLoansData() + assertEquals(loans, back) + } + + @Test fun replacingLoansKeepsReservations() = runBlocking { + dao.replaceLoans(listOf(CachedLoan.RESERVATION), listOf(ReservationItem(id = 9, bookId = 90, title = "R", queuePosition = 2).toCached(0))) + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, loans.toCached()) + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, LoansData().toCached()) + assertTrue(dao.loans(CachedLoan.LOAN_BUCKETS).isEmpty()) + val r = dao.loans(listOf(CachedLoan.RESERVATION)).single().toReservationItem() + assertEquals(2, r.queuePosition) + } + + @Test fun sameIdInDifferentBucketsDoesNotCollide() = runBlocking { + dao.replaceLoans(listOf(CachedLoan.ACTIVE, CachedLoan.RESERVATION), listOf( + LoanItem(id = 1, title = "Loan").toCached(CachedLoan.ACTIVE, 0), + ReservationItem(id = 1, title = "Reservation").toCached(0), + )) + assertEquals(2, dao.loans(listOf(CachedLoan.ACTIVE, CachedLoan.RESERVATION)).size) + } + + @Test fun wishlistReplaceKeepsOrder() = runBlocking { + val items = listOf(WishlistItem(bookId = 5, title = "B"), WishlistItem(bookId = 2, title = "A", copiesAvailable = 1)) + dao.replaceWishlist(items.mapIndexed { i, w -> w.toCached(i) }) + assertEquals(items, dao.wishlist().map { it.toWishlistItem() }) + dao.replaceWishlist(emptyList()) + assertTrue(dao.wishlist().isEmpty()) + } + + @Test fun clearAllDropsEverythingPersonal() = runBlocking { + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, loans.toCached()) + dao.replaceWishlist(listOf(WishlistItem(bookId = 1, title = "W").toCached(0))) + dao.clearAll() + assertTrue(dao.loans(CachedLoan.LOAN_BUCKETS + CachedLoan.RESERVATION).isEmpty()) + assertTrue(dao.wishlist().isEmpty()) + } + + @Test fun syncMetaStoresAndReplacesValues() = runBlocking { + assertNull(meta.get(SyncMeta.CATALOG_SERVER_TIME)) + meta.put(SyncMeta(SyncMeta.CATALOG_SERVER_TIME, "2026-10-13 10:00:00")) + meta.put(SyncMeta(SyncMeta.CATALOG_SERVER_TIME, "2026-10-13 11:00:00")) + assertEquals("2026-10-13 11:00:00", meta.get(SyncMeta.CATALOG_SERVER_TIME)) + meta.clear() + assertNull(meta.get(SyncMeta.CATALOG_SERVER_TIME)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/CalendarLinksTest.kt b/app/src/test/java/com/pinakes/app/staff/CalendarLinksTest.kt new file mode 100644 index 0000000..796ecc2 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/CalendarLinksTest.kt @@ -0,0 +1,36 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.calendar.CalendarLinks +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CalendarLinksTest { + + private val feed = "https://lib.example.org/api/v1/calendar/feed/" + "a".repeat(64) + ".ics" + + @Test fun httpsBecomesWebcal() { + assertEquals("webcal://lib.example.org/api/v1/calendar/feed/" + "a".repeat(64) + ".ics", CalendarLinks.toWebcal(feed)) + } + + @Test fun httpLoopbackBecomesWebcalToo() { + assertEquals("webcal://10.0.2.2:8081/x.ics", CalendarLinks.toWebcal("http://10.0.2.2:8081/x.ics")) + } + + @Test fun schemeIsCaseInsensitiveAndOtherInputUnchanged() { + assertEquals("webcal://h/x.ics", CalendarLinks.toWebcal("HTTPS://h/x.ics")) + assertEquals("ftp://h/x", CalendarLinks.toWebcal("ftp://h/x")) + } + + @Test fun webcalBackToHttps() { + assertEquals("https://h/x.ics", CalendarLinks.toHttps("webcal://h/x.ics")) + assertEquals("https://h/x.ics", CalendarLinks.toHttps("https://h/x.ics")) + } + + @Test fun subscribeUrlPrefersServerWebcal() { + assertEquals("webcal://srv/y.ics", CalendarLinks.subscribeUrl(feed, "webcal://srv/y.ics")) + assertEquals(CalendarLinks.toWebcal(feed), CalendarLinks.subscribeUrl(feed, null)) + assertEquals(CalendarLinks.toWebcal(feed), CalendarLinks.subscribeUrl(feed, " ")) + assertNull(CalendarLinks.subscribeUrl(null, null)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/IsbnCodecTest.kt b/app/src/test/java/com/pinakes/app/staff/IsbnCodecTest.kt new file mode 100644 index 0000000..a24898b --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/IsbnCodecTest.kt @@ -0,0 +1,87 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.staff.Identifier +import com.pinakes.app.data.staff.IsbnCodec +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class IsbnCodecTest { + + @Test fun ean13ChecksumValidAndInvalid() { + assertTrue(IsbnCodec.checkEan13("9788804668237")) + assertFalse(IsbnCodec.checkEan13("9788804668238")) + assertTrue(IsbnCodec.checkEan13("4006381333931")) // non-book EAN + assertFalse(IsbnCodec.checkEan13("978880466823")) // 12 digits + assertFalse(IsbnCodec.checkEan13("97888046682A7")) + } + + @Test fun scanOfBooklandBecomesIsbn13WithIsbn10() { + val id = IsbnCodec.fromScan("9788804668237") + assertEquals(Identifier.Isbn13("9788804668237", "8804668237"), id) + assertEquals("9788804668237", id?.lookupCode) + } + + @Test fun scanOf979HasNoIsbn10() { + val id = IsbnCodec.fromScan("9791032305690") as Identifier.Isbn13 + assertNull(id.isbn10) + } + + @Test fun scanOfNonBookEanIsEan() { + assertEquals(Identifier.Ean("4006381333931"), IsbnCodec.fromScan("4006381333931")) + } + + @Test fun scanRejectsBadChecksumAndPartialReads() { + assertNull(IsbnCodec.fromScan("9788804668238")) + assertNull(IsbnCodec.fromScan("88046682")) + assertNull(IsbnCodec.fromScan(null)) + assertNull(IsbnCodec.fromScan("978-8804668237")) // scans are digits only + } + + @Test fun manualIsbn10WithXConvertsTo13AndKeepsBoth() { + val id = IsbnCodec.fromManual("0-8044-2957-X") as Identifier.Isbn13 + assertEquals("080442957X", id.isbn10) + assertEquals("9780804429573", id.isbn13) + assertTrue(IsbnCodec.checkEan13(id.isbn13)) + } + + @Test fun manualLowercaseXIsAccepted() { + assertEquals("080442957X", (IsbnCodec.fromManual("080442957x") as Identifier.Isbn13).isbn10) + } + + @Test fun manualAcceptsDashesAndSpaces() { + assertEquals(Identifier.Isbn13("9788804668237", "8804668237"), IsbnCodec.fromManual(" 978-88-04 66823-7 ")) + } + + @Test fun manualAcceptsEan() { + assertEquals(Identifier.Ean("4006381333931"), IsbnCodec.fromManual("4006381333931")) + } + + @Test fun manualRejectsGarbage() { + assertNull(IsbnCodec.fromManual("")) + assertNull(IsbnCodec.fromManual("hello")) + assertNull(IsbnCodec.fromManual("0804429571")) // wrong ISBN-10 check digit + assertNull(IsbnCodec.fromManual("X804429573")) // X only allowed last + assertNull(IsbnCodec.fromManual("97888046682370")) // 14 digits + } + + @Test fun isbn10And13ConversionsAreInverse() { + assertEquals("9788804668237", IsbnCodec.isbn10To13("8804668237")) + assertEquals("8804668237", IsbnCodec.isbn13To10("9788804668237")) + assertNull(IsbnCodec.isbn13To10("9791032305690")) + } + + @Test fun formValidatorsAllowBlankAndRejectWrongCodes() { + assertTrue(IsbnCodec.isValidIsbn13OrBlank("")) + assertTrue(IsbnCodec.isValidIsbn13OrBlank("978-88-04-66823-7")) + assertFalse(IsbnCodec.isValidIsbn13OrBlank("4006381333931")) // valid EAN, not an ISBN + assertTrue(IsbnCodec.isValidIsbn10OrBlank("080442957X")) + assertFalse(IsbnCodec.isValidIsbn10OrBlank("0804429571")) + assertTrue(IsbnCodec.isValidEanOrBlank("4006381333931")) + assertTrue(IsbnCodec.isValidIssnOrBlank("0317-8471")) + assertTrue(IsbnCodec.isValidIssnOrBlank("2049-3630")) + assertFalse(IsbnCodec.isValidIssnOrBlank("0317-8472")) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/ScanDebouncerTest.kt b/app/src/test/java/com/pinakes/app/staff/ScanDebouncerTest.kt new file mode 100644 index 0000000..add926e --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/ScanDebouncerTest.kt @@ -0,0 +1,61 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.staff.ScanDebouncer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ScanDebouncerTest { + + private val a = "9788804668237" + private val b = "9780140186475" + + @Test fun needsTwoConsecutiveIdenticalReads() { + val d = ScanDebouncer() + assertNull(d.offer(a, 0)) + assertEquals(a, d.offer(a, 50)) + } + + @Test fun aDifferentCodeInBetweenRestartsTheStreak() { + val d = ScanDebouncer() + assertNull(d.offer(a, 0)) + assertNull(d.offer(b, 10)) + assertNull(d.offer(a, 20)) + assertEquals(a, d.offer(a, 30)) + } + + @Test fun sameCodeIsSuppressedForThreeSeconds() { + val d = ScanDebouncer() + d.offer(a, 0); assertEquals(a, d.offer(a, 10)) + assertNull(d.offer(a, 500)) + assertNull(d.offer(a, 2_999)) + // After the window it needs two fresh reads again. + assertNull(d.offer(a, 3_100)) + assertEquals(a, d.offer(a, 3_200)) + } + + @Test fun anotherCodeIsNotSuppressed() { + val d = ScanDebouncer() + d.offer(a, 0); d.offer(a, 10) + assertNull(d.offer(b, 100)) + assertEquals(b, d.offer(b, 150)) + } + + @Test fun pauseIgnoresEverythingAndResumeStartsFresh() { + val d = ScanDebouncer() + d.pause() + assertNull(d.offer(a, 0)) + assertNull(d.offer(a, 10)) + d.resume() + assertNull(d.offer(a, 20)) // the paused reads did not count + assertEquals(a, d.offer(a, 30)) + } + + @Test fun resetAllowsTheSameCodeImmediately() { + val d = ScanDebouncer() + d.offer(a, 0); d.offer(a, 10) + d.reset() + assertNull(d.offer(a, 20)) + assertEquals(a, d.offer(a, 30)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/StaffAccessTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffAccessTest.kt new file mode 100644 index 0000000..cdd921e --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffAccessTest.kt @@ -0,0 +1,28 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.staff.StaffAccess +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StaffAccessTest { + + @Test fun adminAndStaffAreStaff() { + assertTrue(StaffAccess.isStaff("admin")) + assertTrue(StaffAccess.isStaff("staff")) + assertTrue(StaffAccess.isStaff(" Staff ")) + } + + @Test fun readersAndUnknownAreNot() { + assertFalse(StaffAccess.isStaff("utente")) + assertFalse(StaffAccess.isStaff("premium")) + assertFalse(StaffAccess.isStaff("")) + assertFalse(StaffAccess.isStaff(null)) + } + + @Test fun staffAreaNeedsBothRoleAndServerSupport() { + assertTrue(StaffAccess.canManageCatalog("admin", serverSupportsStaff = true)) + assertFalse(StaffAccess.canManageCatalog("admin", serverSupportsStaff = false)) + assertFalse(StaffAccess.canManageCatalog("utente", serverSupportsStaff = true)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt new file mode 100644 index 0000000..603ec1f --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt @@ -0,0 +1,179 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.IdName +import com.pinakes.app.data.model.IsbnPreview +import com.pinakes.app.data.model.StaffAuthorRef +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffLocation +import com.pinakes.app.data.staff.FormField +import com.pinakes.app.data.staff.Identifier +import com.pinakes.app.data.staff.PickedEntity +import com.pinakes.app.data.staff.StaffBookForm +import com.pinakes.app.data.staff.StaffBookWrite +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class StaffBookFormStateTest { + + private val valid = StaffBookForm(title = "Spartito", copies = 1) + + @Test fun titleIsRequired() { + assertTrue(FormField.TITLE in StaffBookForm().validate()) + assertTrue(FormField.TITLE in StaffBookForm(title = " ").validate()) + assertTrue(valid.canSave()) + } + + @Test fun copiesMustBeOneToHundredOnCreateOnly() { + assertTrue(FormField.COPIES in valid.copy(copies = 0).validate()) + assertTrue(FormField.COPIES in valid.copy(copies = 101).validate()) + assertTrue(valid.copy(copies = 100).canSave()) + assertTrue(valid.copy(copies = 0).canSave(isEdit = true)) + } + + @Test fun identifiersAreValidatedButOptional() { + assertTrue(valid.copy(isbn13 = "978-88-04-66823-7").canSave()) + assertFalse(valid.copy(isbn13 = "9788804668238").canSave()) + assertFalse(valid.copy(isbn10 = "0804429571").canSave()) + assertFalse(valid.copy(ean = "123").canSave()) + assertFalse(valid.copy(issn = "0317-8472").canSave()) + assertEquals(setOf(FormField.ISBN13, FormField.EAN), valid.copy(isbn13 = "1", ean = "2").validate()) + } + + @Test fun numbersAreChecked() { + assertFalse(valid.copy(year = "19x9").canSave()) + assertTrue(valid.copy(year = "-500").canSave()) // antiquity + assertFalse(valid.copy(pages = "0").canSave()) + assertFalse(valid.copy(pages = "abc").canSave()) + assertTrue(valid.copy(price = "12,50").canSave()) + assertFalse(valid.copy(price = "12.505").canSave()) + } + + @Test fun createSendsOnlyFilledFields() { + val body = StaffBookWrite.create( + valid.copy( + subtitle = "", isbn13 = "978-88-04-66823-7", year = "2015", pages = "320", copies = 2, + authors = listOf(PickedEntity(4, "Eco"), PickedEntity(0, "New Author")), + publishers = listOf(PickedEntity(0, "Bompiani")), + genre = IdName(3, "Romanzo"), shelfId = 1, levelId = 2, + coverUrl = "https://covers.example.org/x.jpg", authorBio = "Bio", + ) + ) + assertEquals("Spartito", body.str("title")) + assertFalse("subtitle" in body) + assertEquals("9788804668237", body.str("isbn13")) // separators stripped + assertEquals(2015, body.num("year")) + assertEquals(320, body.num("pages")) + assertEquals(2, body.num("copies")) + assertEquals(listOf(4), body.ints("author_ids")) + assertEquals(listOf("New Author"), body.strs("authors_new")) + assertFalse("publisher_ids" in body) + assertEquals(listOf("Bompiani"), body.strs("publishers_new")) + assertEquals(3, body.num("genre_id")) + assertEquals(1, body.num("shelf_id")) + assertEquals(2, body.num("level_id")) + assertEquals(0, body.num("position")) + assertEquals("https://covers.example.org/x.jpg", body.str("cover_url")) + assertEquals("Bio", body["scraped"]!!.jsonObject.str("author_bio")) + } + + @Test fun createWithoutLevelSendsNoLocation() { + val body = StaffBookWrite.create(valid.copy(shelfId = 1, levelId = null)) + assertFalse("shelf_id" in body) + assertFalse("position" in body) + } + + private val original = StaffBook( + id = 10, title = "Orlando", subtitle = "A Biography", isbn13 = "9780156701600", year = "1928", pages = "333", + language = "English", copiesTotal = 2, + authors = listOf( + StaffAuthorRef(1, "Virginia Woolf", "principale"), + StaffAuthorRef(7, "Nadia Fusini", "traduttore"), + ), + publishers = listOf(IdName(5, "Hogarth")), + genre = IdName(3, "Romanzo"), + location = StaffLocation(shelfId = 1, levelId = 2, position = 4), + ) + + @Test fun formFromRecordKeepsAuthorsButNotContributors() { + val f = StaffBookForm.from(original) + assertEquals(listOf(PickedEntity(1, "Virginia Woolf")), f.authors) + assertEquals(1, f.shelfId) + assertEquals(2, f.levelId) + } + + @Test fun unchangedEditSendsAnEmptyPatch() { + assertEquals(JsonObject(emptyMap()), StaffBookWrite.update(original, StaffBookForm.from(original))) + } + + @Test fun editSendsOnlyTheChangedFields() { + val patch = StaffBookWrite.update(original, StaffBookForm.from(original).copy(title = "Orlando (new)", subtitle = "")) + assertEquals(setOf("title", "subtitle"), patch.keys) + assertEquals("", patch.str("subtitle")) // cleared text is sent as "" + } + + @Test fun clearedNumberIsSentAsNull() { + val patch = StaffBookWrite.update(original, StaffBookForm.from(original).copy(year = "")) + assertEquals(JsonNull, patch["year"]) + } + + @Test fun changedAuthorsResendPreservedContributors() { + val patch = StaffBookWrite.update( + original, + StaffBookForm.from(original).copy(authors = listOf(PickedEntity(1, "Virginia Woolf"), PickedEntity(0, "Second"))), + ) + assertEquals(listOf(1), patch.ints("author_ids")) + assertEquals(listOf("Second"), patch.strs("authors_new")) + assertEquals(listOf(7), patch["contributors"]!!.jsonObject.ints("translator_ids")) + assertFalse("publisher_ids" in patch) + } + + @Test fun removingLocationSendsNulls() { + val patch = StaffBookWrite.update(original, StaffBookForm.from(original).copy(shelfId = null, levelId = null)) + assertEquals(JsonNull, patch["shelf_id"]) + assertEquals(JsonNull, patch["level_id"]) + } + + @Test fun previewPrefillUsesTheScannedIdentifier() { + val f = StaffBookForm.fromPreview( + IsbnPreview(title = "Il nome della rosa", authors = listOf(JsonPrimitive("Umberto Eco")), publisher = "Bompiani", + year = "1980", pages = "503", coverUrl = "https://c/x.jpg", isbn13 = "0000000000000"), + Identifier.Isbn13("9788804668237", "8804668237"), + ) + assertEquals("9788804668237", f.isbn13) + assertEquals("8804668237", f.isbn10) + assertEquals(listOf(PickedEntity(0, "Umberto Eco")), f.authors) + assertEquals(listOf(PickedEntity(0, "Bompiani")), f.publishers) + assertEquals("1980", f.year) + assertTrue(f.canSave()) + } + + @Test fun nonBookEanGoesToTheEanField() { + val f = StaffBookForm.fromIdentifier(Identifier.Ean("4006381333931")) + assertEquals("4006381333931", f.ean) + assertEquals("", f.isbn13) + assertFalse(f.canSave()) // still needs a title + } + + @Test fun scraperJunkIsDroppedNotBlockingSave() { + val f = StaffBookForm.fromPreview(IsbnPreview(title = "X", year = "circa 1900", pages = "xii, 200", price = "EUR 12"), null) + assertEquals("", f.year) + assertEquals("", f.pages) + assertEquals("", f.price) + assertTrue(f.canSave()) + } + + private fun JsonObject.str(k: String) = this[k]!!.jsonPrimitive.content + private fun JsonObject.num(k: String) = this[k]!!.jsonPrimitive.int + private fun JsonObject.ints(k: String) = (this[k] as JsonArray).map { it.jsonPrimitive.int } + private fun JsonObject.strs(k: String) = this[k]!!.jsonArray.map { it.jsonPrimitive.content } +} diff --git a/app/src/test/java/com/pinakes/app/staff/StaffModelsParsingTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffModelsParsingTest.kt new file mode 100644 index 0000000..6f4cf61 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffModelsParsingTest.kt @@ -0,0 +1,126 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.CalendarStatus +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.model.IsbnLookup +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffGenreNode +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.model.SyncPage +import com.pinakes.app.data.model.flatten +import com.pinakes.app.data.network.parseErrorBody +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The Mobile API 1.6.0 payloads decode with the app's tolerant Json settings. */ +class StaffModelsParsingTest { + + // Same configuration as NetworkModule.json. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun healthFeaturesDefaultOffOnOlderServers() { + val old = json.decodeFromString("""{"name":"Lib","features":{"catalog":true}}""") + assertFalse(old.features.staffCatalog) + assertFalse(old.features.catalogSync) + assertFalse(old.features.calendarFeed) + val new = json.decodeFromString("""{"features":{"staff_catalog":true,"catalog_sync":true,"calendar_feed":true}}""") + assertTrue(new.features.staffCatalog && new.features.catalogSync && new.features.calendarFeed) + } + + @Test fun lookupsAcceptObjectsAndStrings() { + val l = json.decodeFromString( + """{"languages":[{"code":"it","label":"Italiano"}],"media_types":[{"value":"libro","label":"Libro"}], + "formats":["Brossura","Copertina rigida"],"acquisition_types":[{"value":"dono"}],"unknown":1}""" + ) + assertEquals("it", l.languageOptions.single().value) + assertEquals("Italiano", l.languageOptions.single().label) + assertEquals("Libro", l.mediaTypeOptions.single().label) + assertEquals(listOf("Brossura", "Copertina rigida"), l.formatOptions.map { it.label }) + assertEquals("dono", l.acquisitionTypeOptions.single().label) + } + + @Test fun isbnLookupWithPreviewAndExisting() { + val r = json.decodeFromString( + """{"identifier":{"input":"9788804668237","isbn10":"8804668237","isbn13":"9788804668237","ean":null,"kind":"isbn"}, + "found":true,"existing":{"id":12,"title":"Il nome della rosa","copies_total":3,"location":"A.1.4"}, + "preview":{"title":"Il nome della rosa","authors":["Umberto Eco",{"name":"Altro"}],"year":1980,"pages":"503", + "price":12.5,"cover_url":"https://c/x.jpg"},"sources_checked":["google",{"name":"sbn"}]}""" + ) + assertTrue(r.found) + assertEquals(12, r.existing?.id) + assertEquals("A.1.4", r.existing?.locationLabel) + assertEquals(listOf("Umberto Eco", "Altro"), r.preview?.authorNames) + assertEquals("1980", r.preview?.year) // number accepted into a string field + assertEquals("12.5", r.preview?.price) + } + + @Test fun isbnLookupNotFound() { + val r = json.decodeFromString("""{"identifier":{"input":"4006381333931","kind":"ean"},"found":false,"existing":null,"preview":null}""") + assertFalse(r.found) + assertNull(r.preview) + assertEquals("ean", r.identifier.kind) + } + + @Test fun staffBookDecodesNestedObjects() { + val b = json.decodeFromString( + """{"id":5,"title":"T","year":2001,"pages":120,"genre":{"id":3,"name":"Romanzo"},"subgenre":null, + "authors":[{"id":1,"name":"A","role":"principale"},{"id":2,"name":"B","role":"traduttore"}], + "publishers":[{"id":9,"name":"P"}],"location":{"shelf_id":1,"shelf_code":"A","level_id":2,"level_number":1,"position":3,"label":"A.1.3"}, + "copies":[{"id":1,"inventory_number":"LIB-5-C1","status":"disponibile"}],"is_wanted":false,"cover_is_local":true}""" + ) + assertEquals("2001", b.year) + assertEquals("120", b.pages) + assertEquals(2, b.authors.size) + assertEquals("A.1.3", b.location?.label) + assertEquals("LIB-5-C1", b.copies.single().inventoryNumber) + } + + @Test fun genreTreeFlattensWithPaths() { + val tree = json.decodeFromString>( + """[{"id":1,"name":"Narrativa","parent_id":null,"children":[{"id":2,"name":"Giallo","parent_id":1,"children":[]}]}]""" + ) + val flat = tree.flatten() + assertEquals(listOf(1, 2), flat.map { it.id }) + assertEquals("Narrativa › Giallo", flat[1].path) + assertEquals(1, flat[1].parentId) + } + + @Test fun syncPageDecodes() { + val env = json.decodeFromString>( + """{"data":{"items":[{"id":1,"change":"upsert","changed_at":"2026-10-13 10:00:00","book":{"id":1,"title":"T","pages":10}}, + {"id":2,"change":"delete","changed_at":"2026-10-13 10:00:01"}],"server_time":"2026-10-13 10:05:00","full_resync":false}, + "meta":{"next_cursor":"abc","has_more":true,"count":2},"error":null}""" + ) + assertEquals(2, env.data?.items?.size) + assertEquals("2026-10-13 10:05:00", env.data?.serverTime) + assertEquals(true, env.meta?.hasMore) + assertEquals("abc", env.meta?.nextCursor) + } + + @Test fun calendarStatusDecodes() { + val s = json.decodeFromString("""{"enabled":true,"feed_url":"https://h/f.ics","webcal_url":"webcal://h/f.ics","created_at":"2026-10-01 09:00:00","rotated_at":null}""") + assertTrue(s.enabled) + assertEquals("webcal://h/f.ics", s.webcalUrl) + assertFalse(json.decodeFromString("""{"enabled":false}""").enabled) + } + + @Test fun duplicateErrorKeepsTheExistingRecordInMeta() { + val f = parseErrorBody( + """{"data":null,"meta":{"existing":{"id":12,"title":"Il nome della rosa"}},"error":{"code":"duplicate","message":"Già presente"}}""", + 409, + ) + assertEquals("duplicate", f.code) + assertEquals(409, f.httpStatus) + assertTrue(f.meta?.existing != null) + } + + @Test fun conflictReasonIsKept() { + val f = parseErrorBody("""{"meta":{"reason":"active_circulation"},"error":{"code":"conflict","message":"x"}}""", 409) + assertEquals("active_circulation", f.meta?.reason) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/SyncMergeTest.kt b/app/src/test/java/com/pinakes/app/staff/SyncMergeTest.kt new file mode 100644 index 0000000..5210298 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/SyncMergeTest.kt @@ -0,0 +1,88 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.SyncBook +import com.pinakes.app.data.model.SyncItem +import com.pinakes.app.data.model.SyncPage +import com.pinakes.app.data.sync.SyncMerge +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncMergeTest { + + private fun up(id: Int, title: String = "T$id") = SyncItem(id = id, change = "upsert", book = SyncBook(id = id, title = title)) + private fun del(id: Int) = SyncItem(id = id, change = "delete") + private fun page(vararg items: SyncItem, time: String? = "2026-10-13 10:00:00", full: Boolean = false) = + SyncPage(items = items.toList(), serverTime = time, fullResync = full) + + private val day = 24L * 60 * 60 * 1000 + + @Test fun deltaCollectsUpsertsAndDeletes() { + val r = SyncMerge.merge(listOf(page(up(1), del(2), up(3))), requestedFull = false) + assertFalse(r.fullResync) + assertEquals(listOf(1, 3), r.upserts.map { it.id }) + assertEquals(listOf(2), r.deleteIds) + assertEquals("2026-10-13 10:00:00", r.serverTime) + } + + @Test fun lastChangeForAnIdWinsAcrossPages() { + val r = SyncMerge.merge( + listOf(page(up(1, "old"), up(2)), page(del(1), up(2, "new"), up(4)), page(up(4, "back"))), + requestedFull = false, + ) + assertEquals(listOf(1), r.deleteIds) + assertEquals(mapOf(2 to "new", 4 to "back"), r.upserts.associate { it.id to it.title }) + } + + @Test fun deleteThenUpsertIsAnUpsert() { + val r = SyncMerge.merge(listOf(page(del(5)), page(up(5))), requestedFull = false) + assertTrue(r.deleteIds.isEmpty()) + assertEquals(listOf(5), r.upserts.map { it.id }) + } + + @Test fun serverDemandedFullResyncDropsDeletes() { + val r = SyncMerge.merge(listOf(page(up(1), del(2), full = true)), requestedFull = false) + assertTrue(r.fullResync) + assertTrue(r.deleteIds.isEmpty()) + assertEquals(listOf(1), r.upserts.map { it.id }) + } + + @Test fun requestedFullIsFullEvenIfPagesDoNotSaySo() { + assertTrue(SyncMerge.merge(listOf(page(up(1))), requestedFull = true).fullResync) + } + + @Test fun upsertWithoutBookIsIgnoredAndInvalidIdsSkipped() { + val r = SyncMerge.merge(listOf(page(SyncItem(id = 3, change = "upsert", book = null), up(0), del(-1))), requestedFull = false) + assertTrue(r.upserts.isEmpty()) + assertTrue(r.deleteIds.isEmpty()) + } + + @Test fun bookIdFollowsTheItemId() { + val r = SyncMerge.merge(listOf(page(SyncItem(id = 8, book = SyncBook(id = 0, title = "x")))), requestedFull = false) + assertEquals(8, r.upserts.single().id) + } + + @Test fun watermarkComesFromThePagesAndIsNullWithoutOne() { + assertEquals("2026-10-13 10:00:00", SyncMerge.merge(listOf(page(time = null), page(time = "2026-10-13 10:00:00")), false).serverTime) + assertNull(SyncMerge.merge(listOf(page(time = null)), false).serverTime) + assertNull(SyncMerge.merge(emptyList(), false).serverTime) + } + + @Test fun forcedFullWithoutWatermarkOrHistory() { + assertTrue(SyncMerge.shouldForceFull(since = null, lastFullAtMs = 0, nowMs = 1)) + assertTrue(SyncMerge.shouldForceFull(since = "2026-10-13 10:00:00", lastFullAtMs = null, nowMs = 1)) + } + + @Test fun weeklyFullResync() { + val now = 100 * day + assertFalse(SyncMerge.shouldForceFull("t", lastFullAtMs = now - 6 * day, nowMs = now)) + assertTrue(SyncMerge.shouldForceFull("t", lastFullAtMs = now - 7 * day, nowMs = now)) + assertTrue(SyncMerge.shouldForceFull("t", lastFullAtMs = now - 30 * day, nowMs = now)) + } + + @Test fun clockMovedBackwardsForcesFull() { + assertTrue(SyncMerge.shouldForceFull("t", lastFullAtMs = 10 * day, nowMs = 9 * day)) + } +} From 16e66774705e116a7fa0b585ab1bc7cf7fb97daa Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 16:59:03 +0200 Subject: [PATCH 03/62] Answer from the offline catalogue when the server is unreachable Search falls back to the cached catalogue (title, author, publisher and identifier match, the same facets by value, offset paging, title or newest order) under an offline banner. A book page is built from the cache with a notice; Library and Wishlist show the last lists the server sent. Availability cannot be guaranteed offline, so loan and reservation affordances are hidden, never disabled: no request or reserve button, no loan calendar, no cancel actions, no wishlist toggle or reviews. Cached availability is never shown as current: the book page says when it was last known (sync time), status pills are hidden on cached cards and the cached Home shelf is labelled as recently added instead of available now. OfflinePolicy holds the decision and is unit-tested. --- .../pinakes/app/ui/common/OfflinePolicy.kt | 19 +++++ .../com/pinakes/app/ui/components/BookCard.kt | 10 ++- .../app/ui/screens/detail/BookDetailScreen.kt | 48 +++++++++--- .../ui/screens/detail/BookDetailViewModel.kt | 17 ++++- .../pinakes/app/ui/screens/home/HomeScreen.kt | 5 +- .../app/ui/screens/home/HomeViewModel.kt | 22 +++--- .../app/ui/screens/library/LibraryScreen.kt | 19 ++++- .../ui/screens/library/LibraryViewModel.kt | 21 +++++- .../app/ui/screens/search/SearchScreen.kt | 14 +++- .../app/ui/screens/search/SearchViewModel.kt | 73 ++++++++++++++++++- .../app/ui/screens/wishlist/WishlistScreen.kt | 37 +++++++--- .../ui/screens/wishlist/WishlistViewModel.kt | 28 +++++-- .../pinakes/app/staff/OfflinePolicyTest.kt | 28 +++++++ i18n/de.json | 7 +- i18n/en.json | 7 +- i18n/fr.json | 7 +- i18n/it.json | 7 +- 17 files changed, 305 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/ui/common/OfflinePolicy.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/OfflinePolicyTest.kt diff --git a/app/src/main/java/com/pinakes/app/ui/common/OfflinePolicy.kt b/app/src/main/java/com/pinakes/app/ui/common/OfflinePolicy.kt new file mode 100644 index 0000000..1c06060 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/common/OfflinePolicy.kt @@ -0,0 +1,19 @@ +package com.pinakes.app.ui.common + +/** + * What the app may offer while it is showing cached data (server unreachable). Availability + * cannot be guaranteed offline, so loan and reservation affordances are hidden outright, never + * shown disabled, and cached availability is never presented as current. Pure, unit-tested. + */ +object OfflinePolicy { + + /** + * Whether "Request loan" / "Reserve", the loan calendar and loan or reservation cancel + * actions may be shown: only online, and only when the instance offers loans or + * reservations ([canBorrow], false in catalogue mode). + */ + fun showLoanActions(canBorrow: Boolean, offline: Boolean): Boolean = canBorrow && !offline + + /** Whether an availability pill may claim the current state (never for cached rows). */ + fun showCurrentAvailability(offline: Boolean): Boolean = !offline +} diff --git a/app/src/main/java/com/pinakes/app/ui/components/BookCard.kt b/app/src/main/java/com/pinakes/app/ui/components/BookCard.kt index d05e702..0666047 100644 --- a/app/src/main/java/com/pinakes/app/ui/components/BookCard.kt +++ b/app/src/main/java/com/pinakes/app/ui/components/BookCard.kt @@ -100,7 +100,8 @@ fun BookCover(title: String, coverUrl: String?, modifier: Modifier = Modifier, c /** Compact catalog row. Account lists keep their separate MediaRow actions. */ @Composable fun BookCard( - title: String, author: String, coverUrl: String?, status: AvailabilityStatus = AvailabilityStatus.Available, + // null hides the pill (offline: cached availability is never shown as current). + title: String, author: String, coverUrl: String?, status: AvailabilityStatus? = AvailabilityStatus.Available, year: String? = null, publisher: String? = null, onClick: () -> Unit = {}, modifier: Modifier = Modifier, subtitle: String? = null, mediaType: String? = null, ) { @@ -116,7 +117,7 @@ fun BookCard( if (meta.isNotEmpty()) Text(meta, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - AvailabilityChip(status) + status?.let { AvailabilityChip(it) } MediaTypeIcon(mediaType) } } @@ -126,7 +127,8 @@ fun BookCard( /** The classic web card: book, status pill, title and metadata, with no outer card. */ @Composable fun BookCardGrid( - title: String, author: String, coverUrl: String?, status: AvailabilityStatus = AvailabilityStatus.Available, + // null hides the pill (offline: cached availability is never shown as current). + title: String, author: String, coverUrl: String?, status: AvailabilityStatus? = AvailabilityStatus.Available, onClick: () -> Unit = {}, modifier: Modifier = Modifier, subtitle: String? = null, publisher: String? = null, year: String? = null, mediaType: String? = null, ) { @@ -142,7 +144,7 @@ fun BookCardGrid( Box(Modifier.fillMaxWidth().aspectRatio(2f / 3f).graphicsLayer { translationY = lift.dp.toPx() }) { BookCover(title, coverUrl, Modifier.fillMaxSize(), onTone = if (colors.cardStyle == CardStyle.Tinted) ({ coverTone = it }) else null, author = author, publisher = publisher) - AvailabilityChip(status, modifier = Modifier.align(Alignment.TopStart).padding(8.dp)) + status?.let { AvailabilityChip(it, modifier = Modifier.align(Alignment.TopStart).padding(8.dp)) } Box(Modifier.align(Alignment.BottomEnd).padding(10.dp)) { MediaTypeIcon(mediaType, onCover = true) } } } 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..0f07172 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 @@ -79,12 +79,15 @@ import com.pinakes.app.data.model.BookDetail import com.pinakes.app.data.model.CirculationRequestKind import com.pinakes.app.data.model.PersonalHistory import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.OfflinePolicy +import com.pinakes.app.ui.common.DateFormat import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.AudioPlayer import com.pinakes.app.ui.components.AvailabilityChip import com.pinakes.app.ui.components.AvailabilityStatus import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner import com.pinakes.app.ui.components.HtmlText import com.pinakes.app.ui.components.LoadingState import com.pinakes.app.ui.components.CitationButton @@ -154,8 +157,8 @@ fun BookDetailScreen( title = stringResource(R.string.title_book), onNavigateUp = onNavigateUp, actions = { - // Wishlist toggle is hidden when the wishlist feature is disabled. - if (features.showWishlist) { + // Wishlist toggle is hidden when the wishlist feature is disabled, and offline. + if (features.showWishlist && !state.offline) { IconButton(onClick = vm::toggleWishlist, enabled = !state.wishlistBusy) { Icon( imageVector = if (state.wishlisted) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, @@ -178,20 +181,24 @@ fun BookDetailScreen( book = content.data, wishlisted = state.wishlisted, reserveBusy = state.reserveBusy, - canBorrow = features.canBorrow, - showWishlist = features.showWishlist, - showReviews = features.showReviews, + // Offline (cached summary): no loan/reservation affordance at all (availability + // cannot be guaranteed), and wishlist and reviews need the server too. + canBorrow = OfflinePolicy.showLoanActions(features.canBorrow, state.offline), + showWishlist = features.showWishlist && !state.offline, + showReviews = features.showReviews && !state.offline, onReserve = vm::openLoanSheet, onToggleWishlist = vm::toggleWishlist, onFindWorks = onFindWorks, onShowMessage = { msg -> scope.launch { snackbarHost.showSnackbar(msg) } }, + offlineNotice = if (state.offline) stringResource(R.string.offline_actions_unavailable) else null, + offlineSyncedAt = state.syncedAt, ) } } } // Loan/reservation sheet is only ever shown when borrowing is enabled. - if (state.showLoanSheet && features.canBorrow) { + if (state.showLoanSheet && OfflinePolicy.showLoanActions(features.canBorrow, state.offline)) { LoanRequestSheet( loading = state.availabilityLoading, calendar = state.availability, @@ -335,7 +342,12 @@ internal fun DetailContent( onToggleWishlist: () -> Unit, onShowMessage: (String) -> Unit, onFindWorks: (String, Int?) -> Unit = { _, _ -> }, + /** Set when the page is built from the offline cache: shown above the cover. */ + offlineNotice: String? = null, + /** Last sync time of the offline copy: availability is shown as "last known", never current. */ + offlineSyncedAt: String? = null, ) { + val offline = offlineNotice != null val context = LocalContext.current var showCover by remember { mutableStateOf(false) } var selectedPdf by remember(book.id) { mutableStateOf(null) } @@ -349,6 +361,10 @@ internal fun DetailContent( .padding(Spacing.lg), ) { val colors = LocalPinakesColors.current + if (offlineNotice != null) { + InlineErrorBanner(message = offlineNotice) + Spacer(Modifier.height(Spacing.lg)) + } BookCover(book.title, book.coverUrl, Modifier.widthIn(max = 340.dp).fillMaxWidth().aspectRatio(2f / 3f) .align(Alignment.CenterHorizontally).then(if (bookCoverImageUrl(book.coverUrl) != null) Modifier.clickable(onClickLabel = stringResource(R.string.cd_cover_zoom)) { showCover = true } else Modifier), @@ -416,10 +432,20 @@ internal fun DetailContent( } // No side inset: "Available" / "On loan" lines up with the copies line and the // buttons below it, as on the web book page. - AvailabilityChip(availStatus, availLabel, Modifier.testTag("detail-availability"), - contentPadding = PaddingValues(vertical = 4.dp)) - if (!book.digitalOnly) Text(availableLabel, style = MaterialTheme.typography.bodyMedium, color = colors.muted, - modifier = Modifier.testTag("detail-copies")) + if (OfflinePolicy.showCurrentAvailability(offline)) { + AvailabilityChip(availStatus, availLabel, Modifier.testTag("detail-availability"), + contentPadding = PaddingValues(vertical = 4.dp)) + if (!book.digitalOnly) Text(availableLabel, style = MaterialTheme.typography.bodyMedium, color = colors.muted, + modifier = Modifier.testTag("detail-copies")) + } else { + // Offline: a neutral, dated statement instead of a status pill. + Text( + if (offlineSyncedAt.isNullOrBlank()) stringResource(R.string.offline_availability_unknown) + else stringResource(R.string.offline_availability_last_known, DateFormat.dateTime(offlineSyncedAt), book.copiesAvailable, book.copiesTotal), + style = MaterialTheme.typography.bodyMedium, color = colors.muted, + modifier = Modifier.testTag("detail-offline-availability"), + ) + } Spacer(Modifier.height(16.dp)) // Personal-status banner: makes the user's own relationship to this book explicit, @@ -544,7 +570,7 @@ internal fun DetailContent( book.ean?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_ean), it) } book.condition?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_condition), it) } book.locationLabel?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_shelf), it) } - MetadataRow(stringResource(R.string.book_meta_copies), stringResource(R.string.book_copies_value, book.copiesAvailable, book.copiesTotal)) + if (!offline) MetadataRow(stringResource(R.string.book_meta_copies), stringResource(R.string.book_copies_value, book.copiesAvailable, book.copiesTotal)) } } 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 b7108e3..b82fc53 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 @@ -39,6 +39,10 @@ data class BookDetailUiState( val availabilityFallback: Boolean = false, // When true the loan-request sheet is open. val showLoanSheet: Boolean = false, + /** The page comes from the offline cache (server unreachable): actions are hidden. */ + val offline: Boolean = false, + /** When the offline copy was last synced (server time), for "last known" wording. */ + val syncedAt: String? = null, ) data class RequestConfirmation( @@ -70,17 +74,23 @@ class BookDetailViewModel @Inject constructor( it.copy( content = UiState.Success(res.data), wishlisted = res.data.personalHistory?.hasWishlisted ?: it.wishlisted, + offline = false, ) } - is ApiResult.Failure -> _state.update { - it.copy(content = UiState.Error(res.message, res.code, R.string.book_error_load)) + is ApiResult.Failure -> { + val cached = if (res.code == ErrorCodes.NETWORK) catalog.bookOffline(bookId) else null + val syncedAt = if (cached != null) catalog.lastSyncedAt() else null + _state.update { + if (cached != null) it.copy(content = UiState.Success(cached), offline = true, syncedAt = syncedAt) + else it.copy(content = UiState.Error(res.message, res.code, R.string.book_error_load)) + } } } } } fun toggleWishlist() { - if (_state.value.wishlistBusy) return + if (_state.value.wishlistBusy || _state.value.offline) return val nowWishlisted = !_state.value.wishlisted _state.update { it.copy(wishlistBusy = true) } viewModelScope.launch { @@ -108,6 +118,7 @@ class BookDetailViewModel @Inject constructor( * picker and flags [BookDetailUiState.availabilityFallback]. */ fun openLoanSheet() { + if (_state.value.offline) return _state.update { it.copy(showLoanSheet = true, availabilityLoading = true, availabilityFallback = false, availability = null) } viewModelScope.launch { when (val res = catalog.bookAvailability(bookId)) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt index 5cf1d62..9a19fae 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt @@ -155,14 +155,15 @@ internal fun HomeContent( item { CollectionDestinations(onOpenPeriodicals, onOpenDesiderata, onOpenArchives, onOpenArticles) } item { Box(Modifier.padding(horizontal = Spacing.lg)) { - SectionHeader(showSeeAll = true, catalogueMode = catalogueMode, onSeeAll = onBrowseCatalog, sort = state.sort, onSort = onSort) + // A cached shelf is labelled "recently added": its availability may be stale. + SectionHeader(showSeeAll = true, catalogueMode = catalogueMode || state.fromCache, onSeeAll = onBrowseCatalog, sort = state.sort, onSort = onSort) } } items(state.available.chunked(2), key = { row -> row.joinToString("-") { it.id.toString() } }) { row -> Row(Modifier.padding(horizontal = Spacing.lg, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) { row.forEach { book -> BookCardGrid(title = book.title, author = book.authorsLabel, coverUrl = book.coverUrl, - status = book.availabilityStatus(), subtitle = book.subtitle, publisher = book.publisher, + status = if (state.fromCache) null else book.availabilityStatus(), subtitle = book.subtitle, publisher = book.publisher, year = book.year?.toString(), mediaType = book.mediaType, onClick = { onBookClick(book.id) }, modifier = Modifier.weight(1f)) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt index dbed6a7..9c64d09 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt @@ -31,6 +31,8 @@ data class HomeUiState( val loading: Boolean = true, val error: String? = null, val sort: BookSort = BookSort.NEWEST, + /** The shelf comes from the offline cache: no availability pills, no "available now" claim. */ + val fromCache: Boolean = false, ) { val isEmpty: Boolean get() = !loading && error == null && available.isEmpty() } @@ -83,10 +85,9 @@ class HomeViewModel @Inject constructor( /** * Offline fallback: render the home shelf from the locally-cached catalog snapshot - * immediately. Loan mode keeps the "Available now" shelf filtered to currently-loanable - * copies; catalogue-only mode labels the shelf "Recently added", so the cache fallback - * must stay unfiltered. Only a partial answer — the cache holds the first unfiltered page — - * so it is superseded as soon as [refresh] gets the server-side query through. + * immediately: the newest cached titles, labelled "Recently added" and without status pills, + * because cached availability may be stale (never presented as current). Superseded as + * soon as [refresh] gets the server-side query through. */ private fun observeCache() { viewModelScope.launch { @@ -96,14 +97,15 @@ class HomeViewModel @Inject constructor( combine( catalog.observeCachedCatalog(), catalogueMode(), - ) { books, catalogueMode -> books to catalogueMode }.collectLatest { (books, catalogueMode) -> + ) { books, catalogueMode -> books to catalogueMode }.collectLatest { (books, _) -> if (hasFreshShelf || _state.value.sort != BookSort.NEWEST) return@collectLatest - val shelfBooks = - if (catalogueMode) books - else books.filter { it.available } + // Cached availability may be stale, so the cached shelf is never filtered on it: + // it shows the newest cached titles, labelled as such, without status pills. + val shelfBooks = books _state.update { it.copy( available = shelfBooks, + fromCache = books.isNotEmpty(), // Keep the loading skeleton until the first [refresh] // resolves when the cache is still empty (cold start): // an empty first emission must not flash the @@ -136,14 +138,14 @@ class HomeViewModel @Inject constructor( if (features.features.value.catalogueMode) SearchFilters() else SearchFilters(availableOnly = true) val shelf = async { catalog.search(shelfFilters, limit = SHELF_LIMIT, sort = sort.apiValue) } - launch { catalog.refreshCatalog() } + launch { catalog.syncCatalog() } val res = shelf.await() if (request != generation) return@launch when (res) { is ApiResult.Success -> { hasFreshShelf = true _state.update { - it.copy(available = res.data.items, loading = false, error = null) + it.copy(available = res.data.items, loading = false, error = null, fromCache = false) } } is ApiResult.Failure -> { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt index 91c5215..11861ea 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt @@ -35,11 +35,13 @@ import com.pinakes.app.data.model.LoanItem import com.pinakes.app.data.model.ReservationItem import com.pinakes.app.ui.common.DateFormat import com.pinakes.app.ui.common.StatusMapping +import com.pinakes.app.ui.common.OfflinePolicy import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.ConfirmDialog import com.pinakes.app.ui.components.EmptyState import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner import com.pinakes.app.ui.components.LoadingState import com.pinakes.app.ui.components.MediaRow import com.pinakes.app.ui.theme.Spacing @@ -85,8 +87,15 @@ fun LibraryScreen(onBookClick: (Int) -> Unit) { when (val content = state.content) { is UiState.Loading -> LoadingState(label = stringResource(R.string.library_loading)) is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) - is UiState.Success -> { + is UiState.Success -> Column(Modifier.fillMaxSize()) { val data = content.data + if (state.offline) { + InlineErrorBanner( + message = stringResource(R.string.offline_lists_cached), + onRetry = vm::refresh, + modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.sm), + ) + } when (tab) { 0 -> ActiveLoanList( loans = data.loans.active + data.loans.pending, @@ -95,6 +104,7 @@ fun LibraryScreen(onBookClick: (Int) -> Unit) { cancelingId = state.cancelingId, onBookClick = onBookClick, onCancel = { confirmCancel = CancelTarget(it, isLoan = true) }, + actionsEnabled = OfflinePolicy.showLoanActions(canBorrow = true, offline = state.offline), ) 1 -> LoanList( loans = data.loans.history, @@ -107,6 +117,7 @@ fun LibraryScreen(onBookClick: (Int) -> Unit) { cancelingId = state.cancelingId, onBookClick = onBookClick, onCancel = { confirmCancel = CancelTarget(it, isLoan = false) }, + actionsEnabled = OfflinePolicy.showLoanActions(canBorrow = true, offline = state.offline), ) } } @@ -145,6 +156,7 @@ private fun ActiveLoanList( cancelingId: Int?, onBookClick: (Int) -> Unit, onCancel: (Int) -> Unit, + actionsEnabled: Boolean = true, ) { if (loans.isEmpty()) { EmptyState(title = emptyTitle, subtitle = emptySubtitle, icon = Icons.AutoMirrored.Outlined.LibraryBooks) @@ -182,7 +194,7 @@ private fun ActiveLoanList( loan = loan, cancelingId = cancelingId, onBookClick = onBookClick, - onCancel = onCancel, + onCancel = if (actionsEnabled) onCancel else null, ) } } @@ -269,6 +281,7 @@ private fun ReservationList( cancelingId: Int?, onBookClick: (Int) -> Unit, onCancel: (Int) -> Unit, + actionsEnabled: Boolean = true, ) { if (reservations.isEmpty()) { EmptyState( @@ -303,7 +316,7 @@ private fun ReservationList( status = status, statusLabel = label, onClick = { onBookClick(r.bookId) }, - trailing = if (StatusMapping.isReservationCancellable(r.status)) { + trailing = if (actionsEnabled && StatusMapping.isReservationCancellable(r.status)) { { TextButton( onClick = { onCancel(r.id) }, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt index c6e590b..c31cc06 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt @@ -30,6 +30,8 @@ data class LibraryUiState( val cancelingId: Int? = null, val snackbar: String? = null, val snackbarRes: Int? = null, + /** Showing the cached lists because the server is unreachable: actions are disabled. */ + val offline: Boolean = false, ) @HiltViewModel @@ -61,10 +63,25 @@ class LibraryViewModel @Inject constructor(private val library: LibraryRepositor it.copy( content = UiState.Success(LibraryData(loans.data, resv)), refreshing = false, + offline = false, ) } } else { val f = loans as ApiResult.Failure + // No connection and nothing on screen yet: show the last lists the server sent. + val cached = if (f.code == ErrorCodes.NETWORK && _state.value.content !is UiState.Success) { + library.loansCached() + } else null + if (cached != null) { + _state.update { + it.copy( + content = UiState.Success(LibraryData(cached, library.reservationsCached())), + refreshing = false, + offline = true, + ) + } + return@launch + } _state.update { it.copy( content = if (it.content is UiState.Success) it.content @@ -78,7 +95,7 @@ class LibraryViewModel @Inject constructor(private val library: LibraryRepositor } fun cancelReservation(id: Int) { - if (_state.value.cancelingId != null) return + if (_state.value.cancelingId != null || _state.value.offline) return _state.update { it.copy(cancelingId = id) } viewModelScope.launch { when (val res = library.cancelReservation(id)) { @@ -101,7 +118,7 @@ class LibraryViewModel @Inject constructor(private val library: LibraryRepositor } fun cancelLoan(id: Int) { - if (_state.value.cancelingId != null) return + if (_state.value.cancelingId != null || _state.value.offline) return _state.update { it.copy(cancelingId = id) } viewModelScope.launch { when (val res = library.cancelLoan(id)) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt index 82a78a0..203c766 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.OfflinePolicy import com.pinakes.app.ui.components.BookCover import com.pinakes.app.ui.screens.periodicals.StandaloneArticleRow import com.pinakes.app.R @@ -61,6 +62,7 @@ import com.pinakes.app.ui.components.BookCard import com.pinakes.app.ui.components.BookCardSkeleton import com.pinakes.app.ui.components.EmptyState import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner import com.pinakes.app.ui.components.SearchField import com.pinakes.app.ui.theme.Spacing @@ -165,6 +167,14 @@ fun SearchScreen(onBookClick: (Int) -> Unit, initialQuery: String? = null, onQue modifier = Modifier.padding(vertical = Spacing.sm)) } + if (state.offline) { + InlineErrorBanner( + message = stringResource(R.string.offline_results), + onRetry = vm::retry, + modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.xs), + ) + } + Box(Modifier.fillMaxSize()) { // Crossfade between the high-level UI states for a smooth load→loaded swap. val phase = when { @@ -274,7 +284,7 @@ fun SearchScreen(onBookClick: (Int) -> Unit, initialQuery: String? = null, onQue Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { row.forEach { book -> BookCardGrid(title = book.title, author = book.authorsLabel, coverUrl = book.coverUrl, - status = book.availabilityStatus(), subtitle = book.subtitle, publisher = book.publisher, + status = if (OfflinePolicy.showCurrentAvailability(state.offline)) book.availabilityStatus() else null, subtitle = book.subtitle, publisher = book.publisher, year = book.year?.toString(), mediaType = book.mediaType, onClick = { onBookClick(book.id) }, modifier = Modifier.weight(1f)) } @@ -284,7 +294,7 @@ fun SearchScreen(onBookClick: (Int) -> Unit, initialQuery: String? = null, onQue } else { items(state.items, key = { it.id }) { book -> BookCard(title = book.title, author = book.authorsLabel, coverUrl = book.coverUrl, - status = book.availabilityStatus(), year = book.year?.toString(), publisher = book.publisher, + status = if (OfflinePolicy.showCurrentAvailability(state.offline)) book.availabilityStatus() else null, year = book.year?.toString(), publisher = book.publisher, subtitle = book.subtitle, mediaType = book.mediaType, onClick = { onBookClick(book.id) }, modifier = Modifier.animateItem()) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt index c667a78..0a8ada0 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt @@ -8,6 +8,7 @@ import com.pinakes.app.data.model.BookSummary import com.pinakes.app.data.model.CatalogLanguage import com.pinakes.app.data.model.GenreNode import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.repository.SearchFilters import dagger.hilt.android.lifecycle.HiltViewModel @@ -57,8 +58,13 @@ data class SearchUiState( val filtersOpen: Boolean = false, val appliedFilters: SearchFilters = SearchFilters(), val searchRevision: Int = 0, + /** Results come from the offline catalogue because the server is unreachable. */ + val offline: Boolean = false, + /** Offline pagination: rows already shown and whether another page exists. */ + val offlineOffset: Int = 0, + val offlineHasMore: Boolean = false, ) { - val hasMore: Boolean get() = nextCursor != null + val hasMore: Boolean get() = nextCursor != null || (offline && offlineHasMore) /** How many facet filters (excluding the free-text query) are currently active. */ val activeFilterCount: Int @@ -93,6 +99,10 @@ internal fun SearchUiState.withQuery(value: String): SearchUiState { @HiltViewModel class SearchViewModel @Inject constructor(private val catalog: CatalogRepository) : ViewModel() { + private companion object { + const val OFFLINE_PAGE = 20 + } + private val _state = MutableStateFlow(SearchUiState()) val state: StateFlow = _state.asStateFlow() @@ -216,6 +226,21 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository if (s.loadingMore || s.loading || !s.hasMore) return _state.update { it.copy(loadingMore = true) } val generation = searchGeneration + if (s.offline) { + viewModelScope.launch { + val page = offlinePage(s.appliedFilters, s.sort, s.offlineOffset) + if (generation != searchGeneration) return@launch + _state.update { + it.copy( + items = it.items + page, + offlineOffset = it.offlineOffset + page.size, + offlineHasMore = page.size == OFFLINE_PAGE, + loadingMore = false, + ) + } + } + return + } viewModelScope.launch { val res = catalog.search(s.appliedFilters, cursor = s.nextCursor, sort = s.sort.apiValue) // A sort/filter reset superseded this page mid-flight: drop it so we neither append @@ -235,6 +260,25 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository } } + /** One page of the offline catalogue for [filters]; sort is limited to title / newest. */ + private suspend fun offlinePage(filters: SearchFilters, sort: BookSort, offset: Int): List { + val genreName = filters.genreId?.let { id -> findGenreName(_state.value.genres, id) } + val offlineSort = when (sort) { + BookSort.TITLE_DESC -> "title_desc" + BookSort.NEWEST -> "newest" + else -> "title_asc" + } + return catalog.searchOffline(filters, genreName, offlineSort, offset, OFFLINE_PAGE) + } + + private fun findGenreName(nodes: List, id: Int): String? { + for (n in nodes) { + if (n.id == id) return n.name + findGenreName(n.children, id)?.let { return it } + } + return null + } + private fun runSearch(reset: Boolean) { if (reset) searchGeneration++ val generation = searchGeneration @@ -264,11 +308,32 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository totalCount = res.data.totalCount, loading = false, error = null, + offline = false, + offlineOffset = 0, + offlineHasMore = false, ) } - is ApiResult.Failure -> _state.update { - // null when the server message is blank → the screen shows the localized fallback. - it.copy(loading = false, error = res.message.takeIf { m -> m.isNotBlank() }) + is ApiResult.Failure -> if (res.code == ErrorCodes.NETWORK) { + // No connection: answer from the offline catalogue instead of an error. + val page = offlinePage(searchFilters, _state.value.sort, 0) + if (generation != searchGeneration) return@launch + _state.update { + it.copy( + items = page, + nextCursor = null, + totalCount = null, + loading = false, + error = null, + offline = true, + offlineOffset = page.size, + offlineHasMore = page.size == OFFLINE_PAGE, + ) + } + } else { + _state.update { + // null when the server message is blank → the screen shows the localized fallback. + it.copy(loading = false, error = res.message.takeIf { m -> m.isNotBlank() }, offline = false) + } } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt index db68f8b..34d3974 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt @@ -26,11 +26,13 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pinakes.app.R +import com.pinakes.app.ui.common.OfflinePolicy import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.AvailabilityStatus import com.pinakes.app.ui.components.EmptyState import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner import com.pinakes.app.ui.components.LoadingState import com.pinakes.app.ui.components.MediaRow import com.pinakes.app.ui.theme.Spacing @@ -69,24 +71,37 @@ fun WishlistScreen(onBookClick: (Int) -> Unit) { contentPadding = PaddingValues(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.md), ) { + if (state.offline) { + item(key = "offline") { + InlineErrorBanner(message = stringResource(R.string.offline_lists_cached), onRetry = vm::refresh) + } + } items(content.data, key = { it.bookId }) { item -> MediaRow( modifier = Modifier.animateItem(), title = item.title, coverUrl = item.coverUrl, line1 = item.author, - status = if (item.available) AvailabilityStatus.Available else AvailabilityStatus.Unavailable, + // Cached availability is never shown as current. + status = when { + !OfflinePolicy.showCurrentAvailability(state.offline) -> null + item.available -> AvailabilityStatus.Available + else -> AvailabilityStatus.Unavailable + }, onClick = { onBookClick(item.bookId) }, - trailing = { - IconButton( - onClick = { vm.remove(item.bookId) }, - enabled = state.removingId != item.bookId, - ) { - Icon( - Icons.Outlined.DeleteOutline, - contentDescription = stringResource(R.string.cd_remove), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Removing needs the server: hidden while showing the offline copy. + trailing = if (state.offline) null else { + { + IconButton( + onClick = { vm.remove(item.bookId) }, + enabled = state.removingId != item.bookId, + ) { + Icon( + Icons.Outlined.DeleteOutline, + contentDescription = stringResource(R.string.cd_remove), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } }, ) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt index c6d1a68..33af34a 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.WishlistItem import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.WishlistRepository import com.pinakes.app.R import com.pinakes.app.ui.common.UiState @@ -21,6 +22,8 @@ data class WishlistUiState( val removingId: Int? = null, val snackbar: String? = null, val snackbarRes: Int? = null, + /** Showing the cached list because the server is unreachable: removing is disabled. */ + val offline: Boolean = false, ) @HiltViewModel @@ -39,21 +42,30 @@ class WishlistViewModel @Inject constructor(private val wishlist: WishlistReposi viewModelScope.launch { when (val res = wishlist.wishlist()) { is ApiResult.Success -> _state.update { - it.copy(content = UiState.Success(res.data), refreshing = false) + it.copy(content = UiState.Success(res.data), refreshing = false, offline = false) } - is ApiResult.Failure -> _state.update { - it.copy( - content = if (it.content is UiState.Success) it.content - else UiState.Error(res.message, res.code, R.string.wishlist_error_load), - refreshing = false, - ) + is ApiResult.Failure -> { + val cached = if (res.code == ErrorCodes.NETWORK && _state.value.content !is UiState.Success) { + wishlist.wishlistCached() + } else null + if (cached != null) { + _state.update { it.copy(content = UiState.Success(cached), refreshing = false, offline = true) } + } else { + _state.update { + it.copy( + content = if (it.content is UiState.Success) it.content + else UiState.Error(res.message, res.code, R.string.wishlist_error_load), + refreshing = false, + ) + } + } } } } } fun remove(bookId: Int) { - if (_state.value.removingId != null) return + if (_state.value.removingId != null || _state.value.offline) return _state.update { it.copy(removingId = bookId) } viewModelScope.launch { when (val res = wishlist.remove(bookId)) { diff --git a/app/src/test/java/com/pinakes/app/staff/OfflinePolicyTest.kt b/app/src/test/java/com/pinakes/app/staff/OfflinePolicyTest.kt new file mode 100644 index 0000000..7a03d8b --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/OfflinePolicyTest.kt @@ -0,0 +1,28 @@ +package com.pinakes.app.staff + +import com.pinakes.app.ui.common.OfflinePolicy +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Offline, availability cannot be guaranteed: loan affordances are hidden, never disabled. */ +class OfflinePolicyTest { + + @Test fun onlineWithLoansShowsLoanActions() { + assertTrue(OfflinePolicy.showLoanActions(canBorrow = true, offline = false)) + } + + @Test fun offlineNeverShowsLoanActions() { + assertFalse(OfflinePolicy.showLoanActions(canBorrow = true, offline = true)) + assertFalse(OfflinePolicy.showLoanActions(canBorrow = false, offline = true)) + } + + @Test fun withoutLoansFeatureNoLoanActions() { + assertFalse(OfflinePolicy.showLoanActions(canBorrow = false, offline = false)) + } + + @Test fun cachedAvailabilityIsNeverPresentedAsCurrent() { + assertTrue(OfflinePolicy.showCurrentAvailability(offline = false)) + assertFalse(OfflinePolicy.showCurrentAvailability(offline = true)) + } +} diff --git a/i18n/de.json b/i18n/de.json index 1ccbd0c..d74f331 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -627,5 +627,10 @@ "credit_illustrator": "Illustrator", "credit_editor": "Herausgeber", "credit_colorist": "Kolorist", - "credit_contributor": "Mitwirkender" + "credit_contributor": "Mitwirkender", + "offline_results": "Sie sind offline: Diese Ergebnisse stammen aus der auf dem Gerät gespeicherten Kopie.", + "offline_lists_cached": "Sie sind offline: Dies ist die zuletzt auf dem Gerät gespeicherte Liste. Aktionen benötigen eine Verbindung.", + "offline_actions_unavailable": "Offline-Kopie. Ausleihen, Vormerkungen, Merkliste und Rezensionen benötigen eine Verbindung.", + "offline_availability_last_known": "Letzter Stand vom %1$s: %2$d von %3$d Exemplaren im Regal. Vor dem Besuch online prüfen.", + "offline_availability_unknown": "Die Verfügbarkeit kann offline nicht geprüft werden." } diff --git a/i18n/en.json b/i18n/en.json index bf8264c..c05e917 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -627,5 +627,10 @@ "credit_illustrator": "Illustrator", "credit_editor": "Editor", "credit_colorist": "Colorist", - "credit_contributor": "Contributor" + "credit_contributor": "Contributor", + "offline_results": "You are offline: these results come from the copy saved on this device.", + "offline_lists_cached": "You are offline: this is the last list saved on this device. Actions need a connection.", + "offline_actions_unavailable": "Offline copy. Loans, reservations, wishlist and reviews need a connection.", + "offline_availability_last_known": "Last known on %1$s: %2$d of %3$d copies on the shelf. Check online before you come.", + "offline_availability_unknown": "Availability cannot be checked offline." } diff --git a/i18n/fr.json b/i18n/fr.json index ab41aed..6600e08 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -627,5 +627,10 @@ "credit_illustrator": "Illustrateur", "credit_editor": "Éditeur scientifique", "credit_colorist": "Coloriste", - "credit_contributor": "Contributeur" + "credit_contributor": "Contributeur", + "offline_results": "Vous êtes hors ligne : ces résultats proviennent de la copie enregistrée sur l’appareil.", + "offline_lists_cached": "Vous êtes hors ligne : voici la dernière liste enregistrée sur l’appareil. Les actions nécessitent une connexion.", + "offline_actions_unavailable": "Copie hors ligne. Les prêts, réservations, favoris et avis nécessitent une connexion.", + "offline_availability_last_known": "Dernière information du %1$s : %2$d exemplaires sur %3$d en rayon. Vérifiez en ligne avant de venir.", + "offline_availability_unknown": "La disponibilité ne peut pas être vérifiée hors ligne." } diff --git a/i18n/it.json b/i18n/it.json index 484a345..99bb526 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -627,5 +627,10 @@ "credit_illustrator": "Illustratore", "credit_editor": "Curatore", "credit_colorist": "Colorista", - "credit_contributor": "Contributore" + "credit_contributor": "Contributore", + "offline_results": "Sei offline: questi risultati vengono dalla copia salvata sul dispositivo.", + "offline_lists_cached": "Sei offline: questo è l’ultimo elenco salvato sul dispositivo. Le azioni richiedono una connessione.", + "offline_actions_unavailable": "Copia offline. Prestiti, prenotazioni, preferiti e recensioni richiedono una connessione.", + "offline_availability_last_known": "Ultimo dato del %1$s: %2$d copie su %3$d a scaffale. Verifica online prima di venire.", + "offline_availability_unknown": "La disponibilità non si può verificare offline." } From ae65a4e82da81e76a8fa0247d008a7e305d73fed Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 16:59:19 +0200 Subject: [PATCH 04/62] Add the loans calendar subscription and the staff entry to Profile Profile shows "Loans in your calendar" when the server advertises calendar_feed and loans are on. The sheet creates the private ICS link, leads with Copy link (Google Calendar on Android cannot subscribe from the device; the hint explains the website route), opens it as webcal:// with the HTTPS link as fallback, and offers Regenerate and Turn off, both behind a confirmation because existing subscriptions stop updating. Profile also shows "Staff area" for admin and staff users on servers with staff_catalog, and GET /me now refreshes the stored role. --- .../screens/profile/CalendarSubscribeSheet.kt | 166 ++++++++++++++++++ .../app/ui/screens/profile/ProfileScreen.kt | 30 ++++ .../ui/screens/profile/ProfileViewModel.kt | 50 +++++- i18n/de.json | 20 ++- i18n/en.json | 20 ++- i18n/fr.json | 20 ++- i18n/it.json | 20 ++- 7 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/profile/CalendarSubscribeSheet.kt diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/CalendarSubscribeSheet.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/CalendarSubscribeSheet.kt new file mode 100644 index 0000000..4f91b08 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/CalendarSubscribeSheet.kt @@ -0,0 +1,166 @@ +package com.pinakes.app.ui.screens.profile + +import android.content.ActivityNotFoundException +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CalendarMonth +import androidx.compose.material.icons.outlined.ContentCopy +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.pinakes.app.R +import com.pinakes.app.data.calendar.CalendarLinks +import com.pinakes.app.data.model.CalendarStatus +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.common.resolvedMessage +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.bookclub.openWeb +import com.pinakes.app.ui.theme.Spacing + +/** + * Subscribe to "my loans" in a calendar app. Copying the link leads: Google Calendar on + * Android cannot subscribe from the device (only from its website, "From URL"), while + * calendar apps that understand `webcal://` subscribe directly. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun CalendarSubscribeSheet( + calendar: UiState, + busy: Boolean, + onEnable: () -> Unit, + onRegenerate: () -> Unit, + onDisable: () -> Unit, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var confirmRegenerate by remember { mutableStateOf(false) } + var confirmDisable by remember { mutableStateOf(false) } + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.lg) + .navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(R.string.calendar_subscribe_title), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.calendar_subscribe_body), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(Spacing.xs)) + when (calendar) { + is UiState.Loading -> LoadingState(modifier = Modifier.heightIn(max = 160.dp)) + is UiState.Error -> ErrorState(message = calendar.resolvedMessage(), onRetry = onRetry, modifier = Modifier.heightIn(max = 320.dp)) + is UiState.Success -> { + val status = calendar.data + val feedUrl = status.feedUrl?.takeIf { status.enabled && it.isNotBlank() } + if (feedUrl == null) { + Text(stringResource(R.string.calendar_status_disabled), style = MaterialTheme.typography.bodyMedium) + PrimaryButton( + stringResource(R.string.calendar_enable), onClick = onEnable, loading = busy, + leadingIcon = Icons.Outlined.CalendarMonth, modifier = Modifier.fillMaxWidth(), + ) + } else { + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { + SelectionContainer { + Text(feedUrl, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(Spacing.md)) + } + } + (status.rotatedAt ?: status.createdAt)?.takeIf { it.isNotBlank() }?.let { + Text(stringResource(R.string.calendar_created_on, DateFormat.dateTime(it)), style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + PrimaryButton( + stringResource(R.string.calendar_copy_link), leadingIcon = Icons.Outlined.ContentCopy, + onClick = { copyLink(context, feedUrl) }, modifier = Modifier.fillMaxWidth(), + ) + SecondaryButton( + stringResource(R.string.calendar_open_app), leadingIcon = Icons.AutoMirrored.Outlined.OpenInNew, + onClick = { openInCalendarApp(context, feedUrl, status.webcalUrl) }, modifier = Modifier.fillMaxWidth(), + ) + Text(stringResource(R.string.calendar_hint_google), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(Spacing.xs)) + PinakesTextButton(stringResource(R.string.calendar_regenerate), onClick = { confirmRegenerate = true }, enabled = !busy) + PinakesTextButton(stringResource(R.string.calendar_disable), onClick = { confirmDisable = true }, enabled = !busy) + } + } + } + Spacer(Modifier.height(Spacing.lg)) + } + } + + if (confirmRegenerate) { + ConfirmDialog( + title = stringResource(R.string.calendar_regenerate), + body = stringResource(R.string.calendar_regenerate_confirm), + confirmLabel = stringResource(R.string.calendar_regenerate), + onConfirm = { confirmRegenerate = false; onRegenerate() }, + onDismiss = { confirmRegenerate = false }, + ) + } + if (confirmDisable) { + ConfirmDialog( + title = stringResource(R.string.calendar_disable), + body = stringResource(R.string.calendar_disable_confirm), + confirmLabel = stringResource(R.string.calendar_disable), + onConfirm = { confirmDisable = false; onDisable() }, + onDismiss = { confirmDisable = false }, + ) + } +} + +private fun copyLink(context: Context, url: String) { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager + clipboard?.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.calendar_subscribe_title), url)) + Toast.makeText(context, context.getString(R.string.calendar_link_copied), Toast.LENGTH_SHORT).show() +} + +/** `webcal://` first; without a calendar app that handles it, open the HTTPS link instead. */ +private fun openInCalendarApp(context: Context, feedUrl: String, webcalUrl: String?) { + val webcal = CalendarLinks.subscribeUrl(feedUrl, webcalUrl) ?: return + try { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(webcal)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } catch (_: ActivityNotFoundException) { + openWeb(context, feedUrl) + } catch (_: SecurityException) { + openWeb(context, feedUrl) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt index 79c6ddc..c397dd4 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.outlined.Edit import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Groups import androidx.compose.material.icons.outlined.Language +import androidx.compose.material.icons.outlined.LibraryAdd import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.AutoStories import androidx.compose.material.icons.outlined.Newspaper @@ -97,8 +98,10 @@ fun ProfileScreen( onOpenArticles: () -> Unit, onOpenDesiderata: () -> Unit, onOpenArchives: () -> Unit, + onOpenStaff: () -> Unit = {}, ) { val app: AppViewModel = hiltViewModel() + val isStaff by app.isStaff.collectAsStateWithLifecycle() val vm: ProfileViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() val features by app.features.collectAsStateWithLifecycle() @@ -130,6 +133,11 @@ fun ProfileScreen( onOpenArticles = onOpenArticles, onOpenDesiderata = onOpenDesiderata, onOpenArchives = onOpenArchives, + onOpenStaff = onOpenStaff, + onOpenCalendar = vm::openCalendar, + showStaff = isStaff, + // The feed lists loans, so it needs the loans feature (off in catalogue mode). + showCalendar = features.calendarFeedAvailable && features.loans, showDesiderata = features.desiderataAvailable, showArchives = features.archivesAvailable, showNotifications = features.notifications, @@ -171,6 +179,18 @@ fun ProfileScreen( ) } + if (state.calendarOpen) { + CalendarSubscribeSheet( + calendar = state.calendar, + busy = state.calendarBusy, + onEnable = vm::rotateCalendar, + onRegenerate = vm::rotateCalendar, + onDisable = vm::disableCalendar, + onRetry = vm::openCalendar, + onDismiss = vm::closeCalendar, + ) + } + if (state.changingPassword) { ChangePasswordDialog( current = state.pwCurrent, @@ -204,6 +224,10 @@ private fun ProfileContent( onOpenArticles: () -> Unit, onOpenDesiderata: () -> Unit, onOpenArchives: () -> Unit, + onOpenStaff: () -> Unit, + onOpenCalendar: () -> Unit, + showStaff: Boolean, + showCalendar: Boolean, showNotifications: Boolean, showContact: Boolean, showReviews: Boolean, @@ -278,6 +302,12 @@ private fun ProfileContent( if (showReviews) { ActionRow(Icons.Outlined.RateReview, stringResource(R.string.profile_action_my_reviews), onClick = onOpenMyReviews) } + if (showCalendar) { + ActionRow(Icons.Outlined.CalendarMonth, stringResource(R.string.profile_action_calendar), onClick = onOpenCalendar) + } + if (showStaff) { + ActionRow(Icons.Outlined.LibraryAdd, stringResource(R.string.profile_action_staff), onClick = onOpenStaff) + } if (showBookClub) { ActionRow(Icons.Outlined.Groups, stringResource(R.string.profile_action_book_club), onClick = onOpenBookClub) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt index b60c125..9a86b59 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt @@ -3,6 +3,8 @@ package com.pinakes.app.ui.screens.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.BuiltinFieldRule +import com.pinakes.app.data.model.CalendarStatus +import com.pinakes.app.data.repository.CalendarRepository import com.pinakes.app.data.model.DeviceItem import com.pinakes.app.data.model.UpdateProfileRequest import com.pinakes.app.data.model.UserProfile @@ -52,6 +54,10 @@ data class ProfileUiState( val loggingOut: Boolean = false, val snackbar: String? = null, val snackbarRes: Int? = null, + // Personal loans calendar feed sheet (Mobile API 1.6.0). + val calendarOpen: Boolean = false, + val calendar: UiState = UiState.Loading, + val calendarBusy: Boolean = false, ) { /** Profile-side required-ness defaults to false until discovery succeeds. */ fun builtinRequired(key: String): Boolean = builtinFields[key]?.required ?: false @@ -77,6 +83,7 @@ internal fun isCanonicalProfileGender(value: String): Boolean = value in PROFILE class ProfileViewModel @Inject constructor( private val profile: ProfileRepository, private val auth: AuthRepository, + private val calendarRepo: CalendarRepository, ) : ViewModel() { private val _state = MutableStateFlow(ProfileUiState()) @@ -108,7 +115,11 @@ class ProfileViewModel @Inject constructor( _state.update { it.copy(profile = UiState.Loading) } viewModelScope.launch { when (val res = profile.me()) { - is ApiResult.Success -> _state.update { it.copy(profile = UiState.Success(res.data)) } + is ApiResult.Success -> { + // GET /me is authoritative for the role: keep the staff area in step. + auth.rememberRole(res.data.tipoUtente) + _state.update { it.copy(profile = UiState.Success(res.data)) } + } is ApiResult.Failure -> _state.update { it.copy(profile = UiState.Error(res.message, res.code, R.string.profile_error_load)) } @@ -260,4 +271,41 @@ class ProfileViewModel @Inject constructor( } fun consumeSnackbar() = _state.update { it.copy(snackbar = null, snackbarRes = null) } + + // ---- Calendar subscription ---- + + fun openCalendar() { + _state.update { it.copy(calendarOpen = true, calendar = UiState.Loading) } + viewModelScope.launch { + val res = calendarRepo.status() + _state.update { it.copy(calendar = res.toCalendarState()) } + } + } + + fun closeCalendar() = _state.update { it.copy(calendarOpen = false) } + + /** Create the feed, or rotate it: the old URL stops working immediately. */ + fun rotateCalendar() = calendarAction(R.string.calendar_link_ready) { calendarRepo.rotate() } + + fun disableCalendar() = calendarAction(R.string.calendar_disabled) { calendarRepo.revoke() } + + private fun calendarAction(successRes: Int, call: suspend () -> ApiResult) { + if (_state.value.calendarBusy) return + _state.update { it.copy(calendarBusy = true) } + viewModelScope.launch { + when (val res = call()) { + is ApiResult.Success -> _state.update { + it.copy(calendarBusy = false, calendar = UiState.Success(res.data), snackbarRes = successRes, snackbar = null) + } + is ApiResult.Failure -> _state.update { + it.copy(calendarBusy = false, snackbar = res.message.ifBlank { null }, snackbarRes = if (res.message.isBlank()) R.string.calendar_error else null) + } + } + } + } + + private fun ApiResult.toCalendarState(): UiState = when (this) { + is ApiResult.Success -> UiState.Success(data) + is ApiResult.Failure -> UiState.Error(message, code, R.string.calendar_error) + } } diff --git a/i18n/de.json b/i18n/de.json index d74f331..096d381 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -632,5 +632,23 @@ "offline_lists_cached": "Sie sind offline: Dies ist die zuletzt auf dem Gerät gespeicherte Liste. Aktionen benötigen eine Verbindung.", "offline_actions_unavailable": "Offline-Kopie. Ausleihen, Vormerkungen, Merkliste und Rezensionen benötigen eine Verbindung.", "offline_availability_last_known": "Letzter Stand vom %1$s: %2$d von %3$d Exemplaren im Regal. Vor dem Besuch online prüfen.", - "offline_availability_unknown": "Die Verfügbarkeit kann offline nicht geprüft werden." + "offline_availability_unknown": "Die Verfügbarkeit kann offline nicht geprüft werden.", + "profile_action_staff": "Mitarbeiterbereich", + "profile_action_calendar": "Ausleihen im Kalender", + "calendar_subscribe_title": "Ausleihen im Kalender", + "calendar_subscribe_body": "Ein privater Kalenderlink mit Rückgabefristen, Abholungen und Vormerkungen. Kalender-Apps aktualisieren ihn selbst.", + "calendar_status_disabled": "Der Kalenderlink ist ausgeschaltet.", + "calendar_enable": "Kalenderlink erstellen", + "calendar_created_on": "Erstellt am %1$s", + "calendar_copy_link": "Link kopieren", + "calendar_link_copied": "Link kopiert", + "calendar_open_app": "In einer Kalender-App öffnen", + "calendar_hint_google": "Google Kalender: calendar.google.com am Computer öffnen, Weitere Kalender, Per URL wählen und den Link einfügen.", + "calendar_regenerate": "Link neu erzeugen", + "calendar_regenerate_confirm": "Der aktuelle Link funktioniert sofort nicht mehr: Abonnierte Kalender werden erst wieder aktualisiert, wenn der neue Link hinzugefügt wird.", + "calendar_disable": "Link ausschalten", + "calendar_disable_confirm": "Kalender, die diesen Link abonniert haben, erhalten keine Ausleihen mehr.", + "calendar_disabled": "Kalenderlink ausgeschaltet", + "calendar_link_ready": "Neuer Kalenderlink bereit", + "calendar_error": "Der Kalenderlink konnte nicht geladen werden." } diff --git a/i18n/en.json b/i18n/en.json index c05e917..082cd2a 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -632,5 +632,23 @@ "offline_lists_cached": "You are offline: this is the last list saved on this device. Actions need a connection.", "offline_actions_unavailable": "Offline copy. Loans, reservations, wishlist and reviews need a connection.", "offline_availability_last_known": "Last known on %1$s: %2$d of %3$d copies on the shelf. Check online before you come.", - "offline_availability_unknown": "Availability cannot be checked offline." + "offline_availability_unknown": "Availability cannot be checked offline.", + "profile_action_staff": "Staff area", + "profile_action_calendar": "Loans in your calendar", + "calendar_subscribe_title": "Loans in your calendar", + "calendar_subscribe_body": "A private calendar link with your loan due dates, pickups and reservations. Calendar apps refresh it on their own.", + "calendar_status_disabled": "The calendar link is off.", + "calendar_enable": "Create calendar link", + "calendar_created_on": "Created on %1$s", + "calendar_copy_link": "Copy link", + "calendar_link_copied": "Link copied", + "calendar_open_app": "Open in a calendar app", + "calendar_hint_google": "Google Calendar: open calendar.google.com on a computer, choose Other calendars, From URL, and paste the link.", + "calendar_regenerate": "Regenerate link", + "calendar_regenerate_confirm": "The current link stops working at once: calendars subscribed to it stop updating until you add the new one.", + "calendar_disable": "Turn off link", + "calendar_disable_confirm": "Calendars subscribed to this link stop receiving your loans.", + "calendar_disabled": "Calendar link turned off", + "calendar_link_ready": "New calendar link ready", + "calendar_error": "Could not load the calendar link." } diff --git a/i18n/fr.json b/i18n/fr.json index 6600e08..4c21c10 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -632,5 +632,23 @@ "offline_lists_cached": "Vous êtes hors ligne : voici la dernière liste enregistrée sur l’appareil. Les actions nécessitent une connexion.", "offline_actions_unavailable": "Copie hors ligne. Les prêts, réservations, favoris et avis nécessitent une connexion.", "offline_availability_last_known": "Dernière information du %1$s : %2$d exemplaires sur %3$d en rayon. Vérifiez en ligne avant de venir.", - "offline_availability_unknown": "La disponibilité ne peut pas être vérifiée hors ligne." + "offline_availability_unknown": "La disponibilité ne peut pas être vérifiée hors ligne.", + "profile_action_staff": "Espace personnel", + "profile_action_calendar": "Prêts dans votre agenda", + "calendar_subscribe_title": "Prêts dans votre agenda", + "calendar_subscribe_body": "Un lien privé avec les échéances de vos prêts, les retraits et les réservations. Les applications d’agenda le mettent à jour d’elles-mêmes.", + "calendar_status_disabled": "Le lien d’agenda est désactivé.", + "calendar_enable": "Créer le lien d’agenda", + "calendar_created_on": "Créé le %1$s", + "calendar_copy_link": "Copier le lien", + "calendar_link_copied": "Lien copié", + "calendar_open_app": "Ouvrir dans une application d’agenda", + "calendar_hint_google": "Google Agenda : ouvrez calendar.google.com sur un ordinateur, choisissez Autres agendas, À partir de l’URL, et collez le lien.", + "calendar_regenerate": "Régénérer le lien", + "calendar_regenerate_confirm": "Le lien actuel cesse immédiatement de fonctionner : les agendas abonnés ne se mettent plus à jour tant que vous n’ajoutez pas le nouveau.", + "calendar_disable": "Désactiver le lien", + "calendar_disable_confirm": "Les agendas abonnés à ce lien ne recevront plus vos prêts.", + "calendar_disabled": "Lien d’agenda désactivé", + "calendar_link_ready": "Nouveau lien d’agenda prêt", + "calendar_error": "Impossible de charger le lien d’agenda." } diff --git a/i18n/it.json b/i18n/it.json index 99bb526..66bad97 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -632,5 +632,23 @@ "offline_lists_cached": "Sei offline: questo è l’ultimo elenco salvato sul dispositivo. Le azioni richiedono una connessione.", "offline_actions_unavailable": "Copia offline. Prestiti, prenotazioni, preferiti e recensioni richiedono una connessione.", "offline_availability_last_known": "Ultimo dato del %1$s: %2$d copie su %3$d a scaffale. Verifica online prima di venire.", - "offline_availability_unknown": "La disponibilità non si può verificare offline." + "offline_availability_unknown": "La disponibilità non si può verificare offline.", + "profile_action_staff": "Area staff", + "profile_action_calendar": "Prestiti nel calendario", + "calendar_subscribe_title": "Prestiti nel calendario", + "calendar_subscribe_body": "Un link privato con le scadenze dei prestiti, i ritiri e le prenotazioni. Le app di calendario lo aggiornano da sole.", + "calendar_status_disabled": "Il link del calendario è disattivato.", + "calendar_enable": "Crea link calendario", + "calendar_created_on": "Creato il %1$s", + "calendar_copy_link": "Copia link", + "calendar_link_copied": "Link copiato", + "calendar_open_app": "Apri in un’app di calendario", + "calendar_hint_google": "Google Calendar: apri calendar.google.com da computer, scegli Altri calendari, Da URL e incolla il link.", + "calendar_regenerate": "Rigenera link", + "calendar_regenerate_confirm": "Il link attuale smette subito di funzionare: i calendari iscritti non si aggiornano più finché non aggiungi quello nuovo.", + "calendar_disable": "Disattiva link", + "calendar_disable_confirm": "I calendari iscritti a questo link smettono di ricevere i tuoi prestiti.", + "calendar_disabled": "Link calendario disattivato", + "calendar_link_ready": "Nuovo link calendario pronto", + "calendar_error": "Impossibile caricare il link del calendario." } From e8cb1e73665ea5707b22d76bfd89bef869d7ff65 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 16:59:50 +0200 Subject: [PATCH 05/62] Add the staff area: catalogue management and the ISBN scanner Staff hub (Profile, or the scan button on the Catalog tab) for admin and staff users on servers with staff_catalog. Books: searchable list of every record with edit and delete (refused with a clear message while loans or reservations are active). One create/edit form serves scanned titles and records without an ISBN: live identifier validation, author and publisher pickers with add-new, genre and subgenre, media type, language/format/acquisition suggestions from the lookups, shelf and level with the next free position, copies (create) or add copies (edit), and a cover from a URL, a photo or a picked image (resized, EXIF-rotated JPEG under 2 MB, uploaded after the save). A 409 duplicate offers Open existing, Add copy or Change identifier. Authors, publishers, genres (tree, find-or-create) and shelves with levels have their own screens. Scanner: CameraX preview with a viewfinder, ZXing EAN-13 decoding on alternate rotated frames, checksum validation and the double-read debouncer. Batch mode remembers copies, shelf, level and an opt-in auto-confirm; preview, duplicate and not-found sheets lead to add, add copies, edit details or create manually. The manual ISBN/EAN field is always visible for emulators and denied permission. CAMERA is requested only here, with a rationale and a settings fallback when it is permanently denied; camera.any is optional so devices without a camera still install the app. Without a connection the scanner says that scanning needs one. --- app/src/main/AndroidManifest.xml | 15 + .../pinakes/app/ui/navigation/MainScaffold.kt | 13 + .../app/ui/navigation/PinakesNavHost.kt | 76 +++ .../com/pinakes/app/ui/navigation/Routes.kt | 14 + .../ui/screens/staff/StaffBookEditScreen.kt | 554 ++++++++++++++++++ .../screens/staff/StaffBookEditViewModel.kt | 437 ++++++++++++++ .../app/ui/screens/staff/StaffBooksScreen.kt | 298 ++++++++++ .../app/ui/screens/staff/StaffHubScreen.kt | 67 +++ .../ui/screens/staff/StaffRecordsScreens.kt | 406 +++++++++++++ .../ui/screens/staff/StaffTaxonomyScreens.kt | 436 ++++++++++++++ .../pinakes/app/ui/screens/staff/StaffUi.kt | 251 ++++++++ .../ui/screens/staff/scan/BarcodeAnalyzer.kt | 91 +++ .../ui/screens/staff/scan/CameraPreview.kt | 112 ++++ .../ui/screens/staff/scan/StaffScanScreen.kt | 459 +++++++++++++++ .../screens/staff/scan/StaffScanViewModel.kt | 269 +++++++++ app/src/main/res/xml/file_paths.xml | 5 + i18n/de.json | 165 +++++- i18n/en.json | 165 +++++- i18n/fr.json | 165 +++++- i18n/it.json | 165 +++++- 20 files changed, 4159 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBooksScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/StaffTaxonomyScreens.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/StaffUi.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/scan/BarcodeAnalyzer.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/scan/CameraPreview.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffScanScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffScanViewModel.kt create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d0e133a..1dbd73b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,10 @@ + + + + + + + + + + + diff --git a/i18n/de.json b/i18n/de.json index 096d381..4fdf1e7 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -650,5 +650,168 @@ "calendar_disable_confirm": "Kalender, die diesen Link abonniert haben, erhalten keine Ausleihen mehr.", "calendar_disabled": "Kalenderlink ausgeschaltet", "calendar_link_ready": "Neuer Kalenderlink bereit", - "calendar_error": "Der Kalenderlink konnte nicht geladen werden." + "calendar_error": "Der Kalenderlink konnte nicht geladen werden.", + "staff_title": "Mitarbeiterbereich", + "staff_section_add": "Zum Katalog hinzufügen", + "staff_section_manage": "Verwalten", + "staff_scan": "ISBN scannen", + "staff_scan_subtitle": "Barcode-Scanner mit Stapelmodus", + "staff_add_manual": "Manuell hinzufügen", + "staff_add_manual_subtitle": "Für Medien ohne ISBN: Noten, Broschüren, Objekte", + "staff_books": "Bücher", + "staff_authors": "Autoren", + "staff_publishers": "Verlage", + "staff_genres": "Genres", + "staff_shelves": "Regale", + "staff_forbidden": "Diese Aktion ist dem Bibliothekspersonal vorbehalten.", + "staff_error_load": "Die Daten konnten nicht geladen werden.", + "staff_error_save": "Speichern fehlgeschlagen. Bitte erneut versuchen.", + "staff_error_busy": "Der Katalog ist gerade mit einer anderen Speicherung beschäftigt. Bitte gleich erneut versuchen.", + "staff_error_offline": "Keine Verbindung. Änderungen am Katalog benötigen den Server.", + "staff_cd_decrease": "Verringern", + "staff_cd_increase": "Erhöhen", + "staff_option_none": "Keine Angabe", + "staff_entity_new": "Neu anlegen: %1$s", + "staff_entity_new_chip": "%1$s (neu)", + "staff_entity_use_existing": "Vorhandenen verwenden", + "staff_picker_empty": "Keine Treffer.", + "staff_books_search": "Titel, Autor oder ISBN suchen", + "staff_books_empty": "Keine Datensätze gefunden", + "staff_copies_count": "%1$d von %2$d Exemplaren verfügbar", + "staff_copies_total": "Exemplare im Katalog: %1$d", + "staff_book_new": "Neuer Datensatz", + "staff_book_edit": "Datensatz bearbeiten", + "staff_book_save": "Speichern", + "staff_book_saved": "Datensatz gespeichert", + "staff_book_delete": "Datensatz löschen", + "staff_book_delete_confirm": "„%1$s“ löschen? Der Datensatz verschwindet aus dem Katalog und seine Kennungen werden wieder frei.", + "staff_book_deleted": "Datensatz gelöscht", + "staff_book_delete_active_circulation": "Dieser Titel hat aktive Ausleihen oder Vormerkungen und kann nicht gelöscht werden.", + "staff_book_duplicate_title": "Bereits im Katalog", + "staff_book_duplicate_body": "Es gibt bereits einen Datensatz mit dieser Kennung:", + "staff_change_identifier": "Kennung ändern", + "staff_form_fix_errors": "Bitte die markierten Felder prüfen.", + "staff_field_required": "Pflichtfeld", + "staff_field_invalid": "Ungültig", + "staff_identifier_invalid": "Keine gültige ISBN oder EAN", + "staff_lookup_failed_manual": "Die Online-Suche hat nicht geantwortet: Datensatz bitte manuell ausfüllen.", + "staff_section_cover": "Cover", + "staff_section_identity": "Titel und Kennungen", + "staff_section_people": "Autoren und Verlage", + "staff_section_classification": "Klassifikation", + "staff_section_content": "Inhalt und Ausgabe", + "staff_section_holdings": "Exemplare und Standort", + "staff_section_acquisition": "Erwerbung und Notizen", + "staff_field_title": "Titel *", + "staff_field_subtitle": "Untertitel", + "staff_field_isbn13": "ISBN-13", + "staff_field_isbn10": "ISBN-10", + "staff_field_ean": "EAN", + "staff_field_issn": "ISSN", + "staff_add_author": "Autor hinzufügen", + "staff_add_publisher": "Verlag hinzufügen", + "staff_field_genre": "Genre", + "staff_field_subgenre": "Untergenre", + "staff_field_media_type": "Medientyp", + "staff_field_language": "Sprache", + "staff_field_format": "Format", + "staff_field_dewey": "Dewey-Klassifikation", + "staff_field_description": "Beschreibung", + "staff_field_keywords": "Schlagwörter", + "staff_field_year": "Jahr", + "staff_field_pages": "Seiten", + "staff_field_pub_date": "Erscheinungsdatum", + "staff_field_edition": "Ausgabe", + "staff_field_place": "Erscheinungsort", + "staff_field_series": "Reihe", + "staff_field_series_number": "Nummer", + "staff_copies": "Exemplare", + "staff_copies_to_add": "Hinzuzufügende Exemplare", + "staff_add_copies": "Exemplare hinzufügen", + "staff_copies_added": "Exemplare hinzugefügt", + "staff_shelf": "Regal", + "staff_level": "Regalboden", + "staff_level_number": "Boden %1$d", + "staff_position_auto": "Die Position wird automatisch vergeben (nächster freier Platz).", + "staff_position_auto_label": "Nächste freie Position: %1$s", + "staff_field_acquisition_date": "Erwerbungsdatum", + "staff_field_acquisition_type": "Erwerbungsart", + "staff_field_price": "Preis", + "staff_field_inventory_base": "Inventarnummer", + "staff_field_notes": "Notizen", + "staff_cover_from_url": "Von URL", + "staff_cover_take_photo": "Foto aufnehmen", + "staff_cover_pick": "Bild auswählen", + "staff_cover_url_label": "Bildadresse", + "staff_cover_pending": "Neues Cover, wird beim Speichern übernommen.", + "staff_cover_discard": "Verwerfen", + "staff_cover_failed": "Gespeichert, aber das Cover konnte nicht hochgeladen werden.", + "staff_camera_unavailable": "Die Kamera ist auf diesem Gerät nicht verfügbar.", + "staff_records_empty": "Noch keine Einträge", + "staff_record_new": "Neu", + "staff_record_edit": "Bearbeiten", + "staff_record_saved": "Gespeichert", + "staff_record_duplicate_title": "Existiert bereits", + "staff_record_duplicate_body": "„%1$s“ ist bereits im Katalog. Stattdessen den vorhandenen Datensatz bearbeiten?", + "staff_field_name": "Name *", + "staff_field_pseudonym": "Pseudonym", + "staff_field_born": "Geboren", + "staff_field_died": "Gestorben", + "staff_field_nationality": "Nationalität", + "staff_field_website": "Website", + "staff_field_gnd": "GND-Kennung", + "staff_field_biography": "Biografie", + "staff_field_address": "Adresse", + "staff_field_phone": "Telefon", + "staff_field_email": "E-Mail", + "staff_genre_add": "Genre hinzufügen", + "staff_subgenre_add_to": "Untergenre zu %1$s hinzufügen", + "staff_genre_exists": "Dieses Genre existiert bereits.", + "staff_shelf_add": "Regal hinzufügen", + "staff_level_add": "Regalboden hinzufügen", + "staff_level_add_to": "Regalboden zu %1$s hinzufügen", + "staff_shelf_exists": "Es gibt bereits ein Regal mit diesem Code.", + "staff_level_exists": "Dieses Regal hat bereits einen Boden mit dieser Nummer.", + "staff_shelf_no_levels": "Noch keine Regalböden", + "staff_field_shelf_code": "Code *", + "staff_field_level_number": "Bodennummer *", + "staff_scan_hint": "Den Barcode in den Rahmen halten. Jeder Code wird zweimal gelesen, bevor er übernommen wird.", + "staff_scan_manual_label": "ISBN oder EAN eingeben", + "staff_scan_lookup": "Suchen", + "staff_scan_looking": "Wird gesucht…", + "staff_scan_permission_rationale": "Die Kamera liest die Barcodes der Bücher. Die ISBN kann auch unten eingegeben werden.", + "staff_scan_permission_allow": "Kamera erlauben", + "staff_scan_permission_denied": "Der Kamerazugriff ist für Pinakes ausgeschaltet. In den Einstellungen aktivieren oder die ISBN unten eingeben.", + "staff_scan_permission_settings": "Einstellungen öffnen", + "staff_scan_torch_on": "Licht einschalten", + "staff_scan_torch_off": "Licht ausschalten", + "staff_scan_settings": "Stapeleinstellungen", + "staff_scan_settings_hint": "Gilt für jeden Titel, der in dieser Sitzung bestätigt wird.", + "staff_scan_batch_title": "Stapelmodus", + "staff_scan_batch_copies": "%1$d Exemplare", + "staff_scan_no_location": "Kein Standort", + "staff_scan_auto_on": "automatische Bestätigung an", + "staff_scan_auto_off": "jeden Titel bestätigen", + "staff_scan_auto_confirm": "Automatisch bestätigen", + "staff_scan_auto_confirm_hint": "Gefundene Titel ohne Vorschau hinzufügen. Bei Duplikaten und unbekannten Codes wird weiterhin nachgefragt.", + "staff_scan_session_count": "%1$d hinzugefügt", + "staff_scan_added": "Zum Katalog hinzugefügt", + "staff_scan_added_list": "In dieser Sitzung hinzugefügt", + "staff_scan_added_new": "Neuer Datensatz · %1$d Exemplare", + "staff_scan_added_copies": "+%1$d Exemplare", + "staff_scan_confirm": "Zum Katalog hinzufügen", + "staff_scan_edit_details": "Zuerst Details bearbeiten", + "staff_scan_skip": "Überspringen", + "staff_scan_duplicate": "Bereits im Katalog", + "staff_scan_open_existing": "Vorhandenen öffnen", + "staff_scan_add_copy": "Exemplare hinzufügen", + "staff_scan_not_found": "Online nicht gefunden", + "staff_scan_not_found_body": "Keine Quelle kennt %1$s. Der Datensatz kann manuell angelegt werden.", + "staff_scan_not_found_ean": "%1$s ist keine Buch-ISBN und keiner Quelle bekannt. Der Datensatz kann manuell angelegt werden.", + "staff_scan_not_found_manual": "Keine Quelle kennt diese Kennung: Datensatz bitte manuell ausfüllen.", + "staff_scan_create_manual": "Manuell anlegen", + "staff_scan_rescan": "Erneut scannen", + "staff_scrape_unavailable": "Die Suchdienste sind gerade nicht erreichbar.", + "staff_scan_offline_title": "Scannen braucht eine Verbindung", + "staff_scan_offline_subtitle": "Jeder Code wird auf dem Bibliotheksserver gesucht. Bitte erneut verbinden und wiederholen." } diff --git a/i18n/en.json b/i18n/en.json index 082cd2a..61e5cf1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -650,5 +650,168 @@ "calendar_disable_confirm": "Calendars subscribed to this link stop receiving your loans.", "calendar_disabled": "Calendar link turned off", "calendar_link_ready": "New calendar link ready", - "calendar_error": "Could not load the calendar link." + "calendar_error": "Could not load the calendar link.", + "staff_title": "Staff area", + "staff_section_add": "Add to the catalogue", + "staff_section_manage": "Manage", + "staff_scan": "Scan ISBN", + "staff_scan_subtitle": "Barcode scanner with batch mode", + "staff_add_manual": "Add manually", + "staff_add_manual_subtitle": "For items without an ISBN: sheet music, pamphlets, objects", + "staff_books": "Books", + "staff_authors": "Authors", + "staff_publishers": "Publishers", + "staff_genres": "Genres", + "staff_shelves": "Shelves", + "staff_forbidden": "This action is reserved for library staff.", + "staff_error_load": "Could not load the data.", + "staff_error_save": "Could not save. Please try again.", + "staff_error_busy": "The catalogue is busy with another save. Try again in a moment.", + "staff_error_offline": "No connection. Changes to the catalogue need the server.", + "staff_cd_decrease": "Decrease", + "staff_cd_increase": "Increase", + "staff_option_none": "None", + "staff_entity_new": "Add new: %1$s", + "staff_entity_new_chip": "%1$s (new)", + "staff_entity_use_existing": "Use existing", + "staff_picker_empty": "No matches.", + "staff_books_search": "Search title, author or ISBN", + "staff_books_empty": "No records found", + "staff_copies_count": "%1$d of %2$d copies available", + "staff_copies_total": "Copies in the catalogue: %1$d", + "staff_book_new": "New record", + "staff_book_edit": "Edit record", + "staff_book_save": "Save", + "staff_book_saved": "Record saved", + "staff_book_delete": "Delete record", + "staff_book_delete_confirm": "Delete “%1$s”? It disappears from the catalogue and its identifiers become free again.", + "staff_book_deleted": "Record deleted", + "staff_book_delete_active_circulation": "This title has active loans or reservations and cannot be deleted.", + "staff_book_duplicate_title": "Already in the catalogue", + "staff_book_duplicate_body": "A record with this identifier already exists:", + "staff_change_identifier": "Change identifier", + "staff_form_fix_errors": "Check the highlighted fields.", + "staff_field_required": "Required", + "staff_field_invalid": "Not valid", + "staff_identifier_invalid": "Not a valid ISBN or EAN", + "staff_lookup_failed_manual": "The online lookup did not answer: fill in the record by hand.", + "staff_section_cover": "Cover", + "staff_section_identity": "Title and identifiers", + "staff_section_people": "Authors and publishers", + "staff_section_classification": "Classification", + "staff_section_content": "Content and edition", + "staff_section_holdings": "Copies and location", + "staff_section_acquisition": "Acquisition and notes", + "staff_field_title": "Title *", + "staff_field_subtitle": "Subtitle", + "staff_field_isbn13": "ISBN-13", + "staff_field_isbn10": "ISBN-10", + "staff_field_ean": "EAN", + "staff_field_issn": "ISSN", + "staff_add_author": "Add author", + "staff_add_publisher": "Add publisher", + "staff_field_genre": "Genre", + "staff_field_subgenre": "Subgenre", + "staff_field_media_type": "Media type", + "staff_field_language": "Language", + "staff_field_format": "Format", + "staff_field_dewey": "Dewey classification", + "staff_field_description": "Description", + "staff_field_keywords": "Keywords", + "staff_field_year": "Year", + "staff_field_pages": "Pages", + "staff_field_pub_date": "Publication date", + "staff_field_edition": "Edition", + "staff_field_place": "Place of publication", + "staff_field_series": "Series", + "staff_field_series_number": "Number", + "staff_copies": "Copies", + "staff_copies_to_add": "Copies to add", + "staff_add_copies": "Add copies", + "staff_copies_added": "Copies added", + "staff_shelf": "Shelf", + "staff_level": "Level", + "staff_level_number": "Level %1$d", + "staff_position_auto": "The position is assigned automatically (next free place).", + "staff_position_auto_label": "Next free position: %1$s", + "staff_field_acquisition_date": "Acquisition date", + "staff_field_acquisition_type": "Acquisition type", + "staff_field_price": "Price", + "staff_field_inventory_base": "Inventory number", + "staff_field_notes": "Notes", + "staff_cover_from_url": "From URL", + "staff_cover_take_photo": "Take photo", + "staff_cover_pick": "Pick image", + "staff_cover_url_label": "Image address", + "staff_cover_pending": "New cover, applied when you save.", + "staff_cover_discard": "Discard", + "staff_cover_failed": "Saved, but the cover could not be uploaded.", + "staff_camera_unavailable": "The camera is not available on this device.", + "staff_records_empty": "Nothing here yet", + "staff_record_new": "New", + "staff_record_edit": "Edit", + "staff_record_saved": "Saved", + "staff_record_duplicate_title": "Already exists", + "staff_record_duplicate_body": "“%1$s” is already in the catalogue. Edit the existing record instead?", + "staff_field_name": "Name *", + "staff_field_pseudonym": "Pseudonym", + "staff_field_born": "Born", + "staff_field_died": "Died", + "staff_field_nationality": "Nationality", + "staff_field_website": "Website", + "staff_field_gnd": "GND identifier", + "staff_field_biography": "Biography", + "staff_field_address": "Address", + "staff_field_phone": "Phone", + "staff_field_email": "Email", + "staff_genre_add": "Add genre", + "staff_subgenre_add_to": "Add a subgenre to %1$s", + "staff_genre_exists": "This genre already exists.", + "staff_shelf_add": "Add shelf", + "staff_level_add": "Add level", + "staff_level_add_to": "Add a level to %1$s", + "staff_shelf_exists": "A shelf with this code already exists.", + "staff_level_exists": "This shelf already has a level with that number.", + "staff_shelf_no_levels": "No levels yet", + "staff_field_shelf_code": "Code *", + "staff_field_level_number": "Level number *", + "staff_scan_hint": "Hold the barcode inside the frame. Each code is read twice before it is accepted.", + "staff_scan_manual_label": "Type an ISBN or EAN", + "staff_scan_lookup": "Look up", + "staff_scan_looking": "Looking it up…", + "staff_scan_permission_rationale": "The camera reads book barcodes. You can also type the ISBN below.", + "staff_scan_permission_allow": "Allow camera", + "staff_scan_permission_denied": "Camera access is off for Pinakes. Turn it on in the settings, or type the ISBN below.", + "staff_scan_permission_settings": "Open settings", + "staff_scan_torch_on": "Turn on the light", + "staff_scan_torch_off": "Turn off the light", + "staff_scan_settings": "Batch settings", + "staff_scan_settings_hint": "Applied to every title you confirm in this session.", + "staff_scan_batch_title": "Batch mode", + "staff_scan_batch_copies": "%1$d copies", + "staff_scan_no_location": "No location", + "staff_scan_auto_on": "auto-confirm on", + "staff_scan_auto_off": "confirm each title", + "staff_scan_auto_confirm": "Auto-confirm", + "staff_scan_auto_confirm_hint": "Add found titles without the preview step. Duplicates and unknown codes still ask.", + "staff_scan_session_count": "%1$d added", + "staff_scan_added": "Added to the catalogue", + "staff_scan_added_list": "Added in this session", + "staff_scan_added_new": "New record · %1$d copies", + "staff_scan_added_copies": "+%1$d copies", + "staff_scan_confirm": "Add to the catalogue", + "staff_scan_edit_details": "Edit details first", + "staff_scan_skip": "Skip", + "staff_scan_duplicate": "Already in the catalogue", + "staff_scan_open_existing": "Open existing", + "staff_scan_add_copy": "Add copies", + "staff_scan_not_found": "Not found online", + "staff_scan_not_found_body": "No source knows %1$s. You can create the record by hand.", + "staff_scan_not_found_ean": "%1$s is not a book ISBN and no source knows it. You can create the record by hand.", + "staff_scan_not_found_manual": "No source knows this identifier: fill in the record by hand.", + "staff_scan_create_manual": "Create manually", + "staff_scan_rescan": "Scan again", + "staff_scrape_unavailable": "The lookup services are not reachable right now.", + "staff_scan_offline_title": "Scanning needs a connection", + "staff_scan_offline_subtitle": "Each code is looked up on the library server. Reconnect and try again." } diff --git a/i18n/fr.json b/i18n/fr.json index 4c21c10..fe6d934 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -650,5 +650,168 @@ "calendar_disable_confirm": "Les agendas abonnés à ce lien ne recevront plus vos prêts.", "calendar_disabled": "Lien d’agenda désactivé", "calendar_link_ready": "Nouveau lien d’agenda prêt", - "calendar_error": "Impossible de charger le lien d’agenda." + "calendar_error": "Impossible de charger le lien d’agenda.", + "staff_title": "Espace personnel", + "staff_section_add": "Ajouter au catalogue", + "staff_section_manage": "Gérer", + "staff_scan": "Scanner un ISBN", + "staff_scan_subtitle": "Lecteur de codes-barres avec mode en série", + "staff_add_manual": "Ajouter manuellement", + "staff_add_manual_subtitle": "Pour les documents sans ISBN : partitions, brochures, objets", + "staff_books": "Livres", + "staff_authors": "Auteurs", + "staff_publishers": "Éditeurs", + "staff_genres": "Genres", + "staff_shelves": "Étagères", + "staff_forbidden": "Cette action est réservée au personnel de la bibliothèque.", + "staff_error_load": "Impossible de charger les données.", + "staff_error_save": "Échec de l’enregistrement. Veuillez réessayer.", + "staff_error_busy": "Le catalogue est occupé par un autre enregistrement. Réessayez dans un instant.", + "staff_error_offline": "Pas de connexion. Les modifications du catalogue nécessitent le serveur.", + "staff_cd_decrease": "Diminuer", + "staff_cd_increase": "Augmenter", + "staff_option_none": "Aucun", + "staff_entity_new": "Ajouter : %1$s", + "staff_entity_new_chip": "%1$s (nouveau)", + "staff_entity_use_existing": "Utiliser l’existant", + "staff_picker_empty": "Aucun résultat.", + "staff_books_search": "Rechercher un titre, un auteur ou un ISBN", + "staff_books_empty": "Aucune notice trouvée", + "staff_copies_count": "%1$d exemplaires disponibles sur %2$d", + "staff_copies_total": "Exemplaires au catalogue : %1$d", + "staff_book_new": "Nouvelle notice", + "staff_book_edit": "Modifier la notice", + "staff_book_save": "Enregistrer", + "staff_book_saved": "Notice enregistrée", + "staff_book_delete": "Supprimer la notice", + "staff_book_delete_confirm": "Supprimer « %1$s » ? La notice disparaît du catalogue et ses identifiants redeviennent libres.", + "staff_book_deleted": "Notice supprimée", + "staff_book_delete_active_circulation": "Ce titre a des prêts ou des réservations en cours et ne peut pas être supprimé.", + "staff_book_duplicate_title": "Déjà au catalogue", + "staff_book_duplicate_body": "Une notice avec cet identifiant existe déjà :", + "staff_change_identifier": "Modifier l’identifiant", + "staff_form_fix_errors": "Vérifiez les champs signalés.", + "staff_field_required": "Obligatoire", + "staff_field_invalid": "Non valide", + "staff_identifier_invalid": "ISBN ou EAN non valide", + "staff_lookup_failed_manual": "La recherche en ligne n’a pas répondu : complétez la notice manuellement.", + "staff_section_cover": "Couverture", + "staff_section_identity": "Titre et identifiants", + "staff_section_people": "Auteurs et éditeurs", + "staff_section_classification": "Classification", + "staff_section_content": "Contenu et édition", + "staff_section_holdings": "Exemplaires et emplacement", + "staff_section_acquisition": "Acquisition et notes", + "staff_field_title": "Titre *", + "staff_field_subtitle": "Sous-titre", + "staff_field_isbn13": "ISBN-13", + "staff_field_isbn10": "ISBN-10", + "staff_field_ean": "EAN", + "staff_field_issn": "ISSN", + "staff_add_author": "Ajouter un auteur", + "staff_add_publisher": "Ajouter un éditeur", + "staff_field_genre": "Genre", + "staff_field_subgenre": "Sous-genre", + "staff_field_media_type": "Type de support", + "staff_field_language": "Langue", + "staff_field_format": "Format", + "staff_field_dewey": "Classification Dewey", + "staff_field_description": "Description", + "staff_field_keywords": "Mots-clés", + "staff_field_year": "Année", + "staff_field_pages": "Pages", + "staff_field_pub_date": "Date de publication", + "staff_field_edition": "Édition", + "staff_field_place": "Lieu de publication", + "staff_field_series": "Collection", + "staff_field_series_number": "Numéro", + "staff_copies": "Exemplaires", + "staff_copies_to_add": "Exemplaires à ajouter", + "staff_add_copies": "Ajouter des exemplaires", + "staff_copies_added": "Exemplaires ajoutés", + "staff_shelf": "Étagère", + "staff_level": "Tablette", + "staff_level_number": "Tablette %1$d", + "staff_position_auto": "La position est attribuée automatiquement (prochaine place libre).", + "staff_position_auto_label": "Prochaine position libre : %1$s", + "staff_field_acquisition_date": "Date d’acquisition", + "staff_field_acquisition_type": "Type d’acquisition", + "staff_field_price": "Prix", + "staff_field_inventory_base": "Numéro d’inventaire", + "staff_field_notes": "Notes", + "staff_cover_from_url": "Depuis une URL", + "staff_cover_take_photo": "Prendre une photo", + "staff_cover_pick": "Choisir une image", + "staff_cover_url_label": "Adresse de l’image", + "staff_cover_pending": "Nouvelle couverture, appliquée à l’enregistrement.", + "staff_cover_discard": "Annuler", + "staff_cover_failed": "Enregistré, mais la couverture n’a pas pu être envoyée.", + "staff_camera_unavailable": "L’appareil photo n’est pas disponible sur cet appareil.", + "staff_records_empty": "Rien pour l’instant", + "staff_record_new": "Nouveau", + "staff_record_edit": "Modifier", + "staff_record_saved": "Enregistré", + "staff_record_duplicate_title": "Existe déjà", + "staff_record_duplicate_body": "« %1$s » est déjà au catalogue. Modifier plutôt la notice existante ?", + "staff_field_name": "Nom *", + "staff_field_pseudonym": "Pseudonyme", + "staff_field_born": "Naissance", + "staff_field_died": "Décès", + "staff_field_nationality": "Nationalité", + "staff_field_website": "Site web", + "staff_field_gnd": "Identifiant GND", + "staff_field_biography": "Biographie", + "staff_field_address": "Adresse", + "staff_field_phone": "Téléphone", + "staff_field_email": "E-mail", + "staff_genre_add": "Ajouter un genre", + "staff_subgenre_add_to": "Ajouter un sous-genre à %1$s", + "staff_genre_exists": "Ce genre existe déjà.", + "staff_shelf_add": "Ajouter une étagère", + "staff_level_add": "Ajouter une tablette", + "staff_level_add_to": "Ajouter une tablette à %1$s", + "staff_shelf_exists": "Une étagère avec ce code existe déjà.", + "staff_level_exists": "Cette étagère a déjà une tablette avec ce numéro.", + "staff_shelf_no_levels": "Aucune tablette", + "staff_field_shelf_code": "Code *", + "staff_field_level_number": "Numéro de tablette *", + "staff_scan_hint": "Placez le code-barres dans le cadre. Chaque code est lu deux fois avant d’être accepté.", + "staff_scan_manual_label": "Saisir un ISBN ou un EAN", + "staff_scan_lookup": "Rechercher", + "staff_scan_looking": "Recherche en cours…", + "staff_scan_permission_rationale": "L’appareil photo lit les codes-barres des livres. Vous pouvez aussi saisir l’ISBN ci-dessous.", + "staff_scan_permission_allow": "Autoriser l’appareil photo", + "staff_scan_permission_denied": "L’accès à l’appareil photo est désactivé pour Pinakes. Activez-le dans les paramètres ou saisissez l’ISBN ci-dessous.", + "staff_scan_permission_settings": "Ouvrir les paramètres", + "staff_scan_torch_on": "Allumer la lampe", + "staff_scan_torch_off": "Éteindre la lampe", + "staff_scan_settings": "Réglages de la série", + "staff_scan_settings_hint": "Appliqués à chaque titre confirmé pendant cette session.", + "staff_scan_batch_title": "Mode en série", + "staff_scan_batch_copies": "%1$d exemplaires", + "staff_scan_no_location": "Aucun emplacement", + "staff_scan_auto_on": "confirmation automatique activée", + "staff_scan_auto_off": "confirmer chaque titre", + "staff_scan_auto_confirm": "Confirmation automatique", + "staff_scan_auto_confirm_hint": "Ajoute les titres trouvés sans l’aperçu. Les doublons et les codes inconnus demandent toujours.", + "staff_scan_session_count": "%1$d ajoutés", + "staff_scan_added": "Ajouté au catalogue", + "staff_scan_added_list": "Ajoutés pendant cette session", + "staff_scan_added_new": "Nouvelle notice · %1$d exemplaires", + "staff_scan_added_copies": "+%1$d exemplaires", + "staff_scan_confirm": "Ajouter au catalogue", + "staff_scan_edit_details": "Modifier d’abord les détails", + "staff_scan_skip": "Passer", + "staff_scan_duplicate": "Déjà au catalogue", + "staff_scan_open_existing": "Ouvrir la notice existante", + "staff_scan_add_copy": "Ajouter des exemplaires", + "staff_scan_not_found": "Introuvable en ligne", + "staff_scan_not_found_body": "Aucune source ne connaît %1$s. Vous pouvez créer la notice manuellement.", + "staff_scan_not_found_ean": "%1$s n’est pas un ISBN de livre et aucune source ne le connaît. Vous pouvez créer la notice manuellement.", + "staff_scan_not_found_manual": "Aucune source ne connaît cet identifiant : complétez la notice manuellement.", + "staff_scan_create_manual": "Créer manuellement", + "staff_scan_rescan": "Scanner à nouveau", + "staff_scrape_unavailable": "Les services de recherche sont injoignables pour le moment.", + "staff_scan_offline_title": "Le scan nécessite une connexion", + "staff_scan_offline_subtitle": "Chaque code est recherché sur le serveur de la bibliothèque. Reconnectez-vous et réessayez." } diff --git a/i18n/it.json b/i18n/it.json index 66bad97..1030e0c 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -650,5 +650,168 @@ "calendar_disable_confirm": "I calendari iscritti a questo link smettono di ricevere i tuoi prestiti.", "calendar_disabled": "Link calendario disattivato", "calendar_link_ready": "Nuovo link calendario pronto", - "calendar_error": "Impossibile caricare il link del calendario." + "calendar_error": "Impossibile caricare il link del calendario.", + "staff_title": "Area staff", + "staff_section_add": "Aggiungi al catalogo", + "staff_section_manage": "Gestisci", + "staff_scan": "Scansiona ISBN", + "staff_scan_subtitle": "Lettore di codici a barre con modalità in serie", + "staff_add_manual": "Aggiungi a mano", + "staff_add_manual_subtitle": "Per materiale senza ISBN: spartiti, opuscoli, oggetti", + "staff_books": "Libri", + "staff_authors": "Autori", + "staff_publishers": "Editori", + "staff_genres": "Generi", + "staff_shelves": "Scaffali", + "staff_forbidden": "Operazione riservata allo staff.", + "staff_error_load": "Impossibile caricare i dati.", + "staff_error_save": "Salvataggio non riuscito. Riprova.", + "staff_error_busy": "Il catalogo è occupato da un altro salvataggio. Riprova tra poco.", + "staff_error_offline": "Nessuna connessione. Le modifiche al catalogo richiedono il server.", + "staff_cd_decrease": "Diminuisci", + "staff_cd_increase": "Aumenta", + "staff_option_none": "Nessuno", + "staff_entity_new": "Aggiungi nuovo: %1$s", + "staff_entity_new_chip": "%1$s (nuovo)", + "staff_entity_use_existing": "Usa esistente", + "staff_picker_empty": "Nessun risultato.", + "staff_books_search": "Cerca titolo, autore o ISBN", + "staff_books_empty": "Nessun record trovato", + "staff_copies_count": "%1$d copie disponibili su %2$d", + "staff_copies_total": "Copie in catalogo: %1$d", + "staff_book_new": "Nuovo record", + "staff_book_edit": "Modifica record", + "staff_book_save": "Salva", + "staff_book_saved": "Record salvato", + "staff_book_delete": "Elimina record", + "staff_book_delete_confirm": "Eliminare «%1$s»? Sparisce dal catalogo e i suoi identificativi tornano liberi.", + "staff_book_deleted": "Record eliminato", + "staff_book_delete_active_circulation": "Questo titolo ha prestiti o prenotazioni attivi e non può essere eliminato.", + "staff_book_duplicate_title": "Già in catalogo", + "staff_book_duplicate_body": "Esiste già un record con questo identificativo:", + "staff_change_identifier": "Cambia identificativo", + "staff_form_fix_errors": "Controlla i campi evidenziati.", + "staff_field_required": "Obbligatorio", + "staff_field_invalid": "Non valido", + "staff_identifier_invalid": "ISBN o EAN non valido", + "staff_lookup_failed_manual": "La ricerca online non ha risposto: compila il record a mano.", + "staff_section_cover": "Copertina", + "staff_section_identity": "Titolo e identificativi", + "staff_section_people": "Autori ed editori", + "staff_section_classification": "Classificazione", + "staff_section_content": "Contenuto ed edizione", + "staff_section_holdings": "Copie e collocazione", + "staff_section_acquisition": "Acquisizione e note", + "staff_field_title": "Titolo *", + "staff_field_subtitle": "Sottotitolo", + "staff_field_isbn13": "ISBN-13", + "staff_field_isbn10": "ISBN-10", + "staff_field_ean": "EAN", + "staff_field_issn": "ISSN", + "staff_add_author": "Aggiungi autore", + "staff_add_publisher": "Aggiungi editore", + "staff_field_genre": "Genere", + "staff_field_subgenre": "Sottogenere", + "staff_field_media_type": "Tipo di supporto", + "staff_field_language": "Lingua", + "staff_field_format": "Formato", + "staff_field_dewey": "Classificazione Dewey", + "staff_field_description": "Descrizione", + "staff_field_keywords": "Parole chiave", + "staff_field_year": "Anno", + "staff_field_pages": "Pagine", + "staff_field_pub_date": "Data di pubblicazione", + "staff_field_edition": "Edizione", + "staff_field_place": "Luogo di pubblicazione", + "staff_field_series": "Collana", + "staff_field_series_number": "Numero", + "staff_copies": "Copie", + "staff_copies_to_add": "Copie da aggiungere", + "staff_add_copies": "Aggiungi copie", + "staff_copies_added": "Copie aggiunte", + "staff_shelf": "Scaffale", + "staff_level": "Mensola", + "staff_level_number": "Mensola %1$d", + "staff_position_auto": "La posizione viene assegnata automaticamente (primo posto libero).", + "staff_position_auto_label": "Prossima posizione libera: %1$s", + "staff_field_acquisition_date": "Data di acquisizione", + "staff_field_acquisition_type": "Tipo di acquisizione", + "staff_field_price": "Prezzo", + "staff_field_inventory_base": "Numero di inventario", + "staff_field_notes": "Note", + "staff_cover_from_url": "Da URL", + "staff_cover_take_photo": "Scatta foto", + "staff_cover_pick": "Scegli immagine", + "staff_cover_url_label": "Indirizzo dell’immagine", + "staff_cover_pending": "Nuova copertina, applicata al salvataggio.", + "staff_cover_discard": "Annulla", + "staff_cover_failed": "Salvato, ma non è stato possibile caricare la copertina.", + "staff_camera_unavailable": "La fotocamera non è disponibile su questo dispositivo.", + "staff_records_empty": "Ancora nessun elemento", + "staff_record_new": "Nuovo", + "staff_record_edit": "Modifica", + "staff_record_saved": "Salvato", + "staff_record_duplicate_title": "Esiste già", + "staff_record_duplicate_body": "«%1$s» è già in catalogo. Vuoi modificare il record esistente?", + "staff_field_name": "Nome *", + "staff_field_pseudonym": "Pseudonimo", + "staff_field_born": "Nascita", + "staff_field_died": "Morte", + "staff_field_nationality": "Nazionalità", + "staff_field_website": "Sito web", + "staff_field_gnd": "Identificativo GND", + "staff_field_biography": "Biografia", + "staff_field_address": "Indirizzo", + "staff_field_phone": "Telefono", + "staff_field_email": "Email", + "staff_genre_add": "Aggiungi genere", + "staff_subgenre_add_to": "Aggiungi un sottogenere a %1$s", + "staff_genre_exists": "Questo genere esiste già.", + "staff_shelf_add": "Aggiungi scaffale", + "staff_level_add": "Aggiungi mensola", + "staff_level_add_to": "Aggiungi una mensola a %1$s", + "staff_shelf_exists": "Esiste già uno scaffale con questo codice.", + "staff_level_exists": "Questo scaffale ha già una mensola con quel numero.", + "staff_shelf_no_levels": "Nessuna mensola", + "staff_field_shelf_code": "Codice *", + "staff_field_level_number": "Numero mensola *", + "staff_scan_hint": "Inquadra il codice a barre nella cornice. Ogni codice viene letto due volte prima di essere accettato.", + "staff_scan_manual_label": "Digita un ISBN o EAN", + "staff_scan_lookup": "Cerca", + "staff_scan_looking": "Ricerca in corso…", + "staff_scan_permission_rationale": "La fotocamera legge i codici a barre dei libri. Puoi anche digitare l’ISBN qui sotto.", + "staff_scan_permission_allow": "Consenti fotocamera", + "staff_scan_permission_denied": "L’accesso alla fotocamera è disattivato per Pinakes. Attivalo nelle impostazioni, oppure digita l’ISBN qui sotto.", + "staff_scan_permission_settings": "Apri impostazioni", + "staff_scan_torch_on": "Accendi la luce", + "staff_scan_torch_off": "Spegni la luce", + "staff_scan_settings": "Impostazioni serie", + "staff_scan_settings_hint": "Applicate a ogni titolo che confermi in questa sessione.", + "staff_scan_batch_title": "Modalità in serie", + "staff_scan_batch_copies": "%1$d copie", + "staff_scan_no_location": "Nessuna collocazione", + "staff_scan_auto_on": "conferma automatica attiva", + "staff_scan_auto_off": "conferma ogni titolo", + "staff_scan_auto_confirm": "Conferma automatica", + "staff_scan_auto_confirm_hint": "Aggiunge i titoli trovati senza l’anteprima. Duplicati e codici sconosciuti chiedono comunque.", + "staff_scan_session_count": "%1$d aggiunti", + "staff_scan_added": "Aggiunto al catalogo", + "staff_scan_added_list": "Aggiunti in questa sessione", + "staff_scan_added_new": "Nuovo record · %1$d copie", + "staff_scan_added_copies": "+%1$d copie", + "staff_scan_confirm": "Aggiungi al catalogo", + "staff_scan_edit_details": "Modifica prima i dettagli", + "staff_scan_skip": "Salta", + "staff_scan_duplicate": "Già in catalogo", + "staff_scan_open_existing": "Apri esistente", + "staff_scan_add_copy": "Aggiungi copie", + "staff_scan_not_found": "Non trovato online", + "staff_scan_not_found_body": "Nessuna fonte conosce %1$s. Puoi creare il record a mano.", + "staff_scan_not_found_ean": "%1$s non è un ISBN di libro e nessuna fonte lo conosce. Puoi creare il record a mano.", + "staff_scan_not_found_manual": "Nessuna fonte conosce questo identificativo: compila il record a mano.", + "staff_scan_create_manual": "Crea a mano", + "staff_scan_rescan": "Scansiona di nuovo", + "staff_scrape_unavailable": "I servizi di ricerca non sono raggiungibili in questo momento.", + "staff_scan_offline_title": "La scansione richiede una connessione", + "staff_scan_offline_subtitle": "Ogni codice viene cercato sul server della biblioteca. Riconnettiti e riprova." } From 8eaffdf3be3e56853efe39951101b09d33331ab0 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 17:00:05 +0200 Subject: [PATCH 06/62] Release 1.7.0 (versionCode 20): staff catalogue, offline catalogue, loans calendar Bump the version and record the new features, their server requirement (Mobile API 1.6.0) and what is not yet verified on a device in STATUS.md and the Uwe parity notes. --- STATUS.md | 12 +++++++++++- app/build.gradle.kts | 4 ++-- docs/UWE-PARITY.md | 1 + 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/STATUS.md b/STATUS.md index 84df63d..e4d6046 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,15 @@ # Pinakes Android — Build Status +## 1.7.0 (versionCode 20): staff catalogue, offline catalogue, loans calendar + +Requires Mobile API 1.6.0 (Pinakes 0.8.5). Every new entry point is gated on the matching `/health` feature, so an older server keeps showing exactly the 1.6.2 app. + +- **Staff area** (`staff_catalog` + role `admin`/`staff`, refreshed from login and `GET /me`; a 403 on any staff call drops the stored role and leaves the screen): Profile → Staff area, and a scan button on the Catalog tab. Books (list with search, edit, delete with the active-circulation refusal), a shared create/edit form for records with or without an ISBN (identifiers validated live, authors/publishers pickers with "add new", genre/subgenre, shelf/level with automatic position, cover from URL / photo / picked image, copies), authors, publishers, genres (tree, find-or-create) and shelves/levels. Edits are true patches: only changed fields are sent. +- **ISBN scanner**: CameraX + ZXing EAN-13 (no Play services), checksum validation, two identical reads before a lookup, 3 s suppression, batch mode with remembered copies, shelf/level and optional auto-confirm, preview / duplicate ("add copies", "open existing") / not-found sheets. Manual ISBN/EAN entry is always visible (emulators, denied permission). CAMERA is requested only there, with rationale and a settings fallback; `camera.any` is optional. +- **Offline catalogue**: Room v2 holds the whole catalogue, kept current by `GET /catalog/sync` (delta with a server watermark, forced full resync every 7 days or when the server asks); loans, reservations and wishlist are written through on every fetch. Without a connection, search, book pages, Library and Wishlist answer from the cache with an offline banner. Loan and reservation actions are hidden offline (never shown disabled) and cached availability is shown only as "last known on ". The cache is excluded from backups. +- **Loans calendar**: Profile → Loans in your calendar (`calendar_feed`): create the private ICS link, copy it (primary, Google Calendar can only subscribe from its website), open it as `webcal://` with an HTTPS fallback, regenerate (old link stops) or turn it off. +- Not verified on a device in this change: camera scanning, photo capture and the flows against a live 1.6.0 server (unit tests, lint and both builds only). + ## 2026 restyle (2026-10-08) The app now uses the 2026 web design: Geist / Fraunces, warm neutrals, a server-ready `ThemePalette`, complete 3D book covers, a searchable home hero with a cover fan, catalog grid/list views, dot status pills, grouped availability/actions and one card per exposed digital asset. Shared tokens apply to all existing account and plugin screens. The five-section bottom navigation is retained. Article lists and details now show server-resolved covers, subtitles, published online resources and access conditions, with a website action for the existing staff management flows. Native collections require the additive Mobile API 1.5.0 server update; existing circulation and authentication routes are retained. @@ -58,7 +68,7 @@ It follows the **device locale** by default and offers an **in-app language switcher** in Profile (System default / Italiano / English / Français / Deutsch) via `AppCompatDelegate.setApplicationLocales(...)`, persisted across restarts (`autoStoreLocales`). -- **Single source of truth = JSON.** Translations live in `i18n/{en,it,fr,de}.json` (622 keys each, +- **Single source of truth = JSON.** Translations live in `i18n/{en,it,fr,de}.json` (815 keys each, en = default/source). A Gradle task (`GenerateI18nResTask`) generates `res/values*/strings.xml` from those JSONs at build time, so the app uses standard Android string resources but the editable source stays JSON — syncable with the web app's `locale/*.json`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9d085f0..6832fbb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -166,8 +166,8 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" minSdk = 26 targetSdk = 35 - versionCode = 19 - versionName = "1.6.2" + versionCode = 20 + versionName = "1.7.0" vectorDrawables { useSupportLibrary = true diff --git a/docs/UWE-PARITY.md b/docs/UWE-PARITY.md index b3cfdb6..f12733f 100644 --- a/docs/UWE-PARITY.md +++ b/docs/UWE-PARITY.md @@ -26,6 +26,7 @@ I filtri di disponibilità si applicano ai libri con copie. La ricerca per gener - Android: 185 unit test, 19 test Compose su Android 15, zero errori Lint; build debug e release R8 riuscite. Test senza donazioni o modifiche al catalogo Bibliodoc di produzione. - Backend richiesto: Mobile API 1.5.0 e plugin aggiornati (Desiderata 1.2.0, Emeroteca 1.13.0, Archives 1.5.1). Le nuove sezioni dipendono dalle capability del server. Un server precedente continua a funzionare, ma non offre la nuova API delle raccolte; per i filtri analitici non supportati l’app richiede l’aggiornamento. - La gestione amministrativa e gli upload degli articoli/archivi aprono il PHP protetto nel browser. Non sono nuovi editor CRUD nativi. +- Aggiornamento 1.7.0 (Mobile API 1.6.0): per libri, autori, editori, generi e scaffali la gestione staff è ora nativa (lettore ISBN con modalità in serie, scheda manuale per materiale senza ISBN, copie, copertina). Articoli, archivi, utenti, prestiti e impostazioni restano sulle pagine PHP protette. Il catalogo è consultabile offline e i prestiti si possono sottoscrivere come calendario personale. - [#52](https://github.com/fabiodalez-dev/Pinakes/issues/52), import danese FBI/DBC, resta una richiesta separata non implementata: token e documentazione completa richiedono accesso DBC, che non è disponibile in questa sessione. Non è una funzionalità già presente nel server da sincronizzare. - [#57](https://github.com/fabiodalez-dev/Pinakes/issues/57) descrive l’installazione dell’estensione YAZ nel container QNAP; Uwe precisa che non è un difetto Pinakes. Non viene dichiarata risolta modificando il client Android. - Nessuna merge, pubblicazione sugli store o distribuzione in produzione effettuata. Il caso ANR sul vecchio emulatore GUI resta distinto dai test riusciti sull’AVD headless; non si dichiara risolto un ANR su dispositivi reali. From 8074242469098f9d3202e7af65bd9989162483e1 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 17:05:02 +0200 Subject: [PATCH 07/62] Treat the calendar feed link as a secret The feed URL carries a bearer-equivalent token. Copy link now flags the clip as sensitive (ClipDescription.EXTRA_IS_SENSITIVE on Android 13+, the same extra key before) with a neutral label, so the clipboard preview and keyboards do not show or keep it, and the sheet shows only the host and the last six characters instead of the full URL. The URL is never logged. --- .../app/data/calendar/CalendarLinks.kt | 15 +++++++++++++ .../screens/profile/CalendarSubscribeSheet.kt | 22 ++++++++++++++----- .../pinakes/app/staff/CalendarLinksTest.kt | 11 ++++++++++ 3 files changed, 43 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/data/calendar/CalendarLinks.kt b/app/src/main/java/com/pinakes/app/data/calendar/CalendarLinks.kt index 0f800b3..8a4b50e 100644 --- a/app/src/main/java/com/pinakes/app/data/calendar/CalendarLinks.kt +++ b/app/src/main/java/com/pinakes/app/data/calendar/CalendarLinks.kt @@ -34,4 +34,19 @@ object CalendarLinks { !feedUrl.isNullOrBlank() -> toWebcal(feedUrl) else -> null } + + /** + * The feed URL embeds a secret token (anyone holding it reads the user's loans), so the + * sheet shows only `host/…` plus the last [visible] characters of the path. + */ + fun mask(url: String, visible: Int = 6): String { + val trimmed = url.trim() + val afterScheme = trimmed.substringAfter("://", trimmed) + val host = afterScheme.substringBefore('/') + val path = afterScheme.substringAfter('/', "") + if (path.isEmpty()) return host + val tail = path.removeSuffix(".ics").takeLast(visible) + val ext = if (path.endsWith(".ics")) ".ics" else "" + return "$host/…$tail$ext" + } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/CalendarSubscribeSheet.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/CalendarSubscribeSheet.kt index 4f91b08..a86888a 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/CalendarSubscribeSheet.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/CalendarSubscribeSheet.kt @@ -2,10 +2,13 @@ package com.pinakes.app.ui.screens.profile import android.content.ActivityNotFoundException import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build +import android.os.PersistableBundle import android.widget.Toast import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -16,7 +19,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CalendarMonth @@ -99,9 +101,8 @@ internal fun CalendarSubscribeSheet( ) } else { Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surfaceVariant, modifier = Modifier.fillMaxWidth()) { - SelectionContainer { - Text(feedUrl, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(Spacing.md)) - } + // Masked: the URL carries the secret token. "Copy link" is the way to use it. + Text(CalendarLinks.mask(feedUrl), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(Spacing.md)) } (status.rotatedAt ?: status.createdAt)?.takeIf { it.isNotBlank() }?.let { Text(stringResource(R.string.calendar_created_on, DateFormat.dateTime(it)), style = MaterialTheme.typography.labelMedium, @@ -147,9 +148,20 @@ internal fun CalendarSubscribeSheet( } } +/** + * The link is a bearer-equivalent secret: the clip is flagged sensitive so Android 13+ hides it + * in the clipboard preview and keyboards/clipboard history should not show or store it, and the + * label is neutral. The URL is never logged. + */ private fun copyLink(context: Context, url: String) { val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - clipboard?.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.calendar_subscribe_title), url)) + val clip = ClipData.newPlainText("Pinakes", url) + clip.description.extras = PersistableBundle().apply { + val key = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) ClipDescription.EXTRA_IS_SENSITIVE + else "android.content.extra.IS_SENSITIVE" + putBoolean(key, true) + } + clipboard?.setPrimaryClip(clip) Toast.makeText(context, context.getString(R.string.calendar_link_copied), Toast.LENGTH_SHORT).show() } diff --git a/app/src/test/java/com/pinakes/app/staff/CalendarLinksTest.kt b/app/src/test/java/com/pinakes/app/staff/CalendarLinksTest.kt index 796ecc2..8443834 100644 --- a/app/src/test/java/com/pinakes/app/staff/CalendarLinksTest.kt +++ b/app/src/test/java/com/pinakes/app/staff/CalendarLinksTest.kt @@ -33,4 +33,15 @@ class CalendarLinksTest { assertEquals(CalendarLinks.toWebcal(feed), CalendarLinks.subscribeUrl(feed, " ")) assertNull(CalendarLinks.subscribeUrl(null, null)) } + + @Test fun maskHidesTheTokenButKeepsHostAndTail() { + val token = "0123456789abcdef".repeat(4) + val masked = CalendarLinks.mask("https://lib.example.org/api/v1/calendar/feed/$token.ics") + assertEquals("lib.example.org/…abcdef.ics", masked) + assertEquals(false, masked.contains(token)) + } + + @Test fun maskOfHostOnlyUrl() { + assertEquals("h", CalendarLinks.mask("https://h")) + } } From 38a6579ccac81ffb93f5661521875bef281c9370 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 17:05:17 +0200 Subject: [PATCH 08/62] docs: STATUS records the 1.7.0 verification --- STATUS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATUS.md b/STATUS.md index e4d6046..5913418 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,6 +8,7 @@ Requires Mobile API 1.6.0 (Pinakes 0.8.5). Every new entry point is gated on the - **ISBN scanner**: CameraX + ZXing EAN-13 (no Play services), checksum validation, two identical reads before a lookup, 3 s suppression, batch mode with remembered copies, shelf/level and optional auto-confirm, preview / duplicate ("add copies", "open existing") / not-found sheets. Manual ISBN/EAN entry is always visible (emulators, denied permission). CAMERA is requested only there, with rationale and a settings fallback; `camera.any` is optional. - **Offline catalogue**: Room v2 holds the whole catalogue, kept current by `GET /catalog/sync` (delta with a server watermark, forced full resync every 7 days or when the server asks); loans, reservations and wishlist are written through on every fetch. Without a connection, search, book pages, Library and Wishlist answer from the cache with an offline banner. Loan and reservation actions are hidden offline (never shown disabled) and cached availability is shown only as "last known on ". The cache is excluded from backups. - **Loans calendar**: Profile → Loans in your calendar (`calendar_feed`): create the private ICS link, copy it (primary, Google Calendar can only subscribe from its website), open it as `webcal://` with an HTTPS fallback, regenerate (old link stops) or turn it off. +- Local verification: 283 debug unit tests (new: ISBN/EAN codec, scan debouncer, staff access, staff form and write bodies, sync merge, offline policy, calendar links, tolerant 1.6.0 parsing, Room v2 catalogue/library DAOs), zero lint errors (111 warnings, mostly newer-dependency notices), debug and R8 release builds. - Not verified on a device in this change: camera scanning, photo capture and the flows against a live 1.6.0 server (unit tests, lint and both builds only). ## 2026 restyle (2026-10-08) From 6c6782872aeab61f6866a158fef8e600de8589ae Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 17:24:11 +0200 Subject: [PATCH 09/62] Let author and publisher edits clear a field Blank fields became null and the shared Json drops null keys, so the server kept the old value and a pseudonym, date, website or GND id could never be emptied. Every editable field is now always sent, trimmed, with a cleared field as an empty string, which the server stores as empty (dates and the GND id as NULL). RecordWritesTest checks the encoded bodies. --- .../ui/screens/staff/StaffRecordsScreens.kt | 29 ++++++---- .../com/pinakes/app/staff/RecordWritesTest.kt | 53 +++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/com/pinakes/app/staff/RecordWritesTest.kt diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt index 9c82a0f..c687b60 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt @@ -114,6 +114,22 @@ data class RecordsUiState( private fun String.orNullIfBlank(): String? = trim().takeIf { it.isNotEmpty() } +/** + * Every editable field is always sent, trimmed, and a cleared field as `""`: the server keeps + * the stored value for an absent key (null keys are dropped by `explicitNulls = false`) and + * clears it for `""` (dates and the GND id become NULL). Without this a PUT could never empty + * a field. + */ +internal fun RecordForm.toAuthorWrite(): StaffAuthorWrite = StaffAuthorWrite( + name = name.trim(), pseudonym = pseudonym.trim(), born = born.trim(), died = died.trim(), + nationality = nationality.trim(), biography = biography.trim(), website = website.trim(), gndId = gndId.trim(), +) + +/** Same contract as [toAuthorWrite]: cleared fields are sent as `""`. */ +internal fun RecordForm.toPublisherWrite(): StaffPublisherWrite = StaffPublisherWrite( + name = name.trim(), website = website.trim(), address = address.trim(), phone = phone.trim(), email = email.trim(), +) + /** Shared logic for the author and publisher screens. */ abstract class RecordsViewModel(protected val staff: StaffRepository, kind: RecordKind) : ViewModel() { protected val _state = MutableStateFlow(RecordsUiState(kind = kind)) @@ -231,12 +247,7 @@ class StaffAuthorsViewModel @Inject constructor(staff: StaffRepository) : Record } override suspend fun save(form: RecordForm): ApiResult { - val body = StaffAuthorWrite( - name = form.name.trim(), pseudonym = form.pseudonym.orNullIfBlank(), born = form.born.orNullIfBlank(), - died = form.died.orNullIfBlank(), nationality = form.nationality.orNullIfBlank(), biography = form.biography.orNullIfBlank(), - website = form.website.orNullIfBlank(), gndId = form.gndId.orNullIfBlank(), - ) - return when (val res = staff.saveAuthor(form.id, body)) { + return when (val res = staff.saveAuthor(form.id, form.toAuthorWrite())) { is ApiResult.Success -> ApiResult.Success(Unit) is ApiResult.Failure -> res } @@ -258,11 +269,7 @@ class StaffPublishersViewModel @Inject constructor(staff: StaffRepository) : Rec } override suspend fun save(form: RecordForm): ApiResult { - val body = StaffPublisherWrite( - name = form.name.trim(), website = form.website.orNullIfBlank(), address = form.address.orNullIfBlank(), - phone = form.phone.orNullIfBlank(), email = form.email.orNullIfBlank(), - ) - return when (val res = staff.savePublisher(form.id, body)) { + return when (val res = staff.savePublisher(form.id, form.toPublisherWrite())) { is ApiResult.Success -> ApiResult.Success(Unit) is ApiResult.Failure -> res } diff --git a/app/src/test/java/com/pinakes/app/staff/RecordWritesTest.kt b/app/src/test/java/com/pinakes/app/staff/RecordWritesTest.kt new file mode 100644 index 0000000..f8aaf7f --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/RecordWritesTest.kt @@ -0,0 +1,53 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.StaffAuthorWrite +import com.pinakes.app.data.model.StaffPublisherWrite +import com.pinakes.app.ui.screens.staff.RecordForm +import com.pinakes.app.ui.screens.staff.toAuthorWrite +import com.pinakes.app.ui.screens.staff.toPublisherWrite +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.encodeToJsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Author/publisher saves must be able to clear a field: cleared fields go out as "". */ +class RecordWritesTest { + + // Same configuration as NetworkModule.json: null keys are dropped from requests. + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun clearedAuthorFieldsAreSentAsEmptyStrings() { + val body = json.encodeToJsonElement( + StaffAuthorWrite.serializer(), + RecordForm(id = 3, name = " Italo Calvino ", pseudonym = "", born = "", died = "", website = " ", gndId = ""), + ).jsonObject + assertEquals("Italo Calvino", body.str("name")) + for (key in listOf("pseudonym", "born", "died", "nationality", "biography", "website", "gnd_id")) { + assertEquals("key $key", "", body.str(key)) + } + } + + @Test fun filledAuthorFieldsAreTrimmed() { + val body = json.encodeToJsonElement(StaffAuthorWrite.serializer(), RecordForm(name = "A", born = " 1923-10-15 ")).jsonObject + assertEquals("1923-10-15", body.str("born")) + } + + @Test fun clearedPublisherFieldsAreSentAsEmptyStrings() { + val body = json.encodeToJsonElement(StaffPublisherWrite.serializer(), RecordForm(name = "Einaudi", website = "", phone = "")).jsonObject + assertEquals(setOf("name", "website", "address", "phone", "email"), body.keys) + assertEquals("", body.str("website")) + assertEquals("", body.str("email")) + } + + private fun Json.encodeToJsonElement(serializer: kotlinx.serialization.KSerializer, form: RecordForm) = + encodeToJsonElement(serializer, form.toAuthorWrite()) + + @JvmName("encodePublisher") + private fun Json.encodeToJsonElement(serializer: kotlinx.serialization.KSerializer, form: RecordForm) = + encodeToJsonElement(serializer, form.toPublisherWrite()) + + private fun JsonObject.str(k: String) = this[k]!!.jsonPrimitive.content +} From a83756580212bf8608bfc55357bdd786bcc13074 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 17:24:11 +0200 Subject: [PATCH 10/62] Keep the staff book list paging after a reload A search or pull-to-refresh during a page load dropped that page through the generation check but left loadingMore set, so loadMore() never ran again. reload() now clears the flag. --- .../java/com/pinakes/app/ui/screens/staff/StaffBooksScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBooksScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBooksScreen.kt index fe0e502..e7ba143 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBooksScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBooksScreen.kt @@ -105,7 +105,9 @@ class StaffBooksViewModel @Inject constructor(private val staff: StaffRepository fun reload(pull: Boolean = false) { val gen = ++generation - _state.update { it.copy(loading = !pull && it.items.isEmpty(), refreshing = pull, error = null) } + // A reload supersedes any page in flight (its result is dropped by the generation + // check), so the pagination flag must be cleared here or loadMore() stays blocked. + _state.update { it.copy(loading = !pull && it.items.isEmpty(), refreshing = pull, error = null, loadingMore = false) } viewModelScope.launch { val res = staff.books(_state.value.query, cursor = null) if (gen != generation) return@launch From 34e6d494adeda22b66b1ef449facea1f21a1737b Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 17:24:11 +0200 Subject: [PATCH 11/62] Require a level with a shelf and stay on the record when the cover fails Picking a shelf without a level sent null shelf and level ids, which cleared the stored location without a word. A shelf without a level is now a validation error with a message under the level field (all four languages); nulls are sent only when the shelf is set to none, and a stored location without a level is left untouched instead of making the form invalid. When the record saved but the cover upload failed, the screen navigated away and the error was lost. It now stays on the saved record (switching a create into an edit of the new id), keeps the pending cover so the next Save retries it, and says so. --- .../pinakes/app/data/staff/StaffBookForm.kt | 33 +++++++++++++------ .../ui/screens/staff/StaffBookEditScreen.kt | 3 ++ .../screens/staff/StaffBookEditViewModel.kt | 27 ++++++++++----- .../app/staff/StaffBookFormStateTest.kt | 22 +++++++++++++ i18n/de.json | 5 +-- i18n/en.json | 5 +-- i18n/fr.json | 5 +-- i18n/it.json | 5 +-- 8 files changed, 79 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt index 7e44b5d..d5d0f27 100644 --- a/app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffBookForm.kt @@ -17,7 +17,7 @@ data class PickedEntity(val id: Int, val name: String) { } /** Form fields that can fail validation, so the screen can point at the right one. */ -enum class FormField { TITLE, ISBN13, ISBN10, EAN, ISSN, YEAR, PAGES, COPIES, PRICE, COVER_URL } +enum class FormField { TITLE, ISBN13, ISBN10, EAN, ISSN, YEAR, PAGES, COPIES, PRICE, COVER_URL, LEVEL } /** Roles the edit form treats as "authors"; the others are preserved contributors. */ private val AUTHOR_ROLES = setOf("principale", "co-autore", "autore", "") @@ -77,6 +77,9 @@ data class StaffBookForm( if (price.isNotBlank() && !PRICE.matches(price.trim())) add(FormField.PRICE) if (!isEdit && copies !in COPIES_RANGE) add(FormField.COPIES) if (!isEdit && coverUrl.isNotBlank() && !isHttpUrl(coverUrl)) add(FormField.COVER_URL) + // A shelf needs a level: the server places a book on a level, and a half-filled + // location must never be sent (it would clear the stored one). + if (shelfId != null && levelId == null) add(FormField.LEVEL) } fun canSave(isEdit: Boolean = false): Boolean = validate(isEdit).isEmpty() @@ -122,8 +125,10 @@ data class StaffBookForm( publishers = book.publishers.map { PickedEntity(it.id, it.name) }, genre = book.genre?.takeIf { it.id > 0 }, subgenre = book.subgenre?.takeIf { it.id > 0 }, - shelfId = book.location?.shelfId, - levelId = book.location?.levelId, + // A location without a level cannot be edited as such: treat it as "no change" + // (the screen still shows the stored label) instead of an invalid form. + shelfId = book.location?.takeIf { it.levelId != null }?.shelfId, + levelId = book.location?.takeIf { it.shelfId != null }?.levelId, ) /** @@ -176,6 +181,8 @@ data class StaffBookForm( * - [update] is a true patch: only fields that differ from [StaffBookForm.from] of the * original record are sent. A cleared text field is sent as `""`, a cleared number as * `null`; the server merges the patch over the current record, so nothing else moves. + * A location is sent whole (shelf + level + next position) or, when the user cleared the + * shelf, as explicit nulls; a shelf without a level is never sent. * When the authors change, the original translators/illustrators/editors/colorists are * re-sent in `contributors` so the rebuild of the author links keeps them. */ @@ -218,13 +225,19 @@ object StaffBookWrite { if (form.genre?.id != before.genre?.id) put("genre_id", form.genre?.id?.let { JsonPrimitive(it) } ?: JsonNull) if (form.subgenre?.id != before.subgenre?.id) put("subgenre_id", form.subgenre?.id?.let { JsonPrimitive(it) } ?: JsonNull) if (form.shelfId != before.shelfId || form.levelId != before.levelId) { - if (form.shelfId != null && form.levelId != null) { - put("shelf_id", form.shelfId) - put("level_id", form.levelId) - put("position", 0) - } else { - put("shelf_id", JsonNull) - put("level_id", JsonNull) + when { + form.shelfId != null && form.levelId != null -> { + put("shelf_id", form.shelfId) + put("level_id", form.levelId) + put("position", 0) + } + // Only an explicit clear (shelf set to none) removes the location. + form.shelfId == null && form.levelId == null -> { + put("shelf_id", JsonNull) + put("level_id", JsonNull) + } + // Shelf without level: blocked by validate(); never send a partial location. + else -> Unit } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt index 9961e18..3af39a1 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt @@ -356,6 +356,9 @@ private fun ShelfFields(state: StaffBookEditUiState, vm: StaffBookEditViewModel) options = levelOptions, editable = false, onValueChange = { v -> vm.selectLevel(v.toIntOrNull()) }, modifier = Modifier.fillMaxWidth(), ) + if (FormField.LEVEL in state.errors) { + Text(stringResource(R.string.staff_level_required), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } } if (f.shelfId != null && f.levelId != null) { val next = state.nextPosition diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt index cb5bd63..d9a8734 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt @@ -107,7 +107,8 @@ class StaffBookEditViewModel @Inject constructor( @ApplicationContext private val context: Context, ) : ViewModel() { - private val bookId: Int = savedStateHandle.get(Routes.ARG_BOOK_ID) ?: 0 + /** 0 while creating; becomes the new id when a create succeeds but the cover upload fails. */ + private var bookId: Int = savedStateHandle.get(Routes.ARG_BOOK_ID) ?: 0 private val isbnArg: String = savedStateHandle.get(Routes.ARG_ISBN).orEmpty() private val _state = MutableStateFlow(StaffBookEditUiState(bookId = bookId)) @@ -325,13 +326,23 @@ class StaffBookEditViewModel @Inject constructor( is ApiResult.Success -> { val id = result.data.id.takeIf { it > 0 } ?: bookId val coverOk = applyCover(id, pending, alreadySent = !s.isEdit && pending is PendingCover.FromUrl) - _state.update { - it.copy( - saving = false, - pendingCover = null, - event = StaffEditEvent.Saved(id, created = !s.isEdit), - snackbarRes = if (coverOk) null else R.string.staff_cover_failed, - ) + if (coverOk) { + _state.update { it.copy(saving = false, pendingCover = null, event = StaffEditEvent.Saved(id, created = !s.isEdit)) } + } else { + // The record is saved but the cover is not: stay here, now editing the + // saved record, keep the pending cover so the next Save retries the + // upload, and say so (navigating away would swallow the message). + bookId = id + val saved = result.data.takeIf { it.id > 0 } ?: s.original + _state.update { + it.copy( + saving = false, + bookId = id, + original = saved ?: it.original, + form = saved?.let { b -> StaffBookForm.from(b) } ?: it.form, + snackbarRes = R.string.staff_cover_failed, + ) + } } } is ApiResult.Failure -> onSaveFailure(result) diff --git a/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt index 603ec1f..4545133 100644 --- a/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt +++ b/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt @@ -176,4 +176,26 @@ class StaffBookFormStateTest { private fun JsonObject.num(k: String) = this[k]!!.jsonPrimitive.int private fun JsonObject.ints(k: String) = (this[k] as JsonArray).map { it.jsonPrimitive.int } private fun JsonObject.strs(k: String) = this[k]!!.jsonArray.map { it.jsonPrimitive.content } + + @Test fun shelfWithoutLevelIsAValidationError() { + assertTrue(FormField.LEVEL in valid.copy(shelfId = 1, levelId = null).validate()) + assertTrue(FormField.LEVEL in valid.copy(shelfId = 1, levelId = null).validate(isEdit = true)) + assertTrue(valid.copy(shelfId = 1, levelId = 2).canSave()) + assertTrue(valid.copy(shelfId = null, levelId = null).canSave()) + } + + @Test fun changingShelfWithoutLevelNeverSendsNulls() { + // Blocked by validate(); even if the body were built, no partial or null location goes out. + val patch = StaffBookWrite.update(original, StaffBookForm.from(original).copy(shelfId = 9, levelId = null)) + assertFalse("shelf_id" in patch) + assertFalse("level_id" in patch) + } + + @Test fun storedLocationWithoutLevelIsLeftAlone() { + val noLevel = original.copy(location = StaffLocation(shelfId = 1, levelId = null, label = "A")) + val form = StaffBookForm.from(noLevel) + assertEquals(null, form.shelfId) + assertTrue(form.canSave(isEdit = true)) + assertEquals(JsonObject(emptyMap()), StaffBookWrite.update(noLevel, form)) + } } diff --git a/i18n/de.json b/i18n/de.json index 4fdf1e7..71e56e8 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -745,7 +745,7 @@ "staff_cover_url_label": "Bildadresse", "staff_cover_pending": "Neues Cover, wird beim Speichern übernommen.", "staff_cover_discard": "Verwerfen", - "staff_cover_failed": "Gespeichert, aber das Cover konnte nicht hochgeladen werden.", + "staff_cover_failed": "Gespeichert, aber das Cover konnte nicht hochgeladen werden. Zum erneuten Versuch noch einmal speichern.", "staff_camera_unavailable": "Die Kamera ist auf diesem Gerät nicht verfügbar.", "staff_records_empty": "Noch keine Einträge", "staff_record_new": "Neu", @@ -813,5 +813,6 @@ "staff_scan_rescan": "Erneut scannen", "staff_scrape_unavailable": "Die Suchdienste sind gerade nicht erreichbar.", "staff_scan_offline_title": "Scannen braucht eine Verbindung", - "staff_scan_offline_subtitle": "Jeder Code wird auf dem Bibliotheksserver gesucht. Bitte erneut verbinden und wiederholen." + "staff_scan_offline_subtitle": "Jeder Code wird auf dem Bibliotheksserver gesucht. Bitte erneut verbinden und wiederholen.", + "staff_level_required": "Bitte einen Regalboden wählen oder das Regal auf „Keine Angabe“ setzen." } diff --git a/i18n/en.json b/i18n/en.json index 61e5cf1..ba3b095 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -745,7 +745,7 @@ "staff_cover_url_label": "Image address", "staff_cover_pending": "New cover, applied when you save.", "staff_cover_discard": "Discard", - "staff_cover_failed": "Saved, but the cover could not be uploaded.", + "staff_cover_failed": "Saved, but the cover could not be uploaded. Save again to retry it.", "staff_camera_unavailable": "The camera is not available on this device.", "staff_records_empty": "Nothing here yet", "staff_record_new": "New", @@ -813,5 +813,6 @@ "staff_scan_rescan": "Scan again", "staff_scrape_unavailable": "The lookup services are not reachable right now.", "staff_scan_offline_title": "Scanning needs a connection", - "staff_scan_offline_subtitle": "Each code is looked up on the library server. Reconnect and try again." + "staff_scan_offline_subtitle": "Each code is looked up on the library server. Reconnect and try again.", + "staff_level_required": "Choose a level for this shelf, or set the shelf to none." } diff --git a/i18n/fr.json b/i18n/fr.json index fe6d934..cba3cac 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -745,7 +745,7 @@ "staff_cover_url_label": "Adresse de l’image", "staff_cover_pending": "Nouvelle couverture, appliquée à l’enregistrement.", "staff_cover_discard": "Annuler", - "staff_cover_failed": "Enregistré, mais la couverture n’a pas pu être envoyée.", + "staff_cover_failed": "Enregistré, mais la couverture n’a pas pu être envoyée. Enregistrez à nouveau pour réessayer.", "staff_camera_unavailable": "L’appareil photo n’est pas disponible sur cet appareil.", "staff_records_empty": "Rien pour l’instant", "staff_record_new": "Nouveau", @@ -813,5 +813,6 @@ "staff_scan_rescan": "Scanner à nouveau", "staff_scrape_unavailable": "Les services de recherche sont injoignables pour le moment.", "staff_scan_offline_title": "Le scan nécessite une connexion", - "staff_scan_offline_subtitle": "Chaque code est recherché sur le serveur de la bibliothèque. Reconnectez-vous et réessayez." + "staff_scan_offline_subtitle": "Chaque code est recherché sur le serveur de la bibliothèque. Reconnectez-vous et réessayez.", + "staff_level_required": "Choisissez une tablette pour cette étagère, ou réglez l’étagère sur aucune." } diff --git a/i18n/it.json b/i18n/it.json index 1030e0c..49a312c 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -745,7 +745,7 @@ "staff_cover_url_label": "Indirizzo dell’immagine", "staff_cover_pending": "Nuova copertina, applicata al salvataggio.", "staff_cover_discard": "Annulla", - "staff_cover_failed": "Salvato, ma non è stato possibile caricare la copertina.", + "staff_cover_failed": "Salvato, ma non è stato possibile caricare la copertina. Salva di nuovo per riprovare.", "staff_camera_unavailable": "La fotocamera non è disponibile su questo dispositivo.", "staff_records_empty": "Ancora nessun elemento", "staff_record_new": "Nuovo", @@ -813,5 +813,6 @@ "staff_scan_rescan": "Scansiona di nuovo", "staff_scrape_unavailable": "I servizi di ricerca non sono raggiungibili in questo momento.", "staff_scan_offline_title": "La scansione richiede una connessione", - "staff_scan_offline_subtitle": "Ogni codice viene cercato sul server della biblioteca. Riconnettiti e riprova." + "staff_scan_offline_subtitle": "Ogni codice viene cercato sul server della biblioteca. Riconnettiti e riprova.", + "staff_level_required": "Scegli una mensola per questo scaffale, oppure imposta lo scaffale su nessuno." } From ff81814f0b7c79f9d01482b398e1fc4c5adfbbdd Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 17:24:11 +0200 Subject: [PATCH 12/62] Harden the offline catalogue search and sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search: rows store a folded search key (NFD, combining marks removed, lower case; identifiers without dashes or spaces) and queries are folded the same way, so Città, Über or ÆRØ match regardless of accents and case. LIKE metacharacters in the query and the author/publisher filters are escaped with ESCAPE, and identifier-like queries drop their separators. Room schema v3 (destructive, refilled by the next full sync). Sync: a page with has_more and no cursor now fails the run instead of applying a partial result and advancing the watermark. Clearing the cache (forget instance) bumps a generation under a write lock that every sync write also takes, so a run in flight can no longer write the old library's rows or watermark after the clear. The book detail ETag cache is concurrent. --- .../com/pinakes/app/data/local/AppDatabase.kt | 5 +- .../com/pinakes/app/data/local/CachedBook.kt | 5 +- .../com/pinakes/app/data/local/CatalogDao.kt | 15 ++-- .../com/pinakes/app/data/local/SearchText.kt | 54 ++++++++++++++ .../app/data/repository/CatalogRepository.kt | 70 +++++++++++++------ .../com/pinakes/app/data/sync/SyncMerge.kt | 22 ++++++ .../java/com/pinakes/app/CatalogDaoTest.kt | 46 +++++++++++- .../com/pinakes/app/staff/SearchTextTest.kt | 37 ++++++++++ .../com/pinakes/app/staff/SyncMergeTest.kt | 17 +++++ 9 files changed, 239 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/data/local/SearchText.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt diff --git a/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt index 2a115f8..d488aa3 100644 --- a/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt +++ b/app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt @@ -8,12 +8,13 @@ import androidx.room.RoomDatabase /** * Local cache database. Everything in it can be rebuilt from the server, so schema changes use * destructive migration: v1 (40-row snapshot) → v2 (whole catalogue + loans + wishlist + sync - * bookkeeping) simply starts empty and the next sync refills it. Excluded from backups + * bookkeeping) → v3 (folded search key) simply starts empty and the next sync refills it + * (the sync watermark lives in the same database, so it restarts with a full pass). Excluded from backups * (`backup_rules.xml`, `data_extraction_rules.xml`): it belongs to one instance and one user. */ @Database( entities = [CachedBook::class, CachedLoan::class, CachedWishlistItem::class, SyncMeta::class], - version = 2, + version = 3, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/pinakes/app/data/local/CachedBook.kt b/app/src/main/java/com/pinakes/app/data/local/CachedBook.kt index c27a23c..0bd54ce 100644 --- a/app/src/main/java/com/pinakes/app/data/local/CachedBook.kt +++ b/app/src/main/java/com/pinakes/app/data/local/CachedBook.kt @@ -18,7 +18,8 @@ import com.pinakes.app.data.model.SyncBook * catalogue, kept current by the delta sync (`GET /catalog/sync`), plus the fields an offline * detail page needs. Ordering is `updatedAt DESC, id DESC` (newest first) or by title. * [updatedAt] is the server's `Y-m-d H:i:s` string, which sorts chronologically as text; - * rows cached from a plain search page (older servers) keep it empty. + * rows cached from a plain search page (older servers) keep it empty. [searchKey] (schema v3) + * makes the offline search accent- and case-insensitive for every script. */ @Entity( tableName = "cached_books", @@ -46,6 +47,8 @@ data class CachedBook( val loanableNow: Boolean = false, val isWanted: Boolean = false, val updatedAt: String = "", + /** Folded text the offline search matches against (see [SearchText]); derived, never sent. */ + val searchKey: String = SearchText.key(title, subtitle, author, publisher, isbn13, isbn10, ean), ) fun CachedBook.toSummary(): BookSummary = BookSummary( diff --git a/app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt b/app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt index 73a0640..70d6de0 100644 --- a/app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt +++ b/app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt @@ -45,18 +45,17 @@ interface CatalogDao { } /** - * Offline catalogue search. [q] matches title, subtitle, author, publisher and the - * identifiers (LIKE is case-insensitive for ASCII in SQLite); empty strings disable a - * filter. [sort] is `title_asc`, `title_desc` or `newest`. + * Offline catalogue search. [q] is a [SearchText.queryPattern] (folded, LIKE-escaped) + * matched against the folded [CachedBook.searchKey]; [author] and [publisher] are + * LIKE-escaped substrings; empty strings disable a filter. [sort] is `title_asc`, + * `title_desc` or `newest`. */ @Query( """ SELECT * FROM cached_books - WHERE (:q = '' OR title LIKE '%' || :q || '%' OR subtitle LIKE '%' || :q || '%' - OR author LIKE '%' || :q || '%' OR publisher LIKE '%' || :q || '%' - OR isbn13 LIKE '%' || :q || '%' OR isbn10 LIKE '%' || :q || '%' OR ean LIKE '%' || :q || '%') - AND (:author = '' OR author LIKE '%' || :author || '%') - AND (:publisher = '' OR publisher LIKE '%' || :publisher || '%') + WHERE (:q = '' OR searchKey LIKE '%' || :q || '%' ESCAPE '\') + AND (:author = '' OR author LIKE '%' || :author || '%' ESCAPE '\') + AND (:publisher = '' OR publisher LIKE '%' || :publisher || '%' ESCAPE '\') AND (:language = '' OR language = :language) AND (:genre = '' OR genre = :genre) AND (:availableOnly = 0 OR loanableNow = 1 OR copiesAvailable > 0) diff --git a/app/src/main/java/com/pinakes/app/data/local/SearchText.kt b/app/src/main/java/com/pinakes/app/data/local/SearchText.kt new file mode 100644 index 0000000..1151036 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/local/SearchText.kt @@ -0,0 +1,54 @@ +package com.pinakes.app.data.local + +import java.text.Normalizer +import java.util.Locale + +/** + * Text folding for the offline search (SQLite LIKE only folds ASCII case). Pure, unit-tested. + * + * Rows store a [key]: title, subtitle, author, publisher and identifiers, lower-cased with + * accents removed (NFD + drop combining marks), identifiers without dashes or spaces. A query + * goes through [queryPattern], folded the same way and escaped for `LIKE … ESCAPE '\'`. + */ +object SearchText { + private val COMBINING = Regex("\\p{M}+") + private val ID_SEPARATORS = Regex("[\\s-]") + private val IDENTIFIER_LIKE = Regex("^[0-9Xx\\s-]+$") + + /** Lower-case, accent-free form of [text] ("Città" → "citta", "Ærø" keeps æ/ø, ß stays). */ + fun fold(text: String?): String = + Normalizer.normalize(text.orEmpty(), Normalizer.Form.NFD).replace(COMBINING, "").lowercase(Locale.ROOT) + + /** The stored search key of one catalogue row. */ + fun key( + title: String?, + subtitle: String?, + author: String?, + publisher: String?, + isbn13: String?, + isbn10: String?, + ean: String?, + ): String = listOf( + fold(title), fold(subtitle), fold(author), fold(publisher), + identifier(isbn13), identifier(isbn10), identifier(ean), + ).filter { it.isNotEmpty() }.joinToString("\u001F") + + /** + * The folded, LIKE-escaped query: `\`, `%` and `_` match themselves. A query that looks + * like an identifier (digits, X, dashes, spaces, at least 4 digits) loses its separators, + * so "978-88-04" finds 9788804…. + */ + fun queryPattern(query: String?): String { + val trimmed = query?.trim().orEmpty() + val compact = if (IDENTIFIER_LIKE.matches(trimmed) && trimmed.count { it.isDigit() } >= 4) { + trimmed.replace(ID_SEPARATORS, "") + } else trimmed + return escapeLike(fold(compact)) + } + + /** Escape the LIKE metacharacters for `ESCAPE '\'`. */ + fun escapeLike(value: String): String = + value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + private fun identifier(value: String?): String = value.orEmpty().replace(ID_SEPARATORS, "").lowercase(Locale.ROOT) +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt index 50f98e7..e46d8ca 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/CatalogRepository.kt @@ -1,6 +1,7 @@ package com.pinakes.app.data.repository import com.pinakes.app.data.local.CatalogDao +import com.pinakes.app.data.local.SearchText import com.pinakes.app.data.local.SyncMeta import com.pinakes.app.data.local.SyncMetaDao import com.pinakes.app.data.local.toCached @@ -21,6 +22,9 @@ import com.pinakes.app.data.sync.SyncMerge import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import com.pinakes.app.data.sync.SyncContinuation +import java.util.concurrent.ConcurrentHashMap /** Filters for a catalog search; nulls are omitted from the query. */ data class SearchFilters( @@ -56,8 +60,19 @@ class CatalogRepository( /** Serialises sync runs: the foreground observer, Home and the worker may all ask at once. */ private val syncMutex = Mutex() - // Small in-memory ETag cache for book detail: id -> (etag, payload). - private val detailCache = HashMap>() + /** + * Guards every cache write and [clearCache]. A sync fetches without it, then applies its + * result under it only if [cacheGeneration] is unchanged, so a run that was in flight when + * the instance was forgotten can never write the old library's rows or watermark back. + */ + private val writeMutex = Mutex() + + @Volatile + private var cacheGeneration = 0 + + // Small in-memory ETag cache for book detail: id -> (etag, payload). Concurrent: detail + // screens, the sync and clearCache() touch it from different coroutines. + private val detailCache = ConcurrentHashMap>() /** * Reactive cached catalog snapshot (offline-first). Emits whatever Room holds — @@ -76,9 +91,12 @@ class CatalogRepository( * payloads can never surface under library B. */ suspend fun clearCache() { - detailCache.clear() - catalogDao.clear() - syncMeta.clear() + writeMutex.withLock { + cacheGeneration++ + detailCache.clear() + catalogDao.clear() + syncMeta.clear() + } } /** Transitional alias: every caller now gets the delta sync. */ @@ -97,6 +115,7 @@ class CatalogRepository( suspend fun syncCatalog(): ApiResult { if (!syncMutex.tryLock()) return ApiResult.Success(Unit) try { + val generation = cacheGeneration val since = syncMeta.get(SyncMeta.CATALOG_SERVER_TIME) val lastFullAt = syncMeta.get(SyncMeta.CATALOG_LAST_FULL_AT)?.toLongOrNull() val requestFull = SyncMerge.shouldForceFull(since, lastFullAt, nowMs()) @@ -115,12 +134,16 @@ class CatalogRepository( when (res) { is ApiResult.Success -> { pages += res.data - val next = res.meta?.nextCursor?.takeIf { it.isNotBlank() } - val more = res.meta?.hasMore ?: (next != null) - cursor = if (more) next else null + cursor = when (val c = SyncMerge.continuation(res.meta?.hasMore, res.meta?.nextCursor)) { + is SyncContinuation.Next -> c.cursor + SyncContinuation.Done -> null + // Never apply a partial run nor advance the watermark past it. + SyncContinuation.Broken -> + return ApiResult.Failure(ErrorCodes.SERVER_ERROR, "Catalogue sync page without a cursor", 0) + } } is ApiResult.Failure -> { - return if (res.httpStatus == 404 && pages.isEmpty()) legacySnapshot() else res + return if (res.httpStatus == 404 && pages.isEmpty()) legacySnapshot(generation) else res } } } while (cursor != null && ++guard < MAX_SYNC_PAGES) @@ -130,13 +153,18 @@ class CatalogRepository( } val result = SyncMerge.merge(pages, requestedFull = requestFull) - if (result.fullResync) { - catalogDao.replaceAll(result.upserts.map { it.toCached() }) - syncMeta.put(SyncMeta(SyncMeta.CATALOG_LAST_FULL_AT, nowMs().toString())) - } else { - catalogDao.applyDelta(result.upserts.map { it.toCached() }, result.deleteIds) + val rows = result.upserts.map { it.toCached() } + writeMutex.withLock { + // The instance was forgotten (cache cleared) while this run was fetching. + if (generation != cacheGeneration) return ApiResult.Success(Unit) + if (result.fullResync) { + catalogDao.replaceAll(rows) + syncMeta.put(SyncMeta(SyncMeta.CATALOG_LAST_FULL_AT, nowMs().toString())) + } else { + catalogDao.applyDelta(rows, result.deleteIds) + } + result.serverTime?.let { syncMeta.put(SyncMeta(SyncMeta.CATALOG_SERVER_TIME, it)) } } - result.serverTime?.let { syncMeta.put(SyncMeta(SyncMeta.CATALOG_SERVER_TIME, it)) } // Book pages served from memory may now be stale. result.upserts.forEach { detailCache.remove(it.id) } result.deleteIds.forEach { detailCache.remove(it) } @@ -147,10 +175,12 @@ class CatalogRepository( } /** Servers before Mobile API 1.6.0: cache the first unfiltered page, as 1.6.x did. */ - private suspend fun legacySnapshot(): ApiResult = + private suspend fun legacySnapshot(generation: Int): ApiResult = when (val res = search(SearchFilters(), limit = HOME_SHELF_SIZE)) { is ApiResult.Success -> { - catalogDao.replaceAll(res.data.items.map { it.toCached() }) + writeMutex.withLock { + if (generation == cacheGeneration) catalogDao.replaceAll(res.data.items.map { it.toCached() }) + } ApiResult.Success(Unit, res.meta) } is ApiResult.Failure -> res @@ -168,9 +198,9 @@ class CatalogRepository( offset: Int, limit: Int = 20, ): List = catalogDao.search( - q = filters.query?.trim().orEmpty(), - author = filters.author?.trim().orEmpty(), - publisher = filters.publisher?.trim().orEmpty(), + q = SearchText.queryPattern(filters.query), + author = SearchText.escapeLike(filters.author?.trim().orEmpty()), + publisher = SearchText.escapeLike(filters.publisher?.trim().orEmpty()), language = filters.language?.trim().orEmpty(), genre = genreName?.trim().orEmpty(), availableOnly = filters.availableOnly == true, diff --git a/app/src/main/java/com/pinakes/app/data/sync/SyncMerge.kt b/app/src/main/java/com/pinakes/app/data/sync/SyncMerge.kt index ae33b05..0f0e6bd 100644 --- a/app/src/main/java/com/pinakes/app/data/sync/SyncMerge.kt +++ b/app/src/main/java/com/pinakes/app/data/sync/SyncMerge.kt @@ -21,8 +21,30 @@ data class SyncResult( * fails half-way therefore leaves the old watermark in place and the next run repeats it; * repeated upserts are harmless. */ +/** What to do after one page of `GET /catalog/sync`. */ +sealed interface SyncContinuation { + data class Next(val cursor: String) : SyncContinuation + data object Done : SyncContinuation + /** `has_more` without a usable cursor: the run cannot be completed and must not be applied. */ + data object Broken : SyncContinuation +} + object SyncMerge { + /** + * Read one page's `meta`. `has_more: true` with a blank cursor is a broken run (applying + * it would drop the remaining pages and advance the watermark past them). Servers that omit + * `has_more` page as long as they send a cursor. + */ + fun continuation(hasMore: Boolean?, nextCursor: String?): SyncContinuation { + val cursor = nextCursor?.takeIf { it.isNotBlank() } + return when (hasMore) { + true -> if (cursor != null) SyncContinuation.Next(cursor) else SyncContinuation.Broken + false -> SyncContinuation.Done + null -> if (cursor != null) SyncContinuation.Next(cursor) else SyncContinuation.Done + } + } + /** The client forces an unfiltered pass at least this often (link-only edits on the server). */ const val FULL_RESYNC_INTERVAL_MS: Long = 7L * 24 * 60 * 60 * 1000 diff --git a/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt index 7f6256a..8177a47 100644 --- a/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt +++ b/app/src/test/java/com/pinakes/app/CatalogDaoTest.kt @@ -5,6 +5,7 @@ import androidx.test.core.app.ApplicationProvider import com.pinakes.app.data.local.AppDatabase import com.pinakes.app.data.local.CachedBook import com.pinakes.app.data.local.CatalogDao +import com.pinakes.app.data.local.SearchText import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.junit.After @@ -47,7 +48,10 @@ class CatalogDaoTest { q: String = "", author: String = "", publisher: String = "", language: String = "", genre: String = "", availableOnly: Boolean = false, sort: String = "title_asc", limit: Int = 50, offset: Int = 0, - ) = dao.search(q, author, publisher, language, genre, availableOnly, sort, limit, offset).map { it.id } + ) = dao.search( + SearchText.queryPattern(q), SearchText.escapeLike(author), SearchText.escapeLike(publisher), + language, genre, availableOnly, sort, limit, offset, + ).map { it.id } @Before fun setup() { @@ -168,4 +172,44 @@ class CatalogDaoTest { dao.clear() assertEquals(0, dao.count()) } + + @Test + fun searchIgnoresAccentsAndCaseBeyondAscii() = runBlocking { + dao.upsertAll( + listOf( + book(1, title = "La città invisibile"), + book(2, title = "Über den Fluss", author = "Kästner"), + book(3, title = "Ærø"), + ) + ) + assertEquals(listOf(1), search(q = "CITTA")) + assertEquals(listOf(1), search(q = "Città")) + assertEquals(listOf(2), search(q = "uber")) + assertEquals(listOf(2), search(q = "kastner")) + assertEquals(listOf(3), search(q = "ærø")) // not decomposable: still case-folded + assertEquals(listOf(3), search(q = "ÆRØ")) + } + + @Test + fun likeWildcardsInTheQueryMatchThemselves() = runBlocking { + dao.upsertAll(listOf(book(1, title = "100% Cotone"), book(2, title = "1000 Cotone"), book(3, title = "snake_case"), book(4, title = "snakeXcase"))) + assertEquals(listOf(1), search(q = "100%")) + assertEquals(listOf(3), search(q = "snake_case")) + assertEquals(emptyList(), search(q = "\\")) + assertEquals(listOf(3), search(author = "", q = "e_c")) + } + + @Test + fun isbnQueryWithDashesOrSpacesMatchesTheCompactIdentifier() = runBlocking { + dao.upsertAll(listOf(book(1, isbn13 = "9788804668237"), book(2, isbn13 = "978-0-14-018647-5"))) + assertEquals(listOf(1), search(q = "978-88-04-66823-7")) + assertEquals(listOf(1), search(q = "978 88 04")) + assertEquals(listOf(2), search(q = "9780140186475")) // stored with dashes, still found + } + + @Test + fun authorFilterEscapesWildcards() = runBlocking { + dao.upsertAll(listOf(book(1, author = "A_B"), book(2, author = "AxB"))) + assertEquals(listOf(1), search(author = "A_B")) + } } diff --git a/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt b/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt new file mode 100644 index 0000000..8f892a5 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/SearchTextTest.kt @@ -0,0 +1,37 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.local.SearchText +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SearchTextTest { + + @Test fun foldRemovesAccentsAndCase() { + assertEquals("citta perche", SearchText.fold("Città Perché")) + assertEquals("uber kastner", SearchText.fold("Über Kästner")) + assertEquals("ærø", SearchText.fold("ÆRØ")) + assertEquals("", SearchText.fold(null)) + } + + @Test fun keyHoldsTextAndCompactIdentifiers() { + val key = SearchText.key("La Città", null, "Calvino", "Einaudi", "978-88-04-66823-7", null, null) + assertTrue(key.contains("la citta")) + assertTrue(key.contains("calvino")) + assertTrue(key.contains("9788804668237")) + } + + @Test fun queryPatternEscapesLikeMetacharacters() { + assertEquals("100\\%", SearchText.queryPattern("100%")) + assertEquals("a\\_b", SearchText.queryPattern("a_b")) + assertEquals("c:\\\\x", SearchText.queryPattern("C:\\X")) + } + + @Test fun identifierLikeQueriesLoseSeparators() { + assertEquals("9788804668237", SearchText.queryPattern(" 978-88-04-66823-7 ")) + assertEquals("080442957x", SearchText.queryPattern("0-8044-2957-X")) + // Short numbers and mixed text keep their spaces and dashes. + assertEquals("1-2", SearchText.queryPattern("1-2")) + assertEquals("harry potter 7", SearchText.queryPattern("Harry Potter 7")) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/SyncMergeTest.kt b/app/src/test/java/com/pinakes/app/staff/SyncMergeTest.kt index 5210298..400b748 100644 --- a/app/src/test/java/com/pinakes/app/staff/SyncMergeTest.kt +++ b/app/src/test/java/com/pinakes/app/staff/SyncMergeTest.kt @@ -3,6 +3,7 @@ package com.pinakes.app.staff import com.pinakes.app.data.model.SyncBook import com.pinakes.app.data.model.SyncItem import com.pinakes.app.data.model.SyncPage +import com.pinakes.app.data.sync.SyncContinuation import com.pinakes.app.data.sync.SyncMerge import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -85,4 +86,20 @@ class SyncMergeTest { @Test fun clockMovedBackwardsForcesFull() { assertTrue(SyncMerge.shouldForceFull("t", lastFullAtMs = 10 * day, nowMs = 9 * day)) } + + @Test fun continuationFollowsTheCursor() { + assertEquals(SyncContinuation.Next("abc"), SyncMerge.continuation(hasMore = true, nextCursor = "abc")) + assertEquals(SyncContinuation.Done, SyncMerge.continuation(hasMore = false, nextCursor = "abc")) + assertEquals(SyncContinuation.Done, SyncMerge.continuation(hasMore = false, nextCursor = null)) + } + + @Test fun hasMoreWithoutCursorIsBroken() { + assertEquals(SyncContinuation.Broken, SyncMerge.continuation(hasMore = true, nextCursor = null)) + assertEquals(SyncContinuation.Broken, SyncMerge.continuation(hasMore = true, nextCursor = " ")) + } + + @Test fun serversWithoutHasMorePageByCursor() { + assertEquals(SyncContinuation.Next("c"), SyncMerge.continuation(hasMore = null, nextCursor = "c")) + assertEquals(SyncContinuation.Done, SyncMerge.continuation(hasMore = null, nextCursor = "")) + } } From 3db98c56965a7dc607c089004b9ec69e9ca86a82 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 17:24:11 +0200 Subject: [PATCH 13/62] Revoke the staff role only on the API's own forbidden error Any 403 counted as a revoked role, so a WAF or proxy block wiped the stored role and closed the form. ApiResult.Failure now records whether its code came from the server's envelope, and only an envelope error.code of forbidden drops the role. --- .../com/pinakes/app/data/network/ApiResult.kt | 16 ++++++++++++++-- .../app/data/repository/StaffRepository.kt | 9 ++++++--- .../pinakes/app/staff/StaffModelsParsingTest.kt | 13 +++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt b/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt index d259294..67ff477 100644 --- a/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt +++ b/app/src/main/java/com/pinakes/app/data/network/ApiResult.kt @@ -25,6 +25,12 @@ sealed interface ApiResult { val retryAfterSeconds: Long? = null, /** Envelope meta of the error body (e.g. `existing` on a 409 duplicate), when present. */ val meta: Meta? = null, + /** + * True when [code] came from the server's own error envelope, false when it was derived + * from the HTTP status (a proxy/WAF page, an empty body). Decisions such as "the role + * was revoked" must only trust the former. + */ + val fromEnvelope: Boolean = false, ) : ApiResult val isSuccess: Boolean get() = this is Success @@ -120,7 +126,7 @@ suspend fun apiResponse(block: suspend () -> Response>): Pair(it) }.getOrNull() }?.takeIf { it.id > 0 } } -/** 403 on a staff route: either the role guard or app access was switched off. */ -fun ApiResult.Failure.isForbidden(): Boolean = - code == ErrorCodes.FORBIDDEN || (httpStatus == 403 && code != ErrorCodes.APP_ACCESS_DISABLED) +/** + * The server's role guard refused the call: 403 with `error.code == "forbidden"` in the API's + * own envelope. A bare 403 (reverse proxy, WAF, CDN) or `app_access_disabled` is not a role + * change, so it must not drop the stored role nor close the screen. + */ +fun ApiResult.Failure.isForbidden(): Boolean = fromEnvelope && code == ErrorCodes.FORBIDDEN /** 409 `duplicate` (identifier or name already used). */ fun ApiResult.Failure.isDuplicate(): Boolean = diff --git a/app/src/test/java/com/pinakes/app/staff/StaffModelsParsingTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffModelsParsingTest.kt index 6f4cf61..6479eec 100644 --- a/app/src/test/java/com/pinakes/app/staff/StaffModelsParsingTest.kt +++ b/app/src/test/java/com/pinakes/app/staff/StaffModelsParsingTest.kt @@ -10,6 +10,7 @@ import com.pinakes.app.data.model.StaffLookups import com.pinakes.app.data.model.SyncPage import com.pinakes.app.data.model.flatten import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.repository.isForbidden import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -123,4 +124,16 @@ class StaffModelsParsingTest { val f = parseErrorBody("""{"meta":{"reason":"active_circulation"},"error":{"code":"conflict","message":"x"}}""", 409) assertEquals("active_circulation", f.meta?.reason) } + + @Test fun onlyTheApiForbiddenCodeRevokesTheRole() { + val api = parseErrorBody("""{"data":null,"error":{"code":"forbidden","message":"Staff only"}}""", 403) + assertTrue(api.isForbidden()) + // A WAF/proxy page: the status maps to "forbidden" but did not come from the API. + val waf = parseErrorBody("Request blocked", 403) + assertEquals("forbidden", waf.code) + assertFalse(waf.fromEnvelope) + assertFalse(waf.isForbidden()) + assertFalse(parseErrorBody("", 403).isForbidden()) + assertFalse(parseErrorBody("""{"error":{"code":"app_access_disabled","message":"x"}}""", 403).isForbidden()) + } } From a7e7f98c7631463af395895624fe639926a557ac Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 18:06:13 +0200 Subject: [PATCH 14/62] Follow device connectivity and never show raw network errors A ConnectivityObserver (system default-network callback, Hilt singleton) now feeds Home, Search, book detail, Library and Wishlist. When the device goes offline, screens already loaded online hide availability pills, the available-now claim, loan and reservation buttons, the loan calendar, cancel and remove actions at once; when it comes back they reload and the offline catalogue syncs. Unit tests cover each screen state. A NETWORK failure never reaches the UI as its IOException text: resolvedMessage() and the new uiText()/serverMessage() helpers give a localized offline message, Library and Wishlist without a saved copy show an offline empty state, Home shows the same wording, staff screens map their load errors likewise, and the desiderata, archive and article sections of Catalog search are hidden when they fail only for lack of a connection. The author, publisher and genre pickers and lists get their own search placeholders. --- .../com/pinakes/app/PinakesApplication.kt | 11 ++++ .../data/connectivity/ConnectivityObserver.kt | 64 +++++++++++++++++++ .../app/data/sync/CatalogSyncWorker.kt | 1 + .../main/java/com/pinakes/app/di/AppModule.kt | 5 ++ .../com/pinakes/app/ui/common/FailureText.kt | 19 ++++++ .../java/com/pinakes/app/ui/common/UiState.kt | 7 +- .../screens/collections/CollectionsScreens.kt | 9 +-- .../app/ui/screens/detail/BookDetailScreen.kt | 12 ++-- .../ui/screens/detail/BookDetailViewModel.kt | 34 +++++++--- .../pinakes/app/ui/screens/home/HomeScreen.kt | 9 +-- .../app/ui/screens/home/HomeViewModel.kt | 30 ++++++++- .../app/ui/screens/library/LibraryScreen.kt | 21 ++++-- .../ui/screens/library/LibraryViewModel.kt | 45 +++++++++---- .../app/ui/screens/search/SearchScreen.kt | 27 ++++---- .../app/ui/screens/search/SearchViewModel.kt | 42 +++++++++++- .../ui/screens/staff/StaffBookEditScreen.kt | 16 ++++- .../screens/staff/StaffBookEditViewModel.kt | 13 ++-- .../app/ui/screens/staff/StaffBooksScreen.kt | 14 ++-- .../ui/screens/staff/StaffRecordsScreens.kt | 13 ++-- .../ui/screens/staff/StaffTaxonomyScreens.kt | 16 +++-- .../screens/staff/scan/StaffScanViewModel.kt | 6 +- .../app/ui/screens/wishlist/WishlistScreen.kt | 20 ++++-- .../ui/screens/wishlist/WishlistViewModel.kt | 32 ++++++++-- .../app/staff/ConnectivityStateTest.kt | 60 +++++++++++++++++ i18n/de.json | 9 ++- i18n/en.json | 9 ++- i18n/fr.json | 9 ++- i18n/it.json | 9 ++- 28 files changed, 471 insertions(+), 91 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/data/connectivity/ConnectivityObserver.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/common/FailureText.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/ConnectivityStateTest.kt diff --git a/app/src/main/java/com/pinakes/app/PinakesApplication.kt b/app/src/main/java/com/pinakes/app/PinakesApplication.kt index 25e6701..05eb2d5 100644 --- a/app/src/main/java/com/pinakes/app/PinakesApplication.kt +++ b/app/src/main/java/com/pinakes/app/PinakesApplication.kt @@ -24,6 +24,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter /** * Application entry point (`@HiltAndroidApp`) plus the Coil image loader. All dependencies @@ -77,6 +79,15 @@ class PinakesApplication : Application(), ImageLoaderFactory { } }) + // Back online after a disconnection: bring the offline catalogue up to date at once + // (screens refresh themselves from the same connectivity signal). + val deps = EntryPointAccessors.fromApplication(this, CatalogSyncWorker.Deps::class.java) + appScope.launch { + deps.connectivity().isOnline.drop(1).filter { it }.collect { + if (deps.session().isLoggedIn()) deps.catalogRepository().syncCatalog() + } + } + // Keep the offline snapshot fresh even when the app isn't opened (every 6h, on a // connected network). Idempotent (KEEP) — safe to call on every process start. CatalogSyncWorker.schedule(this) diff --git a/app/src/main/java/com/pinakes/app/data/connectivity/ConnectivityObserver.kt b/app/src/main/java/com/pinakes/app/data/connectivity/ConnectivityObserver.kt new file mode 100644 index 0000000..fe7626e --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/connectivity/ConnectivityObserver.kt @@ -0,0 +1,64 @@ +package com.pinakes.app.data.connectivity + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Whether the device currently has a network that can carry traffic, from the system default + * network callback (singleton, Hilt-provided). + * + * "A request failed" is not enough to know the app is offline: a screen loaded online keeps + * showing loan buttons and availability after airplane mode is switched on. Screens combine + * [isOnline] with their own request outcome through [com.pinakes.app.ui.common.OfflinePolicy]. + * + * Only `NET_CAPABILITY_INTERNET` is required, not `VALIDATED`: a self-hosted instance on a LAN + * (or the emulator host at 10.0.2.2) is reachable on networks the system never validates. + */ +open class ConnectivityObserver(context: Context?) { + + private val manager = context?.applicationContext?.getSystemService(ConnectivityManager::class.java) + private val _isOnline = MutableStateFlow(currentlyOnline()) + open val isOnline: StateFlow = _isOnline.asStateFlow() + + init { + runCatching { + manager?.registerDefaultNetworkCallback(object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + _isOnline.value = manager.getNetworkCapabilities(network)?.let(::usable) ?: true + } + + override fun onCapabilitiesChanged(network: Network, capabilities: NetworkCapabilities) { + _isOnline.value = usable(capabilities) + } + + override fun onLost(network: Network) { + _isOnline.value = false + } + + override fun onUnavailable() { + _isOnline.value = false + } + }) + } + } + + private fun currentlyOnline(): Boolean { + val m = manager ?: return true + val caps = m.activeNetwork?.let { m.getNetworkCapabilities(it) } ?: return false + return usable(caps) + } + + private fun usable(caps: NetworkCapabilities): Boolean = caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + + /** For tests: a fixed state that never changes on its own. */ + class Fixed(online: Boolean) : ConnectivityObserver(null) { + private val state = MutableStateFlow(online) + override val isOnline: StateFlow = state.asStateFlow() + fun set(online: Boolean) { state.value = online } + } +} diff --git a/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt b/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt index 4d02559..d2b59c1 100644 --- a/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt +++ b/app/src/main/java/com/pinakes/app/data/sync/CatalogSyncWorker.kt @@ -48,6 +48,7 @@ class CatalogSyncWorker( fun wishlistRepository(): WishlistRepository fun featureStore(): FeatureStore fun authRepository(): AuthRepository + fun connectivity(): com.pinakes.app.data.connectivity.ConnectivityObserver } override suspend fun doWork(): Result { diff --git a/app/src/main/java/com/pinakes/app/di/AppModule.kt b/app/src/main/java/com/pinakes/app/di/AppModule.kt index cd6bc31..be24f62 100644 --- a/app/src/main/java/com/pinakes/app/di/AppModule.kt +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -1,6 +1,7 @@ package com.pinakes.app.di import android.content.Context +import com.pinakes.app.data.connectivity.ConnectivityObserver import com.pinakes.app.data.local.AppDatabase import com.pinakes.app.data.local.CatalogDao import com.pinakes.app.data.local.LibraryDao @@ -54,6 +55,10 @@ object AppModule { fun network(@ApplicationContext context: Context, session: SessionStore): NetworkModule = NetworkModule(session, context.cacheDir) + /** System default-network watcher: screens hide loan affordances the moment it drops. */ + @Provides @Singleton + fun connectivity(@ApplicationContext context: Context): ConnectivityObserver = ConnectivityObserver(context) + @Provides @Singleton fun database(@ApplicationContext context: Context): AppDatabase = AppDatabase.get(context) diff --git a/app/src/main/java/com/pinakes/app/ui/common/FailureText.kt b/app/src/main/java/com/pinakes/app/ui/common/FailureText.kt new file mode 100644 index 0000000..dab775e --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/common/FailureText.kt @@ -0,0 +1,19 @@ +package com.pinakes.app.ui.common + +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes + +/** + * The server's own message for a failure, or null when there is none worth showing. A NETWORK + * failure carries the raw IOException text ("Failed to connect to /10.0.2.2:8081"), which must + * never reach the UI: callers fall back to their localized message (offline wording for NETWORK). + */ +fun ApiResult.Failure.serverMessage(): String? = message.takeIf { it.isNotBlank() && code != ErrorCodes.NETWORK } + +/** True for "no connection" failures. */ +val ApiResult.Failure.isOffline: Boolean get() = code == ErrorCodes.NETWORK + +/** The text to show for [this] failure: the server's message, else offline wording or [fallback]. */ +@androidx.compose.runtime.Composable +fun ApiResult.Failure.uiText(@androidx.annotation.StringRes fallback: Int): String = + serverMessage() ?: androidx.compose.ui.res.stringResource(if (isOffline) com.pinakes.app.R.string.offline_generic else fallback) diff --git a/app/src/main/java/com/pinakes/app/ui/common/UiState.kt b/app/src/main/java/com/pinakes/app/ui/common/UiState.kt index 9ae7996..e8506d7 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/UiState.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/UiState.kt @@ -22,9 +22,14 @@ sealed interface UiState { /** True when the [UiState.Success] payload is an empty collection. */ fun UiState>.isEmptyList(): Boolean = this is UiState.Success && data.isEmpty() -/** Resolves an error message: prefers the server [message], falls back to the localized [messageRes]. */ +/** + * Resolves an error message: prefers the server [message], falls back to the localized + * [messageRes]. A NETWORK error always gets the localized offline wording: its message is the + * raw IOException text and is never shown. + */ @androidx.compose.runtime.Composable fun UiState.Error.resolvedMessage(): String = when { + code == com.pinakes.app.data.network.ErrorCodes.NETWORK -> androidx.compose.ui.res.stringResource(com.pinakes.app.R.string.offline_generic) message.isNotBlank() -> message messageRes != null -> androidx.compose.ui.res.stringResource(messageRes) else -> "" diff --git a/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt index 45c6b2f..0e54e63 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/collections/CollectionsScreens.kt @@ -23,6 +23,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pinakes.app.R import com.pinakes.app.data.model.* import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.ui.common.uiText import com.pinakes.app.ui.components.* import com.pinakes.app.ui.screens.bookclub.openWeb import com.pinakes.app.ui.screens.detail.PdfReaderDialog @@ -184,7 +185,7 @@ internal fun DonationContent(state: DonationState, padding: PaddingValues = Padd Text(stringResource(R.string.donation_consent), modifier = Modifier.padding(start = Spacing.sm)) } } if (state.uncertain) item { Text(stringResource(R.string.donation_uncertain), color = MaterialTheme.colorScheme.onSurfaceVariant) } - state.error?.let { error -> item { Text(error.message.ifBlank { stringResource(R.string.standalone_articles_error) }, color = MaterialTheme.colorScheme.error) } } + state.error?.let { error -> item { Text(error.uiText(R.string.standalone_articles_error), color = MaterialTheme.colorScheme.error) } } if (state.bookId != null && state.error?.code in listOf("no_longer_wanted", "not_found") && !state.uncertain) item { SecondaryButton(stringResource(R.string.desiderata_free_offer), onFree, Modifier.fillMaxWidth()) } @@ -241,7 +242,7 @@ private fun CollectionLinks(health: CollectionHealth?) { private fun ListStatus(state: CollectionListState, retry: () -> Unit, emptyLabel: Int) { when { state.loading -> LoadingState(label = stringResource(R.string.collection_loading)) - state.error != null -> ErrorState(state.error.message.ifBlank { stringResource(R.string.standalone_articles_error) }, onRetry = retry) + state.error != null -> ErrorState(state.error.uiText(R.string.standalone_articles_error), onRetry = retry) state.items.isEmpty() -> Text(stringResource(emptyLabel), style = MaterialTheme.typography.bodyLarge) } } @@ -249,7 +250,7 @@ private fun ListStatus(state: CollectionListState, retry: () -> Unit, emp @Composable private fun MoreButton(state: CollectionListState, more: () -> Unit) { if (state.nextCursor != null) { - state.moreError?.let { Text(it.message, color = MaterialTheme.colorScheme.error) } + state.moreError?.let { Text(it.uiText(R.string.standalone_articles_error), color = MaterialTheme.colorScheme.error) } PrimaryButton(stringResource(if (state.moreError != null) R.string.action_retry else R.string.collection_more), more, Modifier.fillMaxWidth(), loading = state.loadingMore) } } @@ -258,7 +259,7 @@ private fun MoreButton(state: CollectionListState, more: () -> Unit) { private fun DetailResult(state: ApiResult?, padding: PaddingValues, retry: () -> Unit, content: @Composable ColumnScope.(T) -> Unit) { when (state) { null -> Box(Modifier.fillMaxSize().padding(padding)) { LoadingState(stringResource(R.string.collection_loading)) } - is ApiResult.Failure -> Box(Modifier.fillMaxSize().padding(padding)) { ErrorState(state.message, onRetry = retry) } + is ApiResult.Failure -> Box(Modifier.fillMaxSize().padding(padding)) { ErrorState(state.uiText(R.string.standalone_articles_error), onRetry = retry) } is ApiResult.Success -> LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(Spacing.lg)) { item { Column(verticalArrangement = Arrangement.spacedBy(Spacing.md)) { content(state.data) } } } 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 0f07172..07c7d63 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 @@ -158,7 +158,7 @@ fun BookDetailScreen( onNavigateUp = onNavigateUp, actions = { // Wishlist toggle is hidden when the wishlist feature is disabled, and offline. - if (features.showWishlist && !state.offline) { + if (features.showWishlist && !state.offlineNow) { IconButton(onClick = vm::toggleWishlist, enabled = !state.wishlistBusy) { Icon( imageVector = if (state.wishlisted) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, @@ -183,14 +183,14 @@ fun BookDetailScreen( reserveBusy = state.reserveBusy, // Offline (cached summary): no loan/reservation affordance at all (availability // cannot be guaranteed), and wishlist and reviews need the server too. - canBorrow = OfflinePolicy.showLoanActions(features.canBorrow, state.offline), - showWishlist = features.showWishlist && !state.offline, - showReviews = features.showReviews && !state.offline, + canBorrow = OfflinePolicy.showLoanActions(features.canBorrow, state.offlineNow), + showWishlist = features.showWishlist && !state.offlineNow, + showReviews = features.showReviews && !state.offlineNow, onReserve = vm::openLoanSheet, onToggleWishlist = vm::toggleWishlist, onFindWorks = onFindWorks, onShowMessage = { msg -> scope.launch { snackbarHost.showSnackbar(msg) } }, - offlineNotice = if (state.offline) stringResource(R.string.offline_actions_unavailable) else null, + offlineNotice = if (state.offlineNow) stringResource(R.string.offline_actions_unavailable) else null, offlineSyncedAt = state.syncedAt, ) } @@ -198,7 +198,7 @@ fun BookDetailScreen( } // Loan/reservation sheet is only ever shown when borrowing is enabled. - if (state.showLoanSheet && OfflinePolicy.showLoanActions(features.canBorrow, state.offline)) { + if (state.showLoanSheet && OfflinePolicy.showLoanActions(features.canBorrow, state.offlineNow)) { LoanRequestSheet( loading = state.availabilityLoading, calendar = state.availability, 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 b82fc53..1f1ef31 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 @@ -7,7 +7,10 @@ import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.BookDetail import com.pinakes.app.data.model.CirculationRequestKind import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.connectivity.ConnectivityObserver import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.ui.common.serverMessage +import kotlinx.coroutines.flow.drop import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.repository.LibraryRepository import com.pinakes.app.data.repository.WishlistRepository @@ -43,7 +46,12 @@ data class BookDetailUiState( val offline: Boolean = false, /** When the offline copy was last synced (server time), for "last known" wording. */ val syncedAt: String? = null, -) + /** Device connectivity: a page loaded online hides its loan actions when it drops. */ + val connected: Boolean = true, +) { + /** Cached page or no connection: no loan/reservation affordance, no current availability. */ + val offlineNow: Boolean get() = offline || !connected +} data class RequestConfirmation( val desiredDate: String, @@ -56,15 +64,25 @@ class BookDetailViewModel @Inject constructor( private val catalog: CatalogRepository, private val library: LibraryRepository, private val wishlist: WishlistRepository, + private val connectivity: ConnectivityObserver, ) : ViewModel() { // The book id arrives as a navigation argument; Hilt populates SavedStateHandle from it. private val bookId: Int = savedStateHandle.get(Routes.ARG_BOOK_ID) ?: 0 - private val _state = MutableStateFlow(BookDetailUiState()) + private val _state = MutableStateFlow(BookDetailUiState(connected = connectivity.isOnline.value)) val state: StateFlow = _state.asStateFlow() - init { load() } + init { + load() + viewModelScope.launch { + connectivity.isOnline.drop(1).collect { online -> + // Offline: close the loan sheet at once (availability can't be guaranteed). + _state.update { it.copy(connected = online, showLoanSheet = it.showLoanSheet && online) } + if (online && (_state.value.offline || _state.value.content is UiState.Error)) load() + } + } + } fun load() { _state.update { it.copy(content = UiState.Loading) } @@ -90,7 +108,7 @@ class BookDetailViewModel @Inject constructor( } fun toggleWishlist() { - if (_state.value.wishlistBusy || _state.value.offline) return + if (_state.value.wishlistBusy || _state.value.offlineNow) return val nowWishlisted = !_state.value.wishlisted _state.update { it.copy(wishlistBusy = true) } viewModelScope.launch { @@ -105,7 +123,7 @@ class BookDetailViewModel @Inject constructor( ) } is ApiResult.Failure -> _state.update { - if (res.message.isNotBlank()) it.copy(wishlistBusy = false, snackbar = res.message, snackbarRes = null) + if (res.serverMessage() != null) it.copy(wishlistBusy = false, snackbar = res.serverMessage(), snackbarRes = null) else it.copy(wishlistBusy = false, snackbar = null, snackbarRes = R.string.snackbar_wishlist_update_error) } } @@ -118,7 +136,7 @@ class BookDetailViewModel @Inject constructor( * picker and flags [BookDetailUiState.availabilityFallback]. */ fun openLoanSheet() { - if (_state.value.offline) return + if (_state.value.offlineNow) return _state.update { it.copy(showLoanSheet = true, availabilityLoading = true, availabilityFallback = false, availability = null) } viewModelScope.launch { when (val res = catalog.bookAvailability(bookId)) { @@ -166,8 +184,8 @@ class BookDetailViewModel @Inject constructor( when { res.code == ErrorCodes.CONFLICT -> _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = R.string.snackbar_request_conflict) } - res.message.isNotBlank() -> - _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = res.message, snackbarRes = null) } + res.serverMessage() != null -> + _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = res.serverMessage(), snackbarRes = null) } else -> _state.update { it.copy(reserveBusy = false, showLoanSheet = false, snackbar = null, snackbarRes = R.string.snackbar_request_error) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt index 9a19fae..21cad75 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeScreen.kt @@ -129,7 +129,8 @@ internal fun HomeContent( } HomePhase.Error -> ErrorState( - message = state.error?.takeIf(String::isNotBlank) ?: stringResource(R.string.home_error), + message = if (state.offlineError) stringResource(R.string.offline_no_copy) + else state.error?.takeIf(String::isNotBlank) ?: stringResource(R.string.home_error), onRetry = onRetry, ) @@ -155,15 +156,15 @@ internal fun HomeContent( item { CollectionDestinations(onOpenPeriodicals, onOpenDesiderata, onOpenArchives, onOpenArticles) } item { Box(Modifier.padding(horizontal = Spacing.lg)) { - // A cached shelf is labelled "recently added": its availability may be stale. - SectionHeader(showSeeAll = true, catalogueMode = catalogueMode || state.fromCache, onSeeAll = onBrowseCatalog, sort = state.sort, onSort = onSort) + // A cached or offline shelf is labelled "recently added": its availability may be stale. + SectionHeader(showSeeAll = true, catalogueMode = catalogueMode || !state.availabilityCurrent, onSeeAll = onBrowseCatalog, sort = state.sort, onSort = onSort) } } items(state.available.chunked(2), key = { row -> row.joinToString("-") { it.id.toString() } }) { row -> Row(Modifier.padding(horizontal = Spacing.lg, vertical = 8.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) { row.forEach { book -> BookCardGrid(title = book.title, author = book.authorsLabel, coverUrl = book.coverUrl, - status = if (state.fromCache) null else book.availabilityStatus(), subtitle = book.subtitle, publisher = book.publisher, + status = if (state.availabilityCurrent) book.availabilityStatus() else null, subtitle = book.subtitle, publisher = book.publisher, year = book.year?.toString(), mediaType = book.mediaType, onClick = { onBookClick(book.id) }, modifier = Modifier.weight(1f)) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt index 9c64d09..d8e9699 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt @@ -5,7 +5,9 @@ import com.pinakes.app.ui.screens.search.BookSort import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.BookSummary +import com.pinakes.app.data.connectivity.ConnectivityObserver import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.repository.SearchFilters import com.pinakes.app.data.store.FeatureStore @@ -20,6 +22,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -33,7 +36,14 @@ data class HomeUiState( val sort: BookSort = BookSort.NEWEST, /** The shelf comes from the offline cache: no availability pills, no "available now" claim. */ val fromCache: Boolean = false, + /** Device connectivity (ConnectivityObserver): a shelf loaded online stops being current when it drops. */ + val connected: Boolean = true, + /** The last refresh failed for lack of a connection (localized message, never the raw exception). */ + val offlineError: Boolean = false, ) { + /** Availability on the shelf may be presented as current only online and from the server. */ + val availabilityCurrent: Boolean get() = connected && !fromCache + val isEmpty: Boolean get() = !loading && error == null && available.isEmpty() } @@ -43,6 +53,7 @@ class HomeViewModel @Inject constructor( private val session: SessionStore, private val features: FeatureStore, private val saved: SavedStateHandle, + private val connectivity: ConnectivityObserver, ) : ViewModel() { private val _state = MutableStateFlow( @@ -59,8 +70,20 @@ class HomeViewModel @Inject constructor( private var generation = 0 init { + _state.update { it.copy(connected = connectivity.isOnline.value) } observeCache() observeCatalogueMode() + observeConnectivity() + } + + /** Going offline hides availability at once; coming back refreshes the shelf (and the sync). */ + private fun observeConnectivity() { + viewModelScope.launch { + connectivity.isOnline.drop(1).collect { online -> + _state.update { it.copy(connected = online) } + if (online) refresh() + } + } } /** @@ -145,15 +168,18 @@ class HomeViewModel @Inject constructor( is ApiResult.Success -> { hasFreshShelf = true _state.update { - it.copy(available = res.data.items, loading = false, error = null, fromCache = false) + it.copy(available = res.data.items, loading = false, error = null, fromCache = false, offlineError = false) } } is ApiResult.Failure -> { val hasCache = catalog.hasCachedCatalog() + val offline = res.code == ErrorCodes.NETWORK _state.update { it.copy( loading = false, - error = if (hasCache && _state.value.available.isNotEmpty()) null else res.message, + // Never the raw IOException text: offline gets a localized message. + error = if (hasCache && _state.value.available.isNotEmpty()) null else if (offline) "" else res.message, + offlineError = offline, ) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt index 11861ea..6d81f65 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt @@ -35,7 +35,8 @@ import com.pinakes.app.data.model.LoanItem import com.pinakes.app.data.model.ReservationItem import com.pinakes.app.ui.common.DateFormat import com.pinakes.app.ui.common.StatusMapping -import com.pinakes.app.ui.common.OfflinePolicy +import com.pinakes.app.data.network.ErrorCodes +import androidx.compose.material.icons.outlined.CloudOff import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.ConfirmDialog @@ -86,10 +87,20 @@ fun LibraryScreen(onBookClick: (Int) -> Unit) { ) { when (val content = state.content) { is UiState.Loading -> LoadingState(label = stringResource(R.string.library_loading)) - is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Error -> if (content.code == ErrorCodes.NETWORK) { + EmptyState( + title = stringResource(R.string.offline_no_copy_title), + subtitle = stringResource(R.string.offline_no_copy), + icon = Icons.Outlined.CloudOff, + actionLabel = stringResource(R.string.action_retry), + onAction = vm::refresh, + ) + } else { + ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + } is UiState.Success -> Column(Modifier.fillMaxSize()) { val data = content.data - if (state.offline) { + if (state.offline || !state.connected) { InlineErrorBanner( message = stringResource(R.string.offline_lists_cached), onRetry = vm::refresh, @@ -104,7 +115,7 @@ fun LibraryScreen(onBookClick: (Int) -> Unit) { cancelingId = state.cancelingId, onBookClick = onBookClick, onCancel = { confirmCancel = CancelTarget(it, isLoan = true) }, - actionsEnabled = OfflinePolicy.showLoanActions(canBorrow = true, offline = state.offline), + actionsEnabled = state.showActions, ) 1 -> LoanList( loans = data.loans.history, @@ -117,7 +128,7 @@ fun LibraryScreen(onBookClick: (Int) -> Unit) { cancelingId = state.cancelingId, onBookClick = onBookClick, onCancel = { confirmCancel = CancelTarget(it, isLoan = false) }, - actionsEnabled = OfflinePolicy.showLoanActions(canBorrow = true, offline = state.offline), + actionsEnabled = state.showActions, ) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt index c31cc06..5ff5ff5 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryViewModel.kt @@ -5,7 +5,12 @@ import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.LoansData import com.pinakes.app.data.model.ReservationItem import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.connectivity.ConnectivityObserver import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.ui.common.OfflinePolicy +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage +import kotlinx.coroutines.flow.drop import com.pinakes.app.data.repository.LibraryRepository import com.pinakes.app.R import com.pinakes.app.ui.common.UiState @@ -32,15 +37,31 @@ data class LibraryUiState( val snackbarRes: Int? = null, /** Showing the cached lists because the server is unreachable: actions are disabled. */ val offline: Boolean = false, -) + /** Device connectivity: cancel actions disappear the moment it drops. */ + val connected: Boolean = true, +) { + /** Loan/reservation actions need a live server: hidden when offline either way. */ + val showActions: Boolean get() = OfflinePolicy.showLoanActions(canBorrow = true, offline = offline || !connected) +} @HiltViewModel -class LibraryViewModel @Inject constructor(private val library: LibraryRepository) : ViewModel() { +class LibraryViewModel @Inject constructor( + private val library: LibraryRepository, + private val connectivity: ConnectivityObserver, +) : ViewModel() { - private val _state = MutableStateFlow(LibraryUiState()) + private val _state = MutableStateFlow(LibraryUiState(connected = connectivity.isOnline.value)) val state: StateFlow = _state.asStateFlow() - init { load(initial = true) } + init { + load(initial = true) + viewModelScope.launch { + connectivity.isOnline.drop(1).collect { online -> + _state.update { it.copy(connected = online) } + if (online && (_state.value.offline || _state.value.content is UiState.Error)) load(initial = false) + } + } + } fun refresh() = load(initial = false) @@ -85,7 +106,9 @@ class LibraryViewModel @Inject constructor(private val library: LibraryRepositor _state.update { it.copy( content = if (it.content is UiState.Success) it.content - else UiState.Error(f.message, f.code, R.string.library_error_load), + // Offline with nothing saved: a localized empty state, never the raw exception. + else if (f.isOffline) UiState.Error("", ErrorCodes.NETWORK, R.string.offline_no_copy) + else UiState.Error(f.message, f.code, R.string.library_error_load), refreshing = false, snackbarRes = if (it.content is UiState.Success) R.string.library_refresh_error else null, ) @@ -95,7 +118,7 @@ class LibraryViewModel @Inject constructor(private val library: LibraryRepositor } fun cancelReservation(id: Int) { - if (_state.value.cancelingId != null || _state.value.offline) return + if (_state.value.cancelingId != null || !_state.value.showActions) return _state.update { it.copy(cancelingId = id) } viewModelScope.launch { when (val res = library.cancelReservation(id)) { @@ -107,8 +130,8 @@ class LibraryViewModel @Inject constructor(private val library: LibraryRepositor when { res.code == ErrorCodes.CONFLICT || res.httpStatus == 409 -> _state.update { it.copy(cancelingId = null, snackbar = null, snackbarRes = R.string.snackbar_reservation_cancel_conflict) } - res.message.isNotBlank() -> - _state.update { it.copy(cancelingId = null, snackbar = res.message, snackbarRes = null) } + res.serverMessage() != null -> + _state.update { it.copy(cancelingId = null, snackbar = res.serverMessage(), snackbarRes = null) } else -> _state.update { it.copy(cancelingId = null, snackbar = null, snackbarRes = R.string.snackbar_reservation_cancel_error) } } @@ -118,7 +141,7 @@ class LibraryViewModel @Inject constructor(private val library: LibraryRepositor } fun cancelLoan(id: Int) { - if (_state.value.cancelingId != null || _state.value.offline) return + if (_state.value.cancelingId != null || !_state.value.showActions) return _state.update { it.copy(cancelingId = id) } viewModelScope.launch { when (val res = library.cancelLoan(id)) { @@ -130,8 +153,8 @@ class LibraryViewModel @Inject constructor(private val library: LibraryRepositor when { res.code == ErrorCodes.CONFLICT || res.httpStatus == 409 -> _state.update { it.copy(cancelingId = null, snackbar = null, snackbarRes = R.string.snackbar_loan_cancel_conflict) } - res.message.isNotBlank() -> - _state.update { it.copy(cancelingId = null, snackbar = res.message, snackbarRes = null) } + res.serverMessage() != null -> + _state.update { it.copy(cancelingId = null, snackbar = res.serverMessage(), snackbarRes = null) } else -> _state.update { it.copy(cancelingId = null, snackbar = null, snackbarRes = R.string.snackbar_loan_cancel_error) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt index 203c766..a8656b6 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt @@ -49,8 +49,11 @@ import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.common.OfflinePolicy +import com.pinakes.app.ui.common.uiText import com.pinakes.app.ui.components.BookCover import com.pinakes.app.ui.screens.periodicals.StandaloneArticleRow import com.pinakes.app.R @@ -86,7 +89,9 @@ fun SearchScreen(onBookClick: (Int) -> Unit, initialQuery: String? = null, onQue else collectionVm.search(state.appliedFilters, features) } androidx.compose.runtime.LaunchedEffect(initialAuthor) { initialAuthor?.let { vm.setAuthorIdentity(it, initialAuthorId?.takeIf { id -> id > 0 }); vm.applyFilters() } } - val otherResults = (showArticles && (articles.items.isNotEmpty() || articles.error != null)) || (showWanted && (wanted.items.isNotEmpty() || wanted.error != null)) || (showArchives && (archives.items.isNotEmpty() || archives.error != null)) + // A collection that failed only for lack of a connection is hidden, not shown as an error. + fun ApiResult.Failure?.visible() = this != null && code != ErrorCodes.NETWORK + val otherResults = (showArticles && (articles.items.isNotEmpty() || articles.error.visible())) || (showWanted && (wanted.items.isNotEmpty() || wanted.error.visible())) || (showArchives && (archives.items.isNotEmpty() || archives.error.visible())) val otherLoading = (showArticles && articles.loading) || (showWanted && wanted.loading) || (showArchives && archives.loading) var gridView by rememberSaveable { mutableStateOf(true) } androidx.compose.runtime.LaunchedEffect(initialQuery) { @@ -167,9 +172,9 @@ fun SearchScreen(onBookClick: (Int) -> Unit, initialQuery: String? = null, onQue modifier = Modifier.padding(vertical = Spacing.sm)) } - if (state.offline) { + if (state.offline || !state.connected) { InlineErrorBanner( - message = stringResource(R.string.offline_results), + message = stringResource(if (state.offline) R.string.offline_results else R.string.offline_availability_hidden), onRetry = vm::retry, modifier = Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.xs), ) @@ -260,22 +265,22 @@ fun SearchScreen(onBookClick: (Int) -> Unit, initialQuery: String? = null, onQue CatalogViewToggle(gridView, onChange = { gridView = it }) } } - if (showArticles) { + if (showArticles && (articles.items.isNotEmpty() || articles.loading || articles.error.visible())) { item { Text(stringResource(R.string.standalone_articles_title), style = MaterialTheme.typography.titleMedium) } if (articles.loading) item { CircularProgressIndicator(Modifier.size(20.dp)) } - articles.error?.let { error -> item { Text(error.message.ifBlank { stringResource(if (error.code == "upgrade_required") R.string.collection_upgrade_required else R.string.standalone_articles_error) }, color = MaterialTheme.colorScheme.error) } } + articles.error?.takeUnless { it.code == ErrorCodes.NETWORK }?.let { error -> item { Text(error.uiText(if (error.code == "upgrade_required") R.string.collection_upgrade_required else R.string.standalone_articles_error), color = MaterialTheme.colorScheme.error) } } items(articles.items, key = { "article-${it.id}" }) { article -> StandaloneArticleRow(article) { onArticleClick(article.id) } } if (articles.nextCursor != null) item { TextButton(collectionVm::moreArticles, enabled = !articles.loadingMore) { Text(stringResource(R.string.collection_more)) } } } - if (showWanted) { + if (showWanted && (wanted.items.isNotEmpty() || wanted.loading || wanted.error.visible())) { item { Text(stringResource(R.string.desiderata_title), style = MaterialTheme.typography.titleMedium) } - wanted.error?.let { error -> item { Text(error.message.ifBlank { stringResource(if (error.code == "upgrade_required") R.string.collection_upgrade_required else R.string.standalone_articles_error) }, color = MaterialTheme.colorScheme.error) } } + wanted.error?.takeUnless { it.code == ErrorCodes.NETWORK }?.let { error -> item { Text(error.uiText(if (error.code == "upgrade_required") R.string.collection_upgrade_required else R.string.standalone_articles_error), color = MaterialTheme.colorScheme.error) } } items(wanted.items, key = { "wanted-${it.id}" }) { book -> SearchCollectionRow(book.title, book.author.orEmpty(), book.coverUrl, stringResource(R.string.desiderata_wanted)) { onWantedClick(book.id) } } if (wanted.nextCursor != null) item { TextButton(collectionVm::moreWanted, enabled = !wanted.loadingMore) { Text(stringResource(R.string.collection_more)) } } } - if (showArchives) { + if (showArchives && (archives.items.isNotEmpty() || archives.loading || archives.error.visible())) { item { Text(stringResource(R.string.archives_title), style = MaterialTheme.typography.titleMedium) } - archives.error?.let { error -> item { Text(error.message.ifBlank { stringResource(if (error.code == "upgrade_required") R.string.collection_upgrade_required else R.string.standalone_articles_error) }, color = MaterialTheme.colorScheme.error) } } + archives.error?.takeUnless { it.code == ErrorCodes.NETWORK }?.let { error -> item { Text(error.uiText(if (error.code == "upgrade_required") R.string.collection_upgrade_required else R.string.standalone_articles_error), color = MaterialTheme.colorScheme.error) } } items(archives.items, key = { "archive-${it.id}" }) { record -> SearchCollectionRow(record.title, record.referenceCode, record.coverUrl, record.datesLabel.orEmpty()) { onArchiveClick(record.id) } } if (archives.nextCursor != null) item { TextButton(collectionVm::moreArchives, enabled = !archives.loadingMore) { Text(stringResource(R.string.collection_more)) } } } @@ -284,7 +289,7 @@ fun SearchScreen(onBookClick: (Int) -> Unit, initialQuery: String? = null, onQue Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { row.forEach { book -> BookCardGrid(title = book.title, author = book.authorsLabel, coverUrl = book.coverUrl, - status = if (OfflinePolicy.showCurrentAvailability(state.offline)) book.availabilityStatus() else null, subtitle = book.subtitle, publisher = book.publisher, + status = if (OfflinePolicy.showCurrentAvailability(!state.availabilityCurrent)) book.availabilityStatus() else null, subtitle = book.subtitle, publisher = book.publisher, year = book.year?.toString(), mediaType = book.mediaType, onClick = { onBookClick(book.id) }, modifier = Modifier.weight(1f)) } @@ -294,7 +299,7 @@ fun SearchScreen(onBookClick: (Int) -> Unit, initialQuery: String? = null, onQue } else { items(state.items, key = { it.id }) { book -> BookCard(title = book.title, author = book.authorsLabel, coverUrl = book.coverUrl, - status = if (OfflinePolicy.showCurrentAvailability(state.offline)) book.availabilityStatus() else null, year = book.year?.toString(), publisher = book.publisher, + status = if (OfflinePolicy.showCurrentAvailability(!state.availabilityCurrent)) book.availabilityStatus() else null, year = book.year?.toString(), publisher = book.publisher, subtitle = book.subtitle, mediaType = book.mediaType, onClick = { onBookClick(book.id) }, modifier = Modifier.animateItem()) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt index 0a8ada0..580aa38 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt @@ -8,7 +8,9 @@ import com.pinakes.app.data.model.BookSummary import com.pinakes.app.data.model.CatalogLanguage import com.pinakes.app.data.model.GenreNode import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.connectivity.ConnectivityObserver import com.pinakes.app.data.network.ErrorCodes +import kotlinx.coroutines.flow.drop import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.repository.SearchFilters import dagger.hilt.android.lifecycle.HiltViewModel @@ -63,7 +65,12 @@ data class SearchUiState( /** Offline pagination: rows already shown and whether another page exists. */ val offlineOffset: Int = 0, val offlineHasMore: Boolean = false, + /** Device connectivity: results loaded online lose their availability pills when it drops. */ + val connected: Boolean = true, ) { + /** Availability shown on the results is current only online and from the server. */ + val availabilityCurrent: Boolean get() = connected && !offline + val hasMore: Boolean get() = nextCursor != null || (offline && offlineHasMore) /** How many facet filters (excluding the free-text query) are currently active. */ @@ -97,13 +104,16 @@ internal fun SearchUiState.withQuery(value: String): SearchUiState { } @HiltViewModel -class SearchViewModel @Inject constructor(private val catalog: CatalogRepository) : ViewModel() { +class SearchViewModel @Inject constructor( + private val catalog: CatalogRepository, + private val connectivity: ConnectivityObserver, +) : ViewModel() { private companion object { const val OFFLINE_PAGE = 20 } - private val _state = MutableStateFlow(SearchUiState()) + private val _state = MutableStateFlow(SearchUiState(connected = connectivity.isOnline.value)) val state: StateFlow = _state.asStateFlow() private var searchJob: Job? = null @@ -122,6 +132,13 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository loadLanguages() // Catalog lands on the full listing — browse-all (empty query, no filters). runSearch(reset = true) + viewModelScope.launch { + connectivity.isOnline.drop(1).collect { online -> + _state.update { it.copy(connected = online) } + // Back online after answering from the cache: ask the server again. + if (online && _state.value.offline) runSearch(reset = true) + } + } } private fun loadGenres() { @@ -162,6 +179,13 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository fun submitSearch() { searchJob?.cancel() runSearch(reset = true) + viewModelScope.launch { + connectivity.isOnline.drop(1).collect { online -> + _state.update { it.copy(connected = online) } + // Back online after answering from the cache: ask the server again. + if (online && _state.value.offline) runSearch(reset = true) + } + } } fun toggleFilters() = _state.update { it.copy(filtersOpen = !it.filtersOpen) } @@ -188,6 +212,13 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository if (_state.value.sort == sort) return _state.update { it.copy(sort = sort) } runSearch(reset = true) + viewModelScope.launch { + connectivity.isOnline.drop(1).collect { online -> + _state.update { it.copy(connected = online) } + // Back online after answering from the cache: ask the server again. + if (online && _state.value.offline) runSearch(reset = true) + } + } } /** Run the search with the currently staged facet filters. */ @@ -205,6 +236,13 @@ class SearchViewModel @Inject constructor(private val catalog: CatalogRepository ) } runSearch(reset = true) + viewModelScope.launch { + connectivity.isOnline.drop(1).collect { online -> + _state.update { it.copy(connected = online) } + // Back online after answering from the cache: ask the server again. + if (online && _state.value.offline) runSearch(reset = true) + } + } } fun retry() = runSearch(reset = true) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt index 3af39a1..55e1c50 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt @@ -167,7 +167,8 @@ fun StaffBookEditScreen( when { state.loading -> LoadingState() state.loadError != null -> ErrorState( - message = state.loadError?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff_error_load), + message = if (state.loadOffline) stringResource(R.string.offline_generic) + else state.loadError?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff_error_load), onRetry = vm::load, ) else -> BookForm(state, vm) @@ -513,7 +514,18 @@ private fun PickerSheet(state: StaffBookEditUiState, vm: StaffBookEditViewModel) Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding()) { Text(stringResource(title), style = MaterialTheme.typography.headlineSmall) Spacer(Modifier.height(Spacing.md)) - SearchField(query = state.pickerQuery, onQueryChange = vm::onPickerQuery, modifier = Modifier.fillMaxWidth()) + SearchField( + query = state.pickerQuery, + onQueryChange = vm::onPickerQuery, + placeholder = stringResource( + when (kind) { + PickerKind.AUTHORS -> R.string.staff_search_authors + PickerKind.PUBLISHERS -> R.string.staff_search_publishers + PickerKind.GENRE, PickerKind.SUBGENRE -> R.string.staff_search_genres + } + ), + modifier = Modifier.fillMaxWidth(), + ) Spacer(Modifier.height(Spacing.sm)) LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp)) { val query = state.pickerQuery.trim() diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt index d9a8734..940e278 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt @@ -16,6 +16,8 @@ import com.pinakes.app.data.model.StaffGenreNode import com.pinakes.app.data.model.StaffLookups import com.pinakes.app.data.model.flatten import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.StaffRepository import com.pinakes.app.data.repository.isDuplicate @@ -63,6 +65,7 @@ data class StaffBookEditUiState( val bookId: Int = 0, val loading: Boolean = true, val loadError: String? = null, + val loadOffline: Boolean = false, val original: StaffBook? = null, val form: StaffBookForm = StaffBookForm(), /** Set after the first save attempt so untouched empty fields do not start red. */ @@ -133,7 +136,7 @@ class StaffBookEditViewModel @Inject constructor( } is ApiResult.Failure -> { _state.update { - it.copy(loading = false, forbidden = res.isForbidden(), loadError = res.message.ifBlank { null } ?: "") + it.copy(loading = false, forbidden = res.isForbidden(), loadError = res.serverMessage() ?: "", loadOffline = res.isOffline) } return@launch } @@ -372,7 +375,7 @@ class StaffBookEditViewModel @Inject constructor( ) res.code == ErrorCodes.LOCK_ERROR -> it.copy(saving = false, errorRes = R.string.staff_error_busy) res.code == ErrorCodes.NETWORK -> it.copy(saving = false, errorRes = R.string.staff_error_offline) - else -> it.copy(saving = false, error = res.message.ifBlank { null }, errorRes = R.string.staff_error_save) + else -> it.copy(saving = false, error = res.serverMessage(), errorRes = R.string.staff_error_save) } } } @@ -395,7 +398,7 @@ class StaffBookEditViewModel @Inject constructor( it.copy(saving = false, duplicate = null, event = StaffEditEvent.Saved(existing.id, created = false)) } is ApiResult.Failure -> _state.update { - it.copy(saving = false, forbidden = res.isForbidden(), error = res.message.ifBlank { null }, errorRes = R.string.staff_error_save) + it.copy(saving = false, forbidden = res.isForbidden(), error = res.serverMessage(), errorRes = R.string.staff_error_save) } } } @@ -419,7 +422,7 @@ class StaffBookEditViewModel @Inject constructor( ) } is ApiResult.Failure -> _state.update { - it.copy(addingCopies = false, forbidden = res.isForbidden(), snackbar = res.message.ifBlank { null }, snackbarRes = R.string.staff_error_save) + it.copy(addingCopies = false, forbidden = res.isForbidden(), snackbar = res.serverMessage(), snackbarRes = R.string.staff_error_save) } } } @@ -436,7 +439,7 @@ class StaffBookEditViewModel @Inject constructor( res.isForbidden() -> it.copy(deleting = false, forbidden = true) res.httpStatus == 409 || res.code == ErrorCodes.CONFLICT -> it.copy(deleting = false, snackbarRes = R.string.staff_book_delete_active_circulation) - else -> it.copy(deleting = false, snackbar = res.message.ifBlank { null }, snackbarRes = R.string.staff_error_save) + else -> it.copy(deleting = false, snackbar = res.serverMessage(), snackbarRes = R.string.staff_error_save) } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBooksScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBooksScreen.kt index e7ba143..c594acf 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBooksScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBooksScreen.kt @@ -46,6 +46,8 @@ import androidx.lifecycle.viewModelScope import com.pinakes.app.R import com.pinakes.app.data.model.StaffBookRow import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.StaffRepository import com.pinakes.app.data.repository.isForbidden @@ -76,6 +78,8 @@ data class StaffBooksUiState( val loadingMore: Boolean = false, val refreshing: Boolean = false, val error: String? = null, + /** The load failed for lack of a connection (localized offline text, never the exception). */ + val errorOffline: Boolean = false, val deletingId: Int? = null, val snackbar: String? = null, val snackbarRes: Int? = null, @@ -120,8 +124,9 @@ class StaffBooksViewModel @Inject constructor(private val staff: StaffRepository loading = false, refreshing = false, forbidden = res.isForbidden(), - error = if (it.items.isEmpty()) res.message.ifBlank { null } ?: "" else null, - snackbar = if (it.items.isNotEmpty()) res.message.ifBlank { null } else null, + error = if (it.items.isEmpty()) res.serverMessage() ?: "" else null, + errorOffline = res.isOffline, + snackbar = if (it.items.isNotEmpty()) res.serverMessage() else null, ) } } @@ -165,7 +170,7 @@ class StaffBooksViewModel @Inject constructor(private val staff: StaffRepository it.copy(deletingId = null, snackbarRes = R.string.staff_book_delete_active_circulation) res.code == ErrorCodes.NOT_FOUND -> it.copy(deletingId = null, items = it.items.filterNot { b -> b.id == id }) - else -> it.copy(deletingId = null, snackbar = res.message.ifBlank { null }, snackbarRes = R.string.staff_error_save) + else -> it.copy(deletingId = null, snackbar = res.serverMessage(), snackbarRes = R.string.staff_error_save) } } } @@ -221,7 +226,8 @@ fun StaffBooksScreen(onNavigateUp: () -> Unit, onOpenBook: (Int) -> Unit, onNewB when { state.loading -> LoadingState() state.error != null -> ErrorState( - message = state.error?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff_error_load), + message = if (state.errorOffline) stringResource(R.string.offline_generic) + else state.error?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff_error_load), onRetry = { vm.reload() }, ) state.items.isEmpty() -> EmptyState( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt index c687b60..82e5904 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt @@ -47,6 +47,8 @@ import com.pinakes.app.data.model.IdName import com.pinakes.app.data.model.StaffAuthorWrite import com.pinakes.app.data.model.StaffPublisherWrite import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage import com.pinakes.app.data.repository.StaffRepository import com.pinakes.app.data.repository.isDuplicate import com.pinakes.app.data.repository.isForbidden @@ -102,6 +104,7 @@ data class RecordsUiState( val loading: Boolean = true, val refreshing: Boolean = false, val error: String? = null, + val errorOffline: Boolean = false, val editing: RecordForm? = null, val editLoading: Boolean = false, val saving: Boolean = false, @@ -164,7 +167,7 @@ abstract class RecordsViewModel(protected val staff: StaffRepository, kind: Reco is ApiResult.Success -> it.copy(items = res.data, loading = false, refreshing = false) is ApiResult.Failure -> it.copy( loading = false, refreshing = false, forbidden = res.isForbidden(), - error = res.message.ifBlank { null } ?: "", + error = res.serverMessage() ?: "", errorOffline = res.isOffline, ) } } @@ -180,7 +183,7 @@ abstract class RecordsViewModel(protected val staff: StaffRepository, kind: Reco when (val res = fetch(id)) { is ApiResult.Success -> _state.update { it.copy(editing = res.data, editLoading = false) } is ApiResult.Failure -> _state.update { - it.copy(editLoading = false, forbidden = res.isForbidden(), formError = res.message.ifBlank { null }, formErrorRes = R.string.staff_error_load) + it.copy(editLoading = false, forbidden = res.isForbidden(), formError = res.serverMessage(), formErrorRes = R.string.staff_error_load) } } } @@ -208,7 +211,7 @@ abstract class RecordsViewModel(protected val staff: StaffRepository, kind: Reco when { res.isForbidden() -> it.copy(saving = false, forbidden = true) res.isDuplicate() -> it.copy(saving = false, duplicate = staff.existingEntity(res) ?: IdName(0, form.name)) - else -> it.copy(saving = false, formError = res.message.ifBlank { null }, formErrorRes = R.string.staff_error_save) + else -> it.copy(saving = false, formError = res.serverMessage(), formErrorRes = R.string.staff_error_save) } } } @@ -305,13 +308,15 @@ private fun RecordsScreen(vm: RecordsViewModel, title: String, icon: ImageVector Column(Modifier.fillMaxSize().padding(padding)) { SearchField( query = state.query, onQueryChange = vm::onQueryChange, + placeholder = stringResource(if (state.kind == RecordKind.AUTHOR) R.string.staff_search_authors else R.string.staff_search_publishers), modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg, vertical = Spacing.sm), ) PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize()) { when { state.loading -> LoadingState() state.error != null -> ErrorState( - message = state.error?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff_error_load), + message = if (state.errorOffline) stringResource(R.string.offline_generic) + else state.error?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff_error_load), onRetry = vm::refresh, ) state.items.isEmpty() -> EmptyState( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffTaxonomyScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffTaxonomyScreens.kt index 0197985..7aead8c 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffTaxonomyScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffTaxonomyScreens.kt @@ -56,6 +56,8 @@ import com.pinakes.app.R import com.pinakes.app.data.model.Shelf import com.pinakes.app.data.model.StaffGenreNode import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage import com.pinakes.app.data.repository.StaffRepository import com.pinakes.app.data.repository.isDuplicate import com.pinakes.app.data.repository.isForbidden @@ -88,6 +90,7 @@ data class GenresUiState( val loading: Boolean = true, val refreshing: Boolean = false, val error: String? = null, + val errorOffline: Boolean = false, val draft: GenreDraft? = null, val saving: Boolean = false, val formError: String? = null, @@ -109,7 +112,7 @@ class StaffGenresViewModel @Inject constructor(private val staff: StaffRepositor _state.update { when (val res = staff.genres()) { is ApiResult.Success -> it.copy(tree = res.data, loading = false, refreshing = false) - is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.message.ifBlank { null } ?: "") + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.serverMessage() ?: "", errorOffline = res.isOffline) } } } @@ -142,7 +145,7 @@ class StaffGenresViewModel @Inject constructor(private val staff: StaffRepositor } is ApiResult.Failure -> _state.update { if (res.isForbidden()) it.copy(saving = false, forbidden = true) - else it.copy(saving = false, formError = res.message.ifBlank { null }, formErrorRes = R.string.staff_error_save) + else it.copy(saving = false, formError = res.serverMessage(), formErrorRes = R.string.staff_error_save) } } } @@ -170,7 +173,7 @@ fun StaffGenresScreen(onNavigateUp: () -> Unit) { PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = { vm.load(pull = true) }, modifier = Modifier.fillMaxSize().padding(padding)) { when { state.loading -> LoadingState() - state.error != null -> ErrorState(state.error?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff_error_load), onRetry = { vm.load() }) + state.error != null -> ErrorState(if (state.errorOffline) stringResource(R.string.offline_generic) else state.error?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff_error_load), onRetry = { vm.load() }) state.tree.isEmpty() -> EmptyState( title = stringResource(R.string.staff_records_empty), icon = Icons.Outlined.Category, actionLabel = stringResource(R.string.staff_genre_add), onAction = { vm.startDraft(null) }, @@ -251,6 +254,7 @@ data class ShelvesUiState( val loading: Boolean = true, val refreshing: Boolean = false, val error: String? = null, + val errorOffline: Boolean = false, val draft: ShelfDraft? = null, val saving: Boolean = false, val formError: String? = null, @@ -272,7 +276,7 @@ class StaffShelvesViewModel @Inject constructor(private val staff: StaffReposito _state.update { when (val res = staff.shelves()) { is ApiResult.Success -> it.copy(shelves = res.data, loading = false, refreshing = false) - is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.message.ifBlank { null } ?: "") + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.serverMessage() ?: "", errorOffline = res.isOffline) } } } @@ -312,7 +316,7 @@ class StaffShelvesViewModel @Inject constructor(private val staff: StaffReposito res.isForbidden() -> it.copy(saving = false, forbidden = true) res.isDuplicate() || res.httpStatus == 409 -> it.copy(saving = false, formErrorRes = if (isLevel) R.string.staff_level_exists else R.string.staff_shelf_exists) - else -> it.copy(saving = false, formError = res.message.ifBlank { null }, formErrorRes = R.string.staff_error_save) + else -> it.copy(saving = false, formError = res.serverMessage(), formErrorRes = R.string.staff_error_save) } } } @@ -340,7 +344,7 @@ fun StaffShelvesScreen(onNavigateUp: () -> Unit) { PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = { vm.load(pull = true) }, modifier = Modifier.fillMaxSize().padding(padding)) { when { state.loading -> LoadingState() - state.error != null -> ErrorState(state.error?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff_error_load), onRetry = { vm.load() }) + state.error != null -> ErrorState(if (state.errorOffline) stringResource(R.string.offline_generic) else state.error?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff_error_load), onRetry = { vm.load() }) state.shelves.isEmpty() -> EmptyState( title = stringResource(R.string.staff_records_empty), icon = Icons.Outlined.Inventory2, actionLabel = stringResource(R.string.staff_shelf_add), onAction = vm::startShelf, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffScanViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffScanViewModel.kt index 55fcc14..8cad488 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffScanViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffScanViewModel.kt @@ -9,6 +9,8 @@ import com.pinakes.app.data.model.ExistingBook import com.pinakes.app.data.model.IsbnPreview import com.pinakes.app.data.model.Shelf import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.repository.StaffRepository import com.pinakes.app.data.repository.isDuplicate @@ -202,7 +204,7 @@ class StaffScanViewModel @Inject constructor( it.copy(saving = false, phase = ScanPhase.Duplicate(phase.identifier, staff.existingBook(res) ?: ExistingBook(title = form.title))) } else -> _state.update { - it.copy(saving = false, message = res.message.ifBlank { null }, messageRes = R.string.staff_error_save) + it.copy(saving = false, message = res.serverMessage(), messageRes = R.string.staff_error_save) } } } @@ -220,7 +222,7 @@ class StaffScanViewModel @Inject constructor( is ApiResult.Success -> added(ScanAdded(phase.existing.id, phase.existing.title, null, quantity, false)) is ApiResult.Failure -> _state.update { if (res.isForbidden()) it.copy(saving = false, forbidden = true) - else it.copy(saving = false, message = res.message.ifBlank { null }, messageRes = R.string.staff_error_save) + else it.copy(saving = false, message = res.serverMessage(), messageRes = R.string.staff_error_save) } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt index 34d3974..f2b9223 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistScreen.kt @@ -27,6 +27,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pinakes.app.R import com.pinakes.app.ui.common.OfflinePolicy +import com.pinakes.app.data.network.ErrorCodes +import androidx.compose.material.icons.outlined.CloudOff import com.pinakes.app.ui.common.UiState import com.pinakes.app.ui.common.resolvedMessage import com.pinakes.app.ui.components.AvailabilityStatus @@ -57,7 +59,17 @@ fun WishlistScreen(onBookClick: (Int) -> Unit) { ) { when (val content = state.content) { is UiState.Loading -> LoadingState(label = stringResource(R.string.wishlist_loading)) - is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Error -> if (content.code == ErrorCodes.NETWORK) { + EmptyState( + title = stringResource(R.string.offline_no_copy_title), + subtitle = stringResource(R.string.offline_no_copy), + icon = Icons.Outlined.CloudOff, + actionLabel = stringResource(R.string.action_retry), + onAction = vm::refresh, + ) + } else { + ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + } is UiState.Success -> { if (content.data.isEmpty()) { EmptyState( @@ -71,7 +83,7 @@ fun WishlistScreen(onBookClick: (Int) -> Unit) { contentPadding = PaddingValues(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.md), ) { - if (state.offline) { + if (state.offlineNow) { item(key = "offline") { InlineErrorBanner(message = stringResource(R.string.offline_lists_cached), onRetry = vm::refresh) } @@ -84,13 +96,13 @@ fun WishlistScreen(onBookClick: (Int) -> Unit) { line1 = item.author, // Cached availability is never shown as current. status = when { - !OfflinePolicy.showCurrentAvailability(state.offline) -> null + !OfflinePolicy.showCurrentAvailability(state.offlineNow) -> null item.available -> AvailabilityStatus.Available else -> AvailabilityStatus.Unavailable }, onClick = { onBookClick(item.bookId) }, // Removing needs the server: hidden while showing the offline copy. - trailing = if (state.offline) null else { + trailing = if (state.offlineNow) null else { { IconButton( onClick = { vm.remove(item.bookId) }, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt index 33af34a..7f265d4 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/wishlist/WishlistViewModel.kt @@ -4,7 +4,11 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.WishlistItem import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.connectivity.ConnectivityObserver import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage +import kotlinx.coroutines.flow.drop import com.pinakes.app.data.repository.WishlistRepository import com.pinakes.app.R import com.pinakes.app.ui.common.UiState @@ -24,15 +28,30 @@ data class WishlistUiState( val snackbarRes: Int? = null, /** Showing the cached list because the server is unreachable: removing is disabled. */ val offline: Boolean = false, -) + /** Device connectivity: removing and availability disappear the moment it drops. */ + val connected: Boolean = true, +) { + val offlineNow: Boolean get() = offline || !connected +} @HiltViewModel -class WishlistViewModel @Inject constructor(private val wishlist: WishlistRepository) : ViewModel() { +class WishlistViewModel @Inject constructor( + private val wishlist: WishlistRepository, + private val connectivity: ConnectivityObserver, +) : ViewModel() { - private val _state = MutableStateFlow(WishlistUiState()) + private val _state = MutableStateFlow(WishlistUiState(connected = connectivity.isOnline.value)) val state: StateFlow = _state.asStateFlow() - init { load(initial = true) } + init { + load(initial = true) + viewModelScope.launch { + connectivity.isOnline.drop(1).collect { online -> + _state.update { it.copy(connected = online) } + if (online && (_state.value.offline || _state.value.content is UiState.Error)) load(initial = false) + } + } + } fun refresh() = load(initial = false) @@ -54,6 +73,7 @@ class WishlistViewModel @Inject constructor(private val wishlist: WishlistReposi _state.update { it.copy( content = if (it.content is UiState.Success) it.content + else if (res.isOffline) UiState.Error("", ErrorCodes.NETWORK, R.string.offline_no_copy) else UiState.Error(res.message, res.code, R.string.wishlist_error_load), refreshing = false, ) @@ -65,7 +85,7 @@ class WishlistViewModel @Inject constructor(private val wishlist: WishlistReposi } fun remove(bookId: Int) { - if (_state.value.removingId != null || _state.value.offline) return + if (_state.value.removingId != null || _state.value.offlineNow) return _state.update { it.copy(removingId = bookId) } viewModelScope.launch { when (val res = wishlist.remove(bookId)) { @@ -81,7 +101,7 @@ class WishlistViewModel @Inject constructor(private val wishlist: WishlistReposi } } is ApiResult.Failure -> _state.update { - if (res.message.isNotBlank()) it.copy(removingId = null, snackbar = res.message, snackbarRes = null) + if (res.serverMessage() != null) it.copy(removingId = null, snackbar = res.serverMessage(), snackbarRes = null) else it.copy(removingId = null, snackbar = null, snackbarRes = R.string.snackbar_wishlist_remove_error) } } diff --git a/app/src/test/java/com/pinakes/app/staff/ConnectivityStateTest.kt b/app/src/test/java/com/pinakes/app/staff/ConnectivityStateTest.kt new file mode 100644 index 0000000..13c9c40 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/ConnectivityStateTest.kt @@ -0,0 +1,60 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage +import com.pinakes.app.ui.screens.detail.BookDetailUiState +import com.pinakes.app.ui.screens.home.HomeUiState +import com.pinakes.app.ui.screens.library.LibraryUiState +import com.pinakes.app.ui.screens.search.SearchUiState +import com.pinakes.app.ui.screens.wishlist.WishlistUiState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Losing the connection after an online load hides loan affordances and current availability + * at once; the raw network exception text never becomes a UI message. + */ +class ConnectivityStateTest { + + @Test fun homeShelfAvailabilityIsCurrentOnlyOnlineAndFromTheServer() { + assertTrue(HomeUiState(connected = true, fromCache = false).availabilityCurrent) + assertFalse(HomeUiState(connected = false, fromCache = false).availabilityCurrent) + assertFalse(HomeUiState(connected = true, fromCache = true).availabilityCurrent) + } + + @Test fun searchResultsLoseAvailabilityWhenDisconnectedOrCached() { + assertTrue(SearchUiState(connected = true).availabilityCurrent) + assertFalse(SearchUiState(connected = false).availabilityCurrent) + assertFalse(SearchUiState(offline = true).availabilityCurrent) + } + + @Test fun bookDetailIsOfflineWhenCachedOrDisconnected() { + assertFalse(BookDetailUiState(connected = true).offlineNow) + assertTrue(BookDetailUiState(connected = false).offlineNow) + assertTrue(BookDetailUiState(offline = true).offlineNow) + } + + @Test fun libraryActionsDisappearOffline() { + assertTrue(LibraryUiState(connected = true).showActions) + assertFalse(LibraryUiState(connected = false).showActions) + assertFalse(LibraryUiState(offline = true).showActions) + } + + @Test fun wishlistIsOfflineWhenCachedOrDisconnected() { + assertFalse(WishlistUiState().offlineNow) + assertTrue(WishlistUiState(connected = false).offlineNow) + } + + @Test fun networkExceptionTextIsNeverAServerMessage() { + val offline = ApiResult.Failure(ErrorCodes.NETWORK, "Failed to connect to /10.0.2.2:8081") + assertTrue(offline.isOffline) + assertNull(offline.serverMessage()) + assertEquals("Titolo obbligatorio", ApiResult.Failure("validation_error", "Titolo obbligatorio", 422).serverMessage()) + assertNull(ApiResult.Failure("validation_error", " ", 422).serverMessage()) + } +} diff --git a/i18n/de.json b/i18n/de.json index 71e56e8..e5b3748 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -814,5 +814,12 @@ "staff_scrape_unavailable": "Die Suchdienste sind gerade nicht erreichbar.", "staff_scan_offline_title": "Scannen braucht eine Verbindung", "staff_scan_offline_subtitle": "Jeder Code wird auf dem Bibliotheksserver gesucht. Bitte erneut verbinden und wiederholen.", - "staff_level_required": "Bitte einen Regalboden wählen oder das Regal auf „Keine Angabe“ setzen." + "staff_level_required": "Bitte einen Regalboden wählen oder das Regal auf „Keine Angabe“ setzen.", + "offline_generic": "Sie sind offline. Bitte die Verbindung prüfen und erneut versuchen.", + "offline_no_copy_title": "Sie sind offline", + "offline_no_copy": "Auf dem Gerät ist noch keine Kopie gespeichert. Diese Seite einmal mit Verbindung öffnen, um sie offline verfügbar zu haben.", + "offline_availability_hidden": "Sie sind offline: Verfügbarkeit und Ausleihaktionen sind ausgeblendet, bis die Verbindung zurück ist.", + "staff_search_authors": "Autoren suchen", + "staff_search_publishers": "Verlage suchen", + "staff_search_genres": "Genres suchen" } diff --git a/i18n/en.json b/i18n/en.json index ba3b095..f2006f9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -814,5 +814,12 @@ "staff_scrape_unavailable": "The lookup services are not reachable right now.", "staff_scan_offline_title": "Scanning needs a connection", "staff_scan_offline_subtitle": "Each code is looked up on the library server. Reconnect and try again.", - "staff_level_required": "Choose a level for this shelf, or set the shelf to none." + "staff_level_required": "Choose a level for this shelf, or set the shelf to none.", + "offline_generic": "You are offline. Check your connection and try again.", + "offline_no_copy_title": "You are offline", + "offline_no_copy": "There is no copy saved on this device yet. Open this page once while connected to keep it available offline.", + "offline_availability_hidden": "You are offline: availability and loan actions are hidden until the connection is back.", + "staff_search_authors": "Search authors", + "staff_search_publishers": "Search publishers", + "staff_search_genres": "Search genres" } diff --git a/i18n/fr.json b/i18n/fr.json index cba3cac..ddff19f 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -814,5 +814,12 @@ "staff_scrape_unavailable": "Les services de recherche sont injoignables pour le moment.", "staff_scan_offline_title": "Le scan nécessite une connexion", "staff_scan_offline_subtitle": "Chaque code est recherché sur le serveur de la bibliothèque. Reconnectez-vous et réessayez.", - "staff_level_required": "Choisissez une tablette pour cette étagère, ou réglez l’étagère sur aucune." + "staff_level_required": "Choisissez une tablette pour cette étagère, ou réglez l’étagère sur aucune.", + "offline_generic": "Vous êtes hors ligne. Vérifiez votre connexion et réessayez.", + "offline_no_copy_title": "Vous êtes hors ligne", + "offline_no_copy": "Aucune copie n’est encore enregistrée sur l’appareil. Ouvrez cette page une fois connecté pour la garder hors ligne.", + "offline_availability_hidden": "Vous êtes hors ligne : la disponibilité et les actions de prêt sont masquées jusqu’au retour de la connexion.", + "staff_search_authors": "Rechercher des auteurs", + "staff_search_publishers": "Rechercher des éditeurs", + "staff_search_genres": "Rechercher des genres" } diff --git a/i18n/it.json b/i18n/it.json index 49a312c..90cf02f 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -814,5 +814,12 @@ "staff_scrape_unavailable": "I servizi di ricerca non sono raggiungibili in questo momento.", "staff_scan_offline_title": "La scansione richiede una connessione", "staff_scan_offline_subtitle": "Ogni codice viene cercato sul server della biblioteca. Riconnettiti e riprova.", - "staff_level_required": "Scegli una mensola per questo scaffale, oppure imposta lo scaffale su nessuno." + "staff_level_required": "Scegli una mensola per questo scaffale, oppure imposta lo scaffale su nessuno.", + "offline_generic": "Sei offline. Controlla la connessione e riprova.", + "offline_no_copy_title": "Sei offline", + "offline_no_copy": "Sul dispositivo non c’è ancora una copia salvata. Apri questa pagina una volta con la connessione per averla anche offline.", + "offline_availability_hidden": "Sei offline: disponibilità e azioni di prestito sono nascoste finché la connessione non torna.", + "staff_search_authors": "Cerca autori", + "staff_search_publishers": "Cerca editori", + "staff_search_genres": "Cerca generi" } From 399ea83fba460061c0610f58d768daa71822df55 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 18:06:34 +0200 Subject: [PATCH 15/62] Fix count wording and the calendar feed description Counts read "Copies: 1" style in every language instead of a plural noun after the number ("1 copies"): batch copies, added this session, copies added, available of total and the offline last-known line. The calendar sheet now says the feed carries loans (due dates, books ready for pickup, booked loans), not reservations. --- i18n/de.json | 14 +++++++------- i18n/en.json | 14 +++++++------- i18n/fr.json | 14 +++++++------- i18n/it.json | 14 +++++++------- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/i18n/de.json b/i18n/de.json index e5b3748..695204d 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -631,12 +631,12 @@ "offline_results": "Sie sind offline: Diese Ergebnisse stammen aus der auf dem Gerät gespeicherten Kopie.", "offline_lists_cached": "Sie sind offline: Dies ist die zuletzt auf dem Gerät gespeicherte Liste. Aktionen benötigen eine Verbindung.", "offline_actions_unavailable": "Offline-Kopie. Ausleihen, Vormerkungen, Merkliste und Rezensionen benötigen eine Verbindung.", - "offline_availability_last_known": "Letzter Stand vom %1$s: %2$d von %3$d Exemplaren im Regal. Vor dem Besuch online prüfen.", + "offline_availability_last_known": "Letzter Stand vom %1$s, Exemplare im Regal: %2$d von %3$d. Vor dem Besuch online prüfen.", "offline_availability_unknown": "Die Verfügbarkeit kann offline nicht geprüft werden.", "profile_action_staff": "Mitarbeiterbereich", "profile_action_calendar": "Ausleihen im Kalender", "calendar_subscribe_title": "Ausleihen im Kalender", - "calendar_subscribe_body": "Ein privater Kalenderlink mit Rückgabefristen, Abholungen und Vormerkungen. Kalender-Apps aktualisieren ihn selbst.", + "calendar_subscribe_body": "Ein privater Kalenderlink mit Ihren Ausleihen: Rückgabefristen, abholbereite Bücher und vorgemerkte Ausleihen. Kalender-Apps aktualisieren ihn selbst.", "calendar_status_disabled": "Der Kalenderlink ist ausgeschaltet.", "calendar_enable": "Kalenderlink erstellen", "calendar_created_on": "Erstellt am %1$s", @@ -677,7 +677,7 @@ "staff_picker_empty": "Keine Treffer.", "staff_books_search": "Titel, Autor oder ISBN suchen", "staff_books_empty": "Keine Datensätze gefunden", - "staff_copies_count": "%1$d von %2$d Exemplaren verfügbar", + "staff_copies_count": "Verfügbar: %1$d von %2$d", "staff_copies_total": "Exemplare im Katalog: %1$d", "staff_book_new": "Neuer Datensatz", "staff_book_edit": "Datensatz bearbeiten", @@ -788,17 +788,17 @@ "staff_scan_settings": "Stapeleinstellungen", "staff_scan_settings_hint": "Gilt für jeden Titel, der in dieser Sitzung bestätigt wird.", "staff_scan_batch_title": "Stapelmodus", - "staff_scan_batch_copies": "%1$d Exemplare", + "staff_scan_batch_copies": "Exemplare: %1$d", "staff_scan_no_location": "Kein Standort", "staff_scan_auto_on": "automatische Bestätigung an", "staff_scan_auto_off": "jeden Titel bestätigen", "staff_scan_auto_confirm": "Automatisch bestätigen", "staff_scan_auto_confirm_hint": "Gefundene Titel ohne Vorschau hinzufügen. Bei Duplikaten und unbekannten Codes wird weiterhin nachgefragt.", - "staff_scan_session_count": "%1$d hinzugefügt", + "staff_scan_session_count": "Hinzugefügt: %1$d", "staff_scan_added": "Zum Katalog hinzugefügt", "staff_scan_added_list": "In dieser Sitzung hinzugefügt", - "staff_scan_added_new": "Neuer Datensatz · %1$d Exemplare", - "staff_scan_added_copies": "+%1$d Exemplare", + "staff_scan_added_new": "Neuer Datensatz · Exemplare: %1$d", + "staff_scan_added_copies": "Hinzugefügte Exemplare: %1$d", "staff_scan_confirm": "Zum Katalog hinzufügen", "staff_scan_edit_details": "Zuerst Details bearbeiten", "staff_scan_skip": "Überspringen", diff --git a/i18n/en.json b/i18n/en.json index f2006f9..16b32f2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -631,12 +631,12 @@ "offline_results": "You are offline: these results come from the copy saved on this device.", "offline_lists_cached": "You are offline: this is the last list saved on this device. Actions need a connection.", "offline_actions_unavailable": "Offline copy. Loans, reservations, wishlist and reviews need a connection.", - "offline_availability_last_known": "Last known on %1$s: %2$d of %3$d copies on the shelf. Check online before you come.", + "offline_availability_last_known": "Last known on %1$s, copies on the shelf: %2$d of %3$d. Check online before you come.", "offline_availability_unknown": "Availability cannot be checked offline.", "profile_action_staff": "Staff area", "profile_action_calendar": "Loans in your calendar", "calendar_subscribe_title": "Loans in your calendar", - "calendar_subscribe_body": "A private calendar link with your loan due dates, pickups and reservations. Calendar apps refresh it on their own.", + "calendar_subscribe_body": "A private calendar link with your loans: due dates, books ready for pickup and booked loans. Calendar apps refresh it on their own.", "calendar_status_disabled": "The calendar link is off.", "calendar_enable": "Create calendar link", "calendar_created_on": "Created on %1$s", @@ -677,7 +677,7 @@ "staff_picker_empty": "No matches.", "staff_books_search": "Search title, author or ISBN", "staff_books_empty": "No records found", - "staff_copies_count": "%1$d of %2$d copies available", + "staff_copies_count": "Available: %1$d of %2$d", "staff_copies_total": "Copies in the catalogue: %1$d", "staff_book_new": "New record", "staff_book_edit": "Edit record", @@ -788,17 +788,17 @@ "staff_scan_settings": "Batch settings", "staff_scan_settings_hint": "Applied to every title you confirm in this session.", "staff_scan_batch_title": "Batch mode", - "staff_scan_batch_copies": "%1$d copies", + "staff_scan_batch_copies": "Copies: %1$d", "staff_scan_no_location": "No location", "staff_scan_auto_on": "auto-confirm on", "staff_scan_auto_off": "confirm each title", "staff_scan_auto_confirm": "Auto-confirm", "staff_scan_auto_confirm_hint": "Add found titles without the preview step. Duplicates and unknown codes still ask.", - "staff_scan_session_count": "%1$d added", + "staff_scan_session_count": "Added: %1$d", "staff_scan_added": "Added to the catalogue", "staff_scan_added_list": "Added in this session", - "staff_scan_added_new": "New record · %1$d copies", - "staff_scan_added_copies": "+%1$d copies", + "staff_scan_added_new": "New record · copies: %1$d", + "staff_scan_added_copies": "Copies added: %1$d", "staff_scan_confirm": "Add to the catalogue", "staff_scan_edit_details": "Edit details first", "staff_scan_skip": "Skip", diff --git a/i18n/fr.json b/i18n/fr.json index ddff19f..88107f8 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -631,12 +631,12 @@ "offline_results": "Vous êtes hors ligne : ces résultats proviennent de la copie enregistrée sur l’appareil.", "offline_lists_cached": "Vous êtes hors ligne : voici la dernière liste enregistrée sur l’appareil. Les actions nécessitent une connexion.", "offline_actions_unavailable": "Copie hors ligne. Les prêts, réservations, favoris et avis nécessitent une connexion.", - "offline_availability_last_known": "Dernière information du %1$s : %2$d exemplaires sur %3$d en rayon. Vérifiez en ligne avant de venir.", + "offline_availability_last_known": "Dernière information du %1$s, exemplaires en rayon : %2$d sur %3$d. Vérifiez en ligne avant de venir.", "offline_availability_unknown": "La disponibilité ne peut pas être vérifiée hors ligne.", "profile_action_staff": "Espace personnel", "profile_action_calendar": "Prêts dans votre agenda", "calendar_subscribe_title": "Prêts dans votre agenda", - "calendar_subscribe_body": "Un lien privé avec les échéances de vos prêts, les retraits et les réservations. Les applications d’agenda le mettent à jour d’elles-mêmes.", + "calendar_subscribe_body": "Un lien privé avec vos prêts : échéances, livres prêts à retirer et prêts programmés. Les applications d’agenda le mettent à jour d’elles-mêmes.", "calendar_status_disabled": "Le lien d’agenda est désactivé.", "calendar_enable": "Créer le lien d’agenda", "calendar_created_on": "Créé le %1$s", @@ -677,7 +677,7 @@ "staff_picker_empty": "Aucun résultat.", "staff_books_search": "Rechercher un titre, un auteur ou un ISBN", "staff_books_empty": "Aucune notice trouvée", - "staff_copies_count": "%1$d exemplaires disponibles sur %2$d", + "staff_copies_count": "Disponibles : %1$d sur %2$d", "staff_copies_total": "Exemplaires au catalogue : %1$d", "staff_book_new": "Nouvelle notice", "staff_book_edit": "Modifier la notice", @@ -788,17 +788,17 @@ "staff_scan_settings": "Réglages de la série", "staff_scan_settings_hint": "Appliqués à chaque titre confirmé pendant cette session.", "staff_scan_batch_title": "Mode en série", - "staff_scan_batch_copies": "%1$d exemplaires", + "staff_scan_batch_copies": "Exemplaires : %1$d", "staff_scan_no_location": "Aucun emplacement", "staff_scan_auto_on": "confirmation automatique activée", "staff_scan_auto_off": "confirmer chaque titre", "staff_scan_auto_confirm": "Confirmation automatique", "staff_scan_auto_confirm_hint": "Ajoute les titres trouvés sans l’aperçu. Les doublons et les codes inconnus demandent toujours.", - "staff_scan_session_count": "%1$d ajoutés", + "staff_scan_session_count": "Ajoutés : %1$d", "staff_scan_added": "Ajouté au catalogue", "staff_scan_added_list": "Ajoutés pendant cette session", - "staff_scan_added_new": "Nouvelle notice · %1$d exemplaires", - "staff_scan_added_copies": "+%1$d exemplaires", + "staff_scan_added_new": "Nouvelle notice · exemplaires : %1$d", + "staff_scan_added_copies": "Exemplaires ajoutés : %1$d", "staff_scan_confirm": "Ajouter au catalogue", "staff_scan_edit_details": "Modifier d’abord les détails", "staff_scan_skip": "Passer", diff --git a/i18n/it.json b/i18n/it.json index 90cf02f..919461a 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -631,12 +631,12 @@ "offline_results": "Sei offline: questi risultati vengono dalla copia salvata sul dispositivo.", "offline_lists_cached": "Sei offline: questo è l’ultimo elenco salvato sul dispositivo. Le azioni richiedono una connessione.", "offline_actions_unavailable": "Copia offline. Prestiti, prenotazioni, preferiti e recensioni richiedono una connessione.", - "offline_availability_last_known": "Ultimo dato del %1$s: %2$d copie su %3$d a scaffale. Verifica online prima di venire.", + "offline_availability_last_known": "Ultimo dato del %1$s, copie a scaffale: %2$d su %3$d. Verifica online prima di venire.", "offline_availability_unknown": "La disponibilità non si può verificare offline.", "profile_action_staff": "Area staff", "profile_action_calendar": "Prestiti nel calendario", "calendar_subscribe_title": "Prestiti nel calendario", - "calendar_subscribe_body": "Un link privato con le scadenze dei prestiti, i ritiri e le prenotazioni. Le app di calendario lo aggiornano da sole.", + "calendar_subscribe_body": "Un link privato con i tuoi prestiti: scadenze, libri pronti per il ritiro e prestiti prenotati. Le app di calendario lo aggiornano da sole.", "calendar_status_disabled": "Il link del calendario è disattivato.", "calendar_enable": "Crea link calendario", "calendar_created_on": "Creato il %1$s", @@ -677,7 +677,7 @@ "staff_picker_empty": "Nessun risultato.", "staff_books_search": "Cerca titolo, autore o ISBN", "staff_books_empty": "Nessun record trovato", - "staff_copies_count": "%1$d copie disponibili su %2$d", + "staff_copies_count": "Disponibili: %1$d su %2$d", "staff_copies_total": "Copie in catalogo: %1$d", "staff_book_new": "Nuovo record", "staff_book_edit": "Modifica record", @@ -788,17 +788,17 @@ "staff_scan_settings": "Impostazioni serie", "staff_scan_settings_hint": "Applicate a ogni titolo che confermi in questa sessione.", "staff_scan_batch_title": "Modalità in serie", - "staff_scan_batch_copies": "%1$d copie", + "staff_scan_batch_copies": "Copie: %1$d", "staff_scan_no_location": "Nessuna collocazione", "staff_scan_auto_on": "conferma automatica attiva", "staff_scan_auto_off": "conferma ogni titolo", "staff_scan_auto_confirm": "Conferma automatica", "staff_scan_auto_confirm_hint": "Aggiunge i titoli trovati senza l’anteprima. Duplicati e codici sconosciuti chiedono comunque.", - "staff_scan_session_count": "%1$d aggiunti", + "staff_scan_session_count": "Aggiunti: %1$d", "staff_scan_added": "Aggiunto al catalogo", "staff_scan_added_list": "Aggiunti in questa sessione", - "staff_scan_added_new": "Nuovo record · %1$d copie", - "staff_scan_added_copies": "+%1$d copie", + "staff_scan_added_new": "Nuovo record · copie: %1$d", + "staff_scan_added_copies": "Copie aggiunte: %1$d", "staff_scan_confirm": "Aggiungi al catalogo", "staff_scan_edit_details": "Modifica prima i dettagli", "staff_scan_skip": "Salta", From f571720afdd1bb671f5d71526e87c2a4a5dd0b05 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 18:06:34 +0200 Subject: [PATCH 16/62] Show the refreshed role in the Profile header The header kept the role of the profile loaded when the screen opened, so a downgraded admin still read Admin while the staff entries had already gone. It now shows the stored role, refreshed by every GET /me, and Profile re-reads GET /me quietly when it comes back to the foreground. --- .../com/pinakes/app/ui/common/AppViewModel.kt | 3 +++ .../app/ui/screens/profile/ProfileScreen.kt | 15 ++++++++++++--- .../app/ui/screens/profile/ProfileViewModel.kt | 13 +++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt index 886a0bf..00582b0 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt @@ -35,6 +35,9 @@ class AppViewModel @Inject constructor( val features = featureStore.features val themeMode = themeStore.mode + /** The signed-in user's role, refreshed from login and `GET /me`. */ + val role = session.role + /** * Staff area visibility: an `admin`/`staff` role AND a server that advertises * `staff_catalog`. The server re-checks the role on every staff call. diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt index c397dd4..6c4c87c 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt @@ -99,10 +99,17 @@ fun ProfileScreen( onOpenDesiderata: () -> Unit, onOpenArchives: () -> Unit, onOpenStaff: () -> Unit = {}, + app: AppViewModel = hiltViewModel(), + vm: ProfileViewModel = hiltViewModel(), ) { - val app: AppViewModel = hiltViewModel() val isStaff by app.isStaff.collectAsStateWithLifecycle() - val vm: ProfileViewModel = hiltViewModel() + val role by app.role.collectAsStateWithLifecycle() + // Back on Profile (e.g. after the role changed on the server): re-read GET /me quietly. + var resumedOnce by remember { mutableStateOf(false) } + androidx.lifecycle.compose.LifecycleResumeEffect(Unit) { + if (resumedOnce) vm.refreshQuietly() else resumedOnce = true + onPauseOrDispose { } + } val state by vm.state.collectAsStateWithLifecycle() val features by app.features.collectAsStateWithLifecycle() val snackbarHost = remember { SnackbarHostState() } @@ -118,7 +125,9 @@ fun ProfileScreen( is UiState.Loading -> LoadingState(label = stringResource(R.string.profile_loading)) is UiState.Error -> ErrorState(message = p.resolvedMessage(), onRetry = vm::load) is UiState.Success -> ProfileContent( - profile = p.data, + // The stored role is refreshed by every GET /me (and the foreground refresh), so + // it wins over the profile snapshot loaded when the screen opened. + profile = role?.let { r -> p.data.copy(tipoUtente = r) } ?: p.data, devices = state.devices, loggingOut = state.loggingOut, onEdit = vm::startEdit, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt index 9a86b59..c3e381e 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt @@ -127,6 +127,19 @@ class ProfileViewModel @Inject constructor( } } + /** Re-read the profile without the loading state (on resume); failures keep what is shown. */ + fun refreshQuietly() { + viewModelScope.launch { + when (val res = profile.me()) { + is ApiResult.Success -> { + auth.rememberRole(res.data.tipoUtente) + _state.update { it.copy(profile = UiState.Success(res.data)) } + } + is ApiResult.Failure -> Unit + } + } + } + fun loadDevices() { _state.update { it.copy(devicesLoading = true) } viewModelScope.launch { From 73c578603b612d87ad41bb25b42228de0bef4bb9 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 20:25:31 +0200 Subject: [PATCH 17/62] Wait up to 60 s for a server response A staff create runs the whole admin pipeline (scraper hooks, cover download) and took over 30 s on a loaded server during the live smoke test: the app timed out and reported a failure for a record the server had already saved. The read timeout is now 60 s; the connect timeout is unchanged, so an unreachable server is still detected quickly. --- .../main/java/com/pinakes/app/data/network/NetworkModule.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt index 0ecbde0..ae3ed55 100644 --- a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt +++ b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt @@ -60,7 +60,10 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { .addInterceptor(CleartextGuardInterceptor { session.allowInsecureHttp || session.pendingAllowInsecureHttp }) .addInterceptor(AuthInterceptor(session)) .connectTimeout(20, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) + // 60 s: a staff create runs the whole admin pipeline (scraper hooks, cover download) + // and can exceed 30 s on a busy server; timing out after the server already saved + // left the user with an error for a record that exists. + .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) httpCache?.let { builder.cache(it) } // Complete incomplete server certificate chains via AIA, like a browser does. Self-hosted From c38d9905d80626d102c188d3e1d04f720aef3a58 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 20:25:31 +0200 Subject: [PATCH 18/62] Add device tests for the staff area, scanner, offline mode and calendar The new staffe2e suite drives the real app (MainActivity, real Hilt graph, ViewModels, repositories and Room) against FakePinakes, a MockWebServer on the device loopback that answers JSON fixtures from androidTest/assets/fixtures and records every request. UiAutomator handles the system camera prompt; airplane mode is real. Covered: staff entry only for admin/staff with staff_catalog (hidden for standard users, without the flag and after a server-side downgrade, header included); scanner manual ISBN to preview to add with the counter, duplicate to add copies, not found to the manual form, invalid code rejected without a lookup, camera denied keeps manual entry; manual form with a missing title or a shelf without a level sends nothing, an edit sends only the changed key; offline search banner with cached results and no availability or loan actions, offline detail with the last-known wording and no loan buttons, Library and Wishlist offline empty state without raw exception text, going offline after an online load hides the loan button and reconnecting restores it; calendar link shown masked, copied as sensitive, regenerate and turn off both confirm. 18 tests, all green on Android 15 together with the 22 existing ones. --- app/build.gradle.kts | 2 + .../androidTest/assets/fixtures/book_1.json | 35 +++++ .../assets/fixtures/calendar_disabled.json | 11 ++ .../assets/fixtures/calendar_enabled.json | 11 ++ .../assets/fixtures/copies_added.json | 14 ++ .../assets/fixtures/empty_list.json | 5 + .../assets/fixtures/health_no_staff.json | 28 ++++ .../assets/fixtures/health_staff.json | 28 ++++ .../assets/fixtures/isbn_duplicate.json | 35 +++++ .../assets/fixtures/isbn_found.json | 30 ++++ .../assets/fixtures/isbn_not_found.json | 17 +++ .../assets/fixtures/loans_empty.json | 9 ++ .../androidTest/assets/fixtures/me_admin.json | 12 ++ .../androidTest/assets/fixtures/me_staff.json | 12 ++ .../androidTest/assets/fixtures/me_user.json | 12 ++ .../assets/fixtures/search_books.json | 41 +++++ .../assets/fixtures/staff_book_5.json | 48 ++++++ .../assets/fixtures/staff_book_created.json | 12 ++ .../assets/fixtures/staff_books.json | 21 +++ .../assets/fixtures/staff_genres.json | 19 +++ .../assets/fixtures/staff_lookups.json | 41 +++++ .../assets/fixtures/staff_shelves.json | 20 +++ .../assets/fixtures/sync_full.json | 62 ++++++++ .../com/pinakes/app/staffe2e/AppFlowTest.kt | 140 ++++++++++++++++++ .../pinakes/app/staffe2e/BookFormFlowTest.kt | 61 ++++++++ .../pinakes/app/staffe2e/CalendarFlowTest.kt | 50 +++++++ .../com/pinakes/app/staffe2e/FakePinakes.kt | 81 ++++++++++ .../pinakes/app/staffe2e/OfflineFlowTest.kt | 74 +++++++++ .../pinakes/app/staffe2e/ScannerFlowTest.kt | 83 +++++++++++ .../app/staffe2e/StaffAccessFlowTest.kt | 55 +++++++ gradle/libs.versions.toml | 2 + 31 files changed, 1071 insertions(+) create mode 100644 app/src/androidTest/assets/fixtures/book_1.json create mode 100644 app/src/androidTest/assets/fixtures/calendar_disabled.json create mode 100644 app/src/androidTest/assets/fixtures/calendar_enabled.json create mode 100644 app/src/androidTest/assets/fixtures/copies_added.json create mode 100644 app/src/androidTest/assets/fixtures/empty_list.json create mode 100644 app/src/androidTest/assets/fixtures/health_no_staff.json create mode 100644 app/src/androidTest/assets/fixtures/health_staff.json create mode 100644 app/src/androidTest/assets/fixtures/isbn_duplicate.json create mode 100644 app/src/androidTest/assets/fixtures/isbn_found.json create mode 100644 app/src/androidTest/assets/fixtures/isbn_not_found.json create mode 100644 app/src/androidTest/assets/fixtures/loans_empty.json create mode 100644 app/src/androidTest/assets/fixtures/me_admin.json create mode 100644 app/src/androidTest/assets/fixtures/me_staff.json create mode 100644 app/src/androidTest/assets/fixtures/me_user.json create mode 100644 app/src/androidTest/assets/fixtures/search_books.json create mode 100644 app/src/androidTest/assets/fixtures/staff_book_5.json create mode 100644 app/src/androidTest/assets/fixtures/staff_book_created.json create mode 100644 app/src/androidTest/assets/fixtures/staff_books.json create mode 100644 app/src/androidTest/assets/fixtures/staff_genres.json create mode 100644 app/src/androidTest/assets/fixtures/staff_lookups.json create mode 100644 app/src/androidTest/assets/fixtures/staff_shelves.json create mode 100644 app/src/androidTest/assets/fixtures/sync_full.json create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/BookFormFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/CalendarFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/OfflineFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/ScannerFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/StaffAccessFlowTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6832fbb..037d6ed 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -287,6 +287,8 @@ dependencies { androidTestImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.test.runner) androidTestImplementation(libs.okhttp.mockwebserver) + // System permission dialogs (camera) and airplane mode in the staff/offline device tests. + androidTestImplementation(libs.androidx.test.uiautomator) implementation(libs.androidx.navigation.compose) diff --git a/app/src/androidTest/assets/fixtures/book_1.json b/app/src/androidTest/assets/fixtures/book_1.json new file mode 100644 index 0000000..ab471f0 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/book_1.json @@ -0,0 +1,35 @@ +{ + "data": { + "id": 1, + "title": "Il barone rampante", + "year": 1957, + "language": "Italiano", + "pages": 200, + "isbn13": "9788804668237", + "media_type": "libro", + "cover_url": null, + "publisher": "Einaudi", + "authors": [ + { + "id": 3, + "name": "Italo Calvino", + "role": "principale" + } + ], + "availability": { + "copies_total": 2, + "copies_available": 1, + "loanable_now": true, + "state": "available" + }, + "personal_history": { + "has_read": false, + "has_reserved": false, + "has_wishlisted": false, + "has_active_loan": false, + "has_pending_request": false + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/calendar_disabled.json b/app/src/androidTest/assets/fixtures/calendar_disabled.json new file mode 100644 index 0000000..6d3f74d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/calendar_disabled.json @@ -0,0 +1,11 @@ +{ + "data": { + "enabled": false, + "feed_url": null, + "webcal_url": null, + "created_at": null, + "rotated_at": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/calendar_enabled.json b/app/src/androidTest/assets/fixtures/calendar_enabled.json new file mode 100644 index 0000000..d470cc9 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/calendar_enabled.json @@ -0,0 +1,11 @@ +{ + "data": { + "enabled": true, + "feed_url": "http://localhost/api/v1/calendar/feed/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.ics", + "webcal_url": "webcal://localhost/api/v1/calendar/feed/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef.ics", + "created_at": "2026-10-13 10:00:00", + "rotated_at": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/copies_added.json b/app/src/androidTest/assets/fixtures/copies_added.json new file mode 100644 index 0000000..52106e5 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/copies_added.json @@ -0,0 +1,14 @@ +{ + "data": { + "copies_total": 3, + "copies_available": 3, + "created": [ + { + "id": 99, + "inventory_number": "LIB-42-C3" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/empty_list.json b/app/src/androidTest/assets/fixtures/empty_list.json new file mode 100644 index 0000000..86d8eb6 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/empty_list.json @@ -0,0 +1,5 @@ +{ + "data": [], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/health_no_staff.json b/app/src/androidTest/assets/fixtures/health_no_staff.json new file mode 100644 index 0000000..adc4327 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_no_staff.json @@ -0,0 +1,28 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": false, + "catalog_sync": true, + "calendar_feed": true + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/health_staff.json b/app/src/androidTest/assets/fixtures/health_staff.json new file mode 100644 index 0000000..1ac9d0d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_staff.json @@ -0,0 +1,28 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": true, + "catalog_sync": true, + "calendar_feed": true + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/isbn_duplicate.json b/app/src/androidTest/assets/fixtures/isbn_duplicate.json new file mode 100644 index 0000000..73b91d0 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/isbn_duplicate.json @@ -0,0 +1,35 @@ +{ + "data": { + "identifier": { + "input": "9788845292613", + "isbn10": "8845292614", + "isbn13": "9788845292613", + "ean": null, + "kind": "isbn" + }, + "found": true, + "existing": { + "id": 42, + "title": "Il nome della rosa", + "copies_total": 2, + "location": "A.1.4" + }, + "preview": { + "title": "Il nome della rosa", + "authors": [ + "Umberto Eco" + ], + "publisher": "Bompiani", + "year": "1980", + "pages": "503", + "cover_url": null, + "isbn13": "9788845292613", + "source": "test" + }, + "sources_checked": [ + "test" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/isbn_found.json b/app/src/androidTest/assets/fixtures/isbn_found.json new file mode 100644 index 0000000..40b273f --- /dev/null +++ b/app/src/androidTest/assets/fixtures/isbn_found.json @@ -0,0 +1,30 @@ +{ + "data": { + "identifier": { + "input": "9788845292613", + "isbn10": "8845292614", + "isbn13": "9788845292613", + "ean": null, + "kind": "isbn" + }, + "found": true, + "existing": null, + "preview": { + "title": "Il nome della rosa", + "authors": [ + "Umberto Eco" + ], + "publisher": "Bompiani", + "year": "1980", + "pages": "503", + "cover_url": null, + "isbn13": "9788845292613", + "source": "test" + }, + "sources_checked": [ + "test" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/isbn_not_found.json b/app/src/androidTest/assets/fixtures/isbn_not_found.json new file mode 100644 index 0000000..ec1d041 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/isbn_not_found.json @@ -0,0 +1,17 @@ +{ + "data": { + "identifier": { + "input": "4006381333931", + "ean": "4006381333931", + "kind": "ean" + }, + "found": false, + "existing": null, + "preview": null, + "sources_checked": [ + "test" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/loans_empty.json b/app/src/androidTest/assets/fixtures/loans_empty.json new file mode 100644 index 0000000..19cb151 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/loans_empty.json @@ -0,0 +1,9 @@ +{ + "data": { + "pending": [], + "active": [], + "history": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/me_admin.json b/app/src/androidTest/assets/fixtures/me_admin.json new file mode 100644 index 0000000..acaa648 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/me_admin.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": 1, + "nome": "Ada", + "cognome": "Admin", + "email": "ada@example.org", + "tipo_utente": "admin", + "email_verificata": true + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/me_staff.json b/app/src/androidTest/assets/fixtures/me_staff.json new file mode 100644 index 0000000..412dfc8 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/me_staff.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": 1, + "nome": "Ada", + "cognome": "Admin", + "email": "ada@example.org", + "tipo_utente": "staff", + "email_verificata": true + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/me_user.json b/app/src/androidTest/assets/fixtures/me_user.json new file mode 100644 index 0000000..2b485f2 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/me_user.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": 1, + "nome": "Ugo", + "cognome": "Admin", + "email": "ada@example.org", + "tipo_utente": "utente", + "email_verificata": true + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/search_books.json b/app/src/androidTest/assets/fixtures/search_books.json new file mode 100644 index 0000000..8a232c7 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/search_books.json @@ -0,0 +1,41 @@ +{ + "data": [ + { + "id": 1, + "title": "Il barone rampante", + "author": "Italo Calvino", + "publisher": "Einaudi", + "genre": "Romanzo", + "year": 1957, + "language": "Italiano", + "media_type": "libro", + "isbn13": "9788804668237", + "cover_url": null, + "copies_total": 2, + "copies_available": 1, + "loanable_now": true + }, + { + "id": 2, + "title": "La città invisibile", + "author": "Anna Rossi", + "publisher": "Adelphi", + "genre": "Saggio", + "year": 2001, + "language": "Italiano", + "media_type": "libro", + "isbn13": "9780140186475", + "cover_url": null, + "copies_total": 1, + "copies_available": 0, + "loanable_now": false + } + ], + "meta": { + "count": 2, + "limit": 20, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_book_5.json b/app/src/androidTest/assets/fixtures/staff_book_5.json new file mode 100644 index 0000000..e04a64d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_book_5.json @@ -0,0 +1,48 @@ +{ + "data": { + "id": 5, + "title": "Orlando", + "subtitle": "A Biography", + "isbn13": "9780156701600", + "year": 1928, + "pages": 333, + "language": "English", + "media_type": "libro", + "copies_total": 2, + "copies_available": 2, + "genre": { + "id": 11, + "name": "Romanzo" + }, + "authors": [ + { + "id": 1, + "name": "Virginia Woolf", + "role": "principale" + }, + { + "id": 7, + "name": "Nadia Fusini", + "role": "traduttore" + } + ], + "publishers": [ + { + "id": 4, + "name": "Hogarth" + } + ], + "location": { + "shelf_id": 7, + "shelf_code": "A", + "level_id": 70, + "level_number": 1, + "position": 3, + "label": "A.1.3" + }, + "copies": [], + "is_wanted": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_book_created.json b/app/src/androidTest/assets/fixtures/staff_book_created.json new file mode 100644 index 0000000..41ac6c7 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_book_created.json @@ -0,0 +1,12 @@ +{ + "data": { + "id": 42, + "title": "Il nome della rosa", + "copies_total": 1, + "copies_available": 1, + "authors": [], + "publishers": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_books.json b/app/src/androidTest/assets/fixtures/staff_books.json new file mode 100644 index 0000000..aee877e --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_books.json @@ -0,0 +1,21 @@ +{ + "data": [ + { + "id": 5, + "title": "Orlando", + "author": "Virginia Woolf", + "isbn13": "9780156701600", + "copies_total": 2, + "copies_available": 2, + "is_wanted": false, + "updated_at": "2026-10-13 09:00:00" + } + ], + "meta": { + "count": 1, + "limit": 30, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_genres.json b/app/src/androidTest/assets/fixtures/staff_genres.json new file mode 100644 index 0000000..fe0acb4 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_genres.json @@ -0,0 +1,19 @@ +{ + "data": [ + { + "id": 10, + "name": "Narrativa", + "parent_id": null, + "children": [ + { + "id": 11, + "name": "Romanzo", + "parent_id": 10, + "children": [] + } + ] + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_lookups.json b/app/src/androidTest/assets/fixtures/staff_lookups.json new file mode 100644 index 0000000..177ef63 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_lookups.json @@ -0,0 +1,41 @@ +{ + "data": { + "languages": [ + { + "code": "it", + "label": "Italiano" + } + ], + "media_types": [ + { + "value": "libro", + "label": "Libro" + }, + { + "value": "altro", + "label": "Altro" + } + ], + "formats": [ + { + "value": "Brossura", + "label": "Brossura" + } + ], + "acquisition_types": [ + { + "value": "Acquisto", + "label": "Acquisto" + } + ], + "contributor_roles": [], + "copy_states": [ + { + "value": "disponibile", + "label": "Disponibile" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_shelves.json b/app/src/androidTest/assets/fixtures/staff_shelves.json new file mode 100644 index 0000000..10877e1 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_shelves.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "id": 7, + "code": "A", + "name": "Narrativa", + "order": 1, + "levels": [ + { + "id": 70, + "number": 1, + "description": "Alto", + "order": 1 + } + ] + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/sync_full.json b/app/src/androidTest/assets/fixtures/sync_full.json new file mode 100644 index 0000000..619ba45 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/sync_full.json @@ -0,0 +1,62 @@ +{ + "data": { + "items": [ + { + "id": 1, + "change": "upsert", + "changed_at": "2026-10-13 09:00:00", + "book": { + "id": 1, + "title": "Il barone rampante", + "author": "Italo Calvino", + "publisher": "Einaudi", + "genre": "Romanzo", + "year": 1957, + "language": "Italiano", + "media_type": "libro", + "isbn13": "9788804668237", + "cover_url": null, + "copies_total": 2, + "copies_available": 1, + "loanable_now": true, + "pages": 200, + "description": "Descrizione.", + "location_label": "A.1.3", + "updated_at": "2026-10-13 09:00:00" + } + }, + { + "id": 2, + "change": "upsert", + "changed_at": "2026-10-13 09:00:00", + "book": { + "id": 2, + "title": "La città invisibile", + "author": "Anna Rossi", + "publisher": "Adelphi", + "genre": "Saggio", + "year": 2001, + "language": "Italiano", + "media_type": "libro", + "isbn13": "9780140186475", + "cover_url": null, + "copies_total": 1, + "copies_available": 0, + "loanable_now": false, + "pages": 200, + "description": "Descrizione.", + "location_label": "A.1.3", + "updated_at": "2026-10-13 09:00:00" + } + } + ], + "server_time": "2026-10-13 10:00:00", + "full_resync": true + }, + "meta": { + "next_cursor": null, + "has_more": false, + "count": 2 + }, + "error": null +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt new file mode 100644 index 0000000..b8d2401 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt @@ -0,0 +1,140 @@ +package com.pinakes.app.staffe2e + +import android.app.Application +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import com.pinakes.app.MainActivity +import com.pinakes.app.R +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.sync.CatalogSyncWorker +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import org.junit.After +import org.junit.Rule + +/** + * Drives the real app (MainActivity, real Hilt graph, real ViewModels, repositories and Room) + * against [FakePinakes] on the device loopback. The session is set up the way login leaves it + * (instance URL, token, role, `/health` flags), then the test acts like a user: tabs, rows, + * buttons, typed text. System UI (camera permission, airplane mode) goes through UiAutomator. + * + * Runs on a disposable emulator: it replaces the app's stored session and offline cache. + */ +abstract class AppFlowTest { + @get:Rule val compose = createEmptyComposeRule() + + protected val instrumentation get() = InstrumentationRegistry.getInstrumentation() + protected val context get() = instrumentation.targetContext + protected val device: UiDevice get() = UiDevice.getInstance(instrumentation) + protected lateinit var api: FakePinakes + private var scenario: ActivityScenario? = null + + protected val deps: CatalogSyncWorker.Deps by lazy { + EntryPointAccessors.fromApplication(context.applicationContext as Application, CatalogSyncWorker.Deps::class.java) + } + + protected fun text(id: Int, vararg args: Any): String = context.getString(id, *args) + + /** Sign in as [role] on a fresh fake instance and open the app. */ + protected fun launch(role: String = "admin", health: String = "health_staff", configure: FakePinakes.() -> Unit = {}) { + setAirplaneMode(false) + api = FakePinakes().apply(configure) + api.json("GET health", health) + runBlocking { deps.authRepository().forgetInstance() } + val session = deps.session() + session.saveInstance(api.origin, api.apiBaseUrl, "Test Library", allowInsecure = true) + session.saveToken("test-token") + session.saveRole(role) + val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + deps.featureStore().update(json.decodeFromString>(FakePinakes.fixture(health)).data!!) + scenario = ActivityScenario.launch(MainActivity::class.java) + waitFor(hasText(text(R.string.nav_profile))) + } + + @After + fun tearDown() { + setAirplaneMode(false) + scenario?.close() + if (::api.isInitialized) api.shutdown() + } + + protected fun waitFor(matcher: androidx.compose.ui.test.SemanticsMatcher, timeoutMs: Long = 10_000) { + compose.waitUntil(timeoutMs) { compose.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() } + } + + protected fun waitGone(matcher: androidx.compose.ui.test.SemanticsMatcher, timeoutMs: Long = 10_000) { + compose.waitUntil(timeoutMs) { compose.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().isEmpty() } + } + + protected fun waitText(value: String, timeoutMs: Long = 10_000) = waitFor(hasText(value), timeoutMs) + + protected fun exists(value: String, substring: Boolean = false): Boolean = + compose.onAllNodes(hasText(value, substring = substring), useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() + + protected fun node(value: String): SemanticsNodeInteraction = compose.onAllNodes(hasText(value)).onFirst() + + protected fun click(value: String) { + waitText(value) + node(value).performScrollToSafely().performClick() + compose.waitForIdle() + } + + /** + * Click [value] until [expected] shows (a tap can land while the list is still settling, + * e.g. right after the profile finished loading). Fails after [attempts]. + */ + protected fun clickUntil(value: String, expected: String, attempts: Int = 3) { + repeat(attempts) { + click(value) + if (runCatching { waitText(expected, 5_000) }.isSuccess) return + } + waitText(expected, 1) + } + + protected fun clickDescription(description: String) { + waitFor(hasContentDescription(description)) + compose.onAllNodes(hasContentDescription(description)).onFirst().performClick() + compose.waitForIdle() + } + + protected fun openTab(labelRes: Int) = click(text(labelRes)) + + protected fun SemanticsNodeInteraction.performScrollToSafely(): SemanticsNodeInteraction = + runCatching { performScrollTo() }.getOrDefault(this) + + /** Wait until a request matching [method] + [path] reached the fake server. */ + protected fun waitRequest(method: String, path: String, timeoutMs: Long = 10_000) { + compose.waitUntil(timeoutMs) { api.recorded(method, path).isNotEmpty() } + } + + /** Wait for the offline catalogue to hold the fixture rows (the first sync after launch). */ + protected fun waitCatalogueCached() { + compose.waitUntil(15_000) { runBlocking { deps.catalogRepository().hasCachedCatalog() } } + } + + /** Deny the system camera prompt if it shows (it does on the first scanner visits). */ + protected fun denyCameraPromptIfShown() { + val deny = device.wait(Until.findObject(By.res("com.android.permissioncontroller:id/permission_deny_button")), 3_000) + ?: device.wait(Until.findObject(By.res("com.android.permissioncontroller:id/permission_deny_and_dont_ask_again_button")), 500) + deny?.click() + device.waitForIdle() + } + + protected fun setAirplaneMode(on: Boolean) { + val mode = if (on) "enable" else "disable" + instrumentation.uiAutomation.executeShellCommand("cmd connectivity airplane-mode $mode").close() + Thread.sleep(1_500) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/BookFormFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/BookFormFlowTest.kt new file mode 100644 index 0000000..4d19018 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/BookFormFlowTest.kt @@ -0,0 +1,61 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextReplacement +import com.pinakes.app.R +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The manual record form: required title, complete locations, true patches on edit. */ +class BookFormFlowTest : AppFlowTest() { + + private fun openHub() { + openTab(R.string.nav_profile) + clickUntil(text(R.string.profile_action_staff), text(R.string.staff_scan_subtitle)) + } + + private fun field(label: String) = compose.onAllNodes(hasSetTextAction() and hasText(label, substring = true)).onFirst() + + @Test fun titleIsRequiredAndNothingIsSent() { + launch() + openHub() + click(text(R.string.staff_add_manual)) + waitText(text(R.string.staff_book_save)) + click(text(R.string.staff_book_save)) + waitText(text(R.string.staff_field_required)) + assertTrue(api.recorded("POST", "staff/books").isEmpty()) + } + + @Test fun shelfWithoutLevelShowsTheErrorAndSendsNothing() { + launch() + openHub() + click(text(R.string.staff_add_manual)) + waitText(text(R.string.staff_field_title)) + field(text(R.string.staff_field_title)).performTextInput("Spartito senza ISBN") + click(text(R.string.staff_shelf)) + click("A · Narrativa") + click(text(R.string.staff_book_save)) + waitText(text(R.string.staff_level_required)) + assertTrue(api.recorded("POST", "staff/books").isEmpty()) + } + + @Test fun editingOneFieldSendsOnlyThatKey() { + launch { json("PUT staff/books/5", "staff_book_5") } + openHub() + click(text(R.string.staff_books)) + click("Orlando") + waitText("A Biography") + compose.onAllNodes(hasSetTextAction() and hasText("A Biography")).onFirst().performTextReplacement("A new subtitle") + click(text(R.string.staff_book_save)) + waitRequest("PUT", "staff/books/5") + val body = Json.parseToJsonElement(api.recorded("PUT", "staff/books/5").single().body.readUtf8()).jsonObject + assertEquals(setOf("subtitle"), body.keys) + assertEquals("\"A new subtitle\"", body["subtitle"].toString()) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/CalendarFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/CalendarFlowTest.kt new file mode 100644 index 0000000..1781fb9 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/CalendarFlowTest.kt @@ -0,0 +1,50 @@ +package com.pinakes.app.staffe2e + +import android.content.ClipDescription +import android.content.ClipboardManager +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Profile → loans calendar: create, masked link, sensitive copy, confirmed regenerate/turn off. */ +class CalendarFlowTest : AppFlowTest() { + + private val token = "0123456789abcdef".repeat(4) + + @Test fun createCopyRegenerateAndTurnOff() { + launch { json("POST me/calendar/token", "calendar_enabled", 201) } + openTab(R.string.nav_profile) + clickUntil(text(R.string.profile_action_calendar), text(R.string.calendar_enable)) + click(text(R.string.calendar_enable)) + waitRequest("POST", "me/calendar/token") + + // Only host and tail of the secret are on screen. + waitText("localhost/…abcdef.ics") + assertFalse(exists(token, substring = true)) + + click(text(R.string.calendar_copy_link)) + var sensitive = false + var copied: String? = null + instrumentation.runOnMainSync { + val clip = context.getSystemService(ClipboardManager::class.java).primaryClip + copied = clip?.getItemAt(0)?.text?.toString() + sensitive = clip?.description?.extras?.getBoolean(ClipDescription.EXTRA_IS_SENSITIVE) == true + } + assertTrue("clip not flagged sensitive", sensitive) + assertEquals("http://localhost/api/v1/calendar/feed/$token.ics", copied) + + // Regenerate asks first; cancelling sends nothing. + click(text(R.string.calendar_regenerate)) + waitText(text(R.string.calendar_regenerate_confirm)) + click(text(R.string.action_cancel)) + assertEquals(1, api.recorded("POST", "me/calendar/token").size) + + // Turning it off asks too. + click(text(R.string.calendar_disable)) + waitText(text(R.string.calendar_disable_confirm)) + click(text(R.string.action_cancel)) + assertTrue(api.recorded("DELETE", "me/calendar/token").isEmpty()) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt new file mode 100644 index 0000000..80432b9 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt @@ -0,0 +1,81 @@ +package com.pinakes.app.staffe2e + +import androidx.test.platform.app.InstrumentationRegistry +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CopyOnWriteArrayList + +/** + * A fake Pinakes Mobile API on the device's loopback: every route answers a JSON fixture from + * `androidTest/assets/fixtures`, unknown routes answer the API's 404 envelope, and every + * request is recorded so a test can assert what the app actually sent. + * + * Routes are keyed `"METHOD /path"` with the path relative to `/api/v1/` and without the + * query string, e.g. `"GET staff/lookups"`. + */ +class FakePinakes { + val server = MockWebServer() + private val routes = ConcurrentHashMap MockResponse>() + val requests = CopyOnWriteArrayList() + + init { + // Everything the app asks for in the background, so screens never block on a 404. + json("GET health", "health_staff") + json("GET me", "me_admin") + json("GET me/devices", "empty_list") + json("GET me/notifications", "empty_list") + json("GET me/loans", "loans_empty") + json("GET me/reservations", "empty_list") + json("GET me/wishlist", "empty_list") + json("GET me/calendar", "calendar_disabled") + json("GET catalog/search", "search_books") + json("GET catalog/sync", "sync_full") + json("GET catalog/genres", "empty_list") + json("GET catalog/languages", "empty_list") + json("GET catalog/books/1", "book_1") + json("GET staff/lookups", "staff_lookups") + json("GET staff/genres", "staff_genres") + json("GET staff/shelves", "staff_shelves") + json("GET staff/books", "staff_books") + json("GET staff/books/5", "staff_book_5") + json("GET staff/authors", "empty_list") + json("GET staff/publishers", "empty_list") + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + requests += request + val path = request.requestUrl?.encodedPath.orEmpty().removePrefix("/api/v1/").trimEnd('/') + val route = routes["${request.method} $path"] + return route?.invoke() ?: notFound() + } + } + server.start() + } + + /** Base API URL the app is pointed at (`http://localhost:port/api/v1/`). */ + val apiBaseUrl: String get() = server.url("/api/v1/").toString() + val origin: String get() = apiBaseUrl.removeSuffix("/api/v1/") + + fun json(route: String, fixture: String, status: Int = 200) { + routes[route] = { MockResponse().setResponseCode(status).setHeader("Content-Type", "application/json").setBody(fixture(fixture)) } + } + + fun body(route: String, status: Int, body: String) { + routes[route] = { MockResponse().setResponseCode(status).setHeader("Content-Type", "application/json").setBody(body) } + } + + fun recorded(method: String, path: String): List = + requests.filter { it.method == method && it.requestUrl?.encodedPath == "/api/v1/$path" } + + fun shutdown() = runCatching { server.shutdown() } + + private fun notFound() = MockResponse().setResponseCode(404).setHeader("Content-Type", "application/json") + .setBody("""{"data":null,"meta":{},"error":{"code":"not_found","message":"Not found."}}""") + + companion object { + fun fixture(name: String): String = + InstrumentationRegistry.getInstrumentation().context.assets.open("fixtures/$name.json").bufferedReader().use { it.readText() } + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/OfflineFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/OfflineFlowTest.kt new file mode 100644 index 0000000..beacdc8 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/OfflineFlowTest.kt @@ -0,0 +1,74 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasText +import com.pinakes.app.R +import com.pinakes.app.ui.common.DateFormat +import org.junit.Assert.assertFalse +import org.junit.Test + +/** + * Offline behaviour: cached answers with a banner, no loan affordances, no availability shown as + * current, localized empty states and never the raw network exception. + */ +class OfflineFlowTest : AppFlowTest() { + + /** Server gone and airplane mode on: the device is really offline. */ + private fun goOffline() { + api.shutdown() + setAirplaneMode(true) + } + + private fun assertNoRawNetworkError() { + assertFalse(exists("Failed to connect", substring = true)) + assertFalse(exists("Unable to resolve", substring = true)) + } + + @Test fun offlineSearchShowsCachedResultsWithoutAvailabilityOrLoanActions() { + launch() + waitCatalogueCached() + goOffline() + openTab(R.string.nav_catalog) + waitText(text(R.string.offline_results)) + waitText("Il barone rampante") + assertFalse(exists(text(R.string.availability_available))) + assertFalse(exists(text(R.string.availability_on_loan))) + assertNoRawNetworkError() + } + + @Test fun offlineDetailSaysLastKnownAndHasNoLoanButtons() { + launch() + waitCatalogueCached() + goOffline() + openTab(R.string.nav_catalog) + click("Il barone rampante") + waitText(text(R.string.offline_availability_last_known, DateFormat.dateTime("2026-10-13 10:00:00"), 1, 2)) + assertFalse(exists(text(R.string.book_request_loan))) + assertFalse(exists(text(R.string.book_reserve))) + assertNoRawNetworkError() + } + + @Test fun libraryAndWishlistWithoutASavedCopyShowTheOfflineEmptyState() { + launch() + goOffline() + openTab(R.string.nav_library) + waitText(text(R.string.offline_no_copy_title)) + assertNoRawNetworkError() + openTab(R.string.nav_wishlist) + waitText(text(R.string.offline_no_copy_title)) + assertNoRawNetworkError() + } + + @Test fun goingOfflineAfterAnOnlineLoadHidesLoanAffordances() { + launch() + openTab(R.string.nav_catalog) + click("Il barone rampante") + waitText(text(R.string.book_request_loan)) + setAirplaneMode(true) + waitGone(hasText(text(R.string.book_request_loan))) + waitText(text(R.string.offline_actions_unavailable)) + assertFalse(exists(text(R.string.availability_available))) + // Back online: the page reloads with its loan action. + setAirplaneMode(false) + waitText(text(R.string.book_request_loan), 20_000) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ScannerFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ScannerFlowTest.kt new file mode 100644 index 0000000..d18b5ab --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ScannerFlowTest.kt @@ -0,0 +1,83 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTextInput +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The ISBN scanner, driven through the always-visible manual entry (as on an emulator). */ +class ScannerFlowTest : AppFlowTest() { + + private fun openScanner() { + openTab(R.string.nav_catalog) + clickDescription(text(R.string.staff_scan)) + denyCameraPromptIfShown() + waitText(text(R.string.staff_scan_manual_label)) + } + + private fun typeCode(code: String) { + compose.onAllNodes(hasSetTextAction() and hasText(text(R.string.staff_scan_manual_label))).onFirst().performTextInput(code) + clickDescription(text(R.string.staff_scan_lookup)) + } + + @Test fun cameraDeniedKeepsManualEntryAndExplainsWhy() { + launch() + openScanner() + // Denied (or never granted): the rationale or settings fallback replaces the preview, + // and the manual field stays usable. + assertTrue( + exists(text(R.string.staff_scan_permission_rationale)) || exists(text(R.string.staff_scan_permission_denied)) || + exists(text(R.string.staff_camera_unavailable)) + ) + waitText(text(R.string.staff_scan_manual_label)) + } + + @Test fun manualIsbnShowsThePreviewAndAddsTheTitle() { + launch { json("GET staff/isbn/9788845292613", "isbn_found"); json("POST staff/books", "staff_book_created", 201) } + openScanner() + typeCode("978-88-452-9261-3") + waitText("Il nome della rosa") + click(text(R.string.staff_scan_confirm)) + waitText(text(R.string.staff_scan_session_count, 1)) + val body = api.recorded("POST", "staff/books").single().body.readUtf8() + assertTrue(body, body.contains("\"isbn13\":\"9788845292613\"")) + assertTrue(body, body.contains("\"copies\":1")) + assertTrue(body, body.contains("\"authors_new\":[\"Umberto Eco\"]")) + } + + @Test fun duplicateOffersToAddCopies() { + launch { json("GET staff/isbn/9788845292613", "isbn_duplicate"); json("POST staff/books/42/copies", "copies_added", 201) } + openScanner() + typeCode("9788845292613") + waitText(text(R.string.staff_scan_duplicate)) + click(text(R.string.staff_scan_add_copy)) + waitText(text(R.string.staff_scan_added_copies, 1)) + assertEquals(1, api.recorded("POST", "staff/books/42/copies").size) + assertTrue(api.recorded("POST", "staff/books").isEmpty()) + } + + @Test fun notFoundLeadsToTheManualFormWithTheCode() { + launch { json("GET staff/isbn/4006381333931", "isbn_not_found") } + openScanner() + typeCode("4006381333931") + waitText(text(R.string.staff_scan_not_found)) + click(text(R.string.staff_scan_create_manual)) + waitText(text(R.string.staff_book_new)) + waitText("4006381333931") + assertTrue(api.recorded("POST", "staff/books").isEmpty()) + } + + @Test fun invalidCodeIsRejectedWithoutALookup() { + launch() + openScanner() + typeCode("9788845292614") + waitText(text(R.string.staff_identifier_invalid)) + assertTrue(api.requests.none { it.requestUrl?.encodedPath?.contains("staff/isbn") == true }) + assertTrue(compose.onAllNodes(hasContentDescription(text(R.string.staff_scan_lookup))).fetchSemanticsNodes().isNotEmpty()) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffAccessFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffAccessFlowTest.kt new file mode 100644 index 0000000..b1ec449 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffAccessFlowTest.kt @@ -0,0 +1,55 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.hasText +import com.pinakes.app.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The staff area shows only for admin/staff users on a server that advertises staff_catalog. */ +class StaffAccessFlowTest : AppFlowTest() { + + @Test fun adminSeesTheStaffAreaAndTheCatalogScanButton() { + launch(role = "admin") + openTab(R.string.nav_profile) + waitText(text(R.string.profile_action_staff)) + openTab(R.string.nav_catalog) + waitFor(hasContentDescription(text(R.string.staff_scan))) + } + + @Test fun staffRoleSeesTheStaffArea() { + launch(role = "staff") { json("GET me", "me_staff") } + openTab(R.string.nav_profile) + waitText(text(R.string.profile_action_staff)) + } + + @Test fun standardUserSeesNeitherEntry() { + launch(role = "utente") { json("GET me", "me_user") } + openTab(R.string.nav_profile) + waitText(text(R.string.profile_action_edit)) + assertFalse(exists(text(R.string.profile_action_staff))) + openTab(R.string.nav_catalog) + compose.waitForIdle() + assertTrue(compose.onAllNodes(hasContentDescription(text(R.string.staff_scan))).fetchSemanticsNodes().isEmpty()) + } + + @Test fun adminOnAServerWithoutStaffCatalogSeesNoStaffEntry() { + launch(role = "admin", health = "health_no_staff") + openTab(R.string.nav_profile) + waitText(text(R.string.profile_action_edit)) + assertFalse(exists(text(R.string.profile_action_staff))) + openTab(R.string.nav_catalog) + compose.waitForIdle() + assertTrue(compose.onAllNodes(hasContentDescription(text(R.string.staff_scan))).fetchSemanticsNodes().isEmpty()) + } + + @Test fun roleDowngradedOnTheServerHidesTheStaffAreaAndUpdatesTheHeader() { + // Stored role says admin, but GET /me (the authoritative read) now says utente. + launch(role = "admin") { json("GET me", "me_user") } + openTab(R.string.nav_profile) + waitText("Ugo Admin") + waitGone(hasText(text(R.string.profile_action_staff))) + assertFalse(exists("Admin", substring = false)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d304f6c..880c8f8 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ coroutinesTest = "1.8.1" robolectric = "4.13" testCore = "1.6.1" testRunner = "1.6.2" +uiautomator = "2.3.0" [libraries] androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" } @@ -78,6 +79,7 @@ androidx-test-core = { group = "androidx.test", name = "core", version.ref = "te androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-test-runner = { group = "androidx.test", name = "runner", version.ref = "testRunner" } +androidx-test-uiautomator = { group = "androidx.test.uiautomator", name = "uiautomator", version.ref = "uiautomator" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From fbe4e31520840dd26a29fec3bddea1740ff8e78f Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 20:25:32 +0200 Subject: [PATCH 19/62] Add a live end-to-end smoke test against a real Pinakes instance tools/e2e/live-smoke.sh drives the installed app on an emulator with adb and uiautomator (tools/e2e/ui.py) like a user: onboarding, sign-in, staff hub, scan-add of an ISBN with two copies, a copy through the duplicate sheet, a record without ISBN, its shelf and level on edit (checking that nothing else changed), airplane-mode search and detail, the calendar link and its feed. Every step is checked through the Mobile API, and cleanup removes only what the run created (books, a calendar link that did not exist before, device tokens issued during the run) and clears the app. Instance URL and credentials come only from PINAKES_E2E_* variables; texts follow the device language through i18n/*.json. STATUS.md documents how to run it and the device tests. --- STATUS.md | 4 +- tools/e2e/live-smoke.sh | 26 +++ tools/e2e/live_smoke.py | 473 ++++++++++++++++++++++++++++++++++++++++ tools/e2e/ui.py | 238 ++++++++++++++++++++ 4 files changed, 740 insertions(+), 1 deletion(-) create mode 100755 tools/e2e/live-smoke.sh create mode 100755 tools/e2e/live_smoke.py create mode 100755 tools/e2e/ui.py diff --git a/STATUS.md b/STATUS.md index 5913418..406caa2 100644 --- a/STATUS.md +++ b/STATUS.md @@ -9,7 +9,9 @@ Requires Mobile API 1.6.0 (Pinakes 0.8.5). Every new entry point is gated on the - **Offline catalogue**: Room v2 holds the whole catalogue, kept current by `GET /catalog/sync` (delta with a server watermark, forced full resync every 7 days or when the server asks); loans, reservations and wishlist are written through on every fetch. Without a connection, search, book pages, Library and Wishlist answer from the cache with an offline banner. Loan and reservation actions are hidden offline (never shown disabled) and cached availability is shown only as "last known on ". The cache is excluded from backups. - **Loans calendar**: Profile → Loans in your calendar (`calendar_feed`): create the private ICS link, copy it (primary, Google Calendar can only subscribe from its website), open it as `webcal://` with an HTTPS fallback, regenerate (old link stops) or turn it off. - Local verification: 283 debug unit tests (new: ISBN/EAN codec, scan debouncer, staff access, staff form and write bodies, sync merge, offline policy, calendar links, tolerant 1.6.0 parsing, Room v2 catalogue/library DAOs), zero lint errors (111 warnings, mostly newer-dependency notices), debug and R8 release builds. -- Not verified on a device in this change: camera scanning, photo capture and the flows against a live 1.6.0 server (unit tests, lint and both builds only). +- Device tests (re-runnable): `app/src/androidTest/java/com/pinakes/app/staffe2e/` drives the real app (MainActivity, real Hilt graph, ViewModels, Room) against `FakePinakes`, a MockWebServer on the device loopback answering JSON fixtures from `app/src/androidTest/assets/fixtures/` and recording every request. Covered: staff entry only for admin/staff with `staff_catalog` (hidden for standard users, for the flag off and after a server-side downgrade, header included); scanner manual ISBN → preview → add → counter, duplicate → add copies, not found → manual form with the code, invalid code rejected, camera denied keeps manual entry (the camera prompt is denied through UiAutomator); manual form title required and shelf-without-level both send nothing, edit sends only the changed key (recorded PUT body); offline search banner with cached results and no availability or loan actions, offline detail "last known" wording without loan buttons, Library/Wishlist offline empty state with no raw exception, going offline after an online load hides the loan button and coming back restores it (real airplane mode); calendar create → masked link, copy flagged sensitive, regenerate and turn off both confirm. Run on a disposable emulator (it replaces the app's stored session and cache): `ANDROID_SERIAL=emulator-5554 ./gradlew connectedDebugAndroidTest` (one class: add `-Pandroid.testInstrumentationRunnerArguments.class=com.pinakes.app.staffe2e.ScannerFlowTest`). +- Live smoke (real app, real server): `tools/e2e/live-smoke.sh [--install]` with `PINAKES_E2E_URL` (as the device reaches it, e.g. `http://10.0.2.2:8081`), `PINAKES_E2E_EMAIL`, `PINAKES_E2E_PASSWORD` (admin or staff), optional `PINAKES_E2E_HOST_URL`, `PINAKES_E2E_ISBN` (default 9788845292613, must not be catalogued) and `ANDROID_SERIAL`. It clears the app, onboards, signs in, opens the staff hub, scan-adds an ISBN with 2 copies, adds a copy through the duplicate sheet, creates a record without ISBN, sets its shelf/level (skipped when the instance has none) and checks nothing else changed, searches and opens it in airplane mode, creates the calendar link and fetches the feed; every step is checked through the API. Cleanup removes only what the run created (books, a calendar link that did not exist before, device tokens issued during the run) and clears the app. Texts follow the device language through `i18n/*.json`; `tools/e2e/ui.py` is the adb/uiautomator driver (`python3 tools/e2e/ui.py dump`). +- Not covered by automation: real camera decoding and photo capture (emulators have no barcode in view). ## 2026 restyle (2026-10-08) diff --git a/tools/e2e/live-smoke.sh b/tools/e2e/live-smoke.sh new file mode 100755 index 0000000..066ce39 --- /dev/null +++ b/tools/e2e/live-smoke.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Live end-to-end smoke test of the Android app against a real Pinakes instance. +# +# PINAKES_E2E_URL=http://10.0.2.2:8081 PINAKES_E2E_EMAIL=... PINAKES_E2E_PASSWORD=... \ +# ANDROID_SERIAL=emulator-5554 tools/e2e/live-smoke.sh [--install] +# +# --install builds and installs the debug APK first. The device is a disposable test device: +# the app's data is cleared at the start and at the end. Everything the run creates on the +# server (books, calendar link, device tokens) is removed again. See tools/e2e/live_smoke.py. +set -euo pipefail +cd "$(dirname "$0")/../.." + +for var in PINAKES_E2E_URL PINAKES_E2E_EMAIL PINAKES_E2E_PASSWORD; do + if [ -z "${!var:-}" ]; then echo "missing environment variable $var" >&2; exit 2; fi +done +command -v adb >/dev/null || { echo "adb not on PATH" >&2; exit 2; } +command -v python3 >/dev/null || { echo "python3 not on PATH" >&2; exit 2; } +adb ${ANDROID_SERIAL:+-s "$ANDROID_SERIAL"} get-state >/dev/null + +if [ "${1:-}" = "--install" ]; then + ./gradlew installDebug --console=plain -q +fi +adb ${ANDROID_SERIAL:+-s "$ANDROID_SERIAL"} shell pm path com.pinakes.app >/dev/null || { + echo "com.pinakes.app is not installed on the device (use --install)" >&2; exit 2; } + +exec python3 -u tools/e2e/live_smoke.py diff --git a/tools/e2e/live_smoke.py b/tools/e2e/live_smoke.py new file mode 100755 index 0000000..dfe7dbc --- /dev/null +++ b/tools/e2e/live_smoke.py @@ -0,0 +1,473 @@ +#!/usr/bin/env python3 +"""Live end-to-end smoke test: the real app on an emulator against a real Pinakes instance. + +Drives the installed debug app with adb/uiautomator (``ui.py``) like a user, checks the +server state through the Mobile API after every step, and removes what it created. + +Environment (nothing is hardcoded, secrets are never printed): + PINAKES_E2E_URL instance URL as the device reaches it (emulator: http://10.0.2.2:8081) + PINAKES_E2E_HOST_URL same instance as this machine reaches it (default: the device URL + with 10.0.2.2 replaced by 127.0.0.1) + PINAKES_E2E_EMAIL an admin or staff account + PINAKES_E2E_PASSWORD its password + PINAKES_E2E_ISBN ISBN to scan-add (default 9788845292613); must not be in the catalogue + ANDROID_SERIAL target device (adb's own variable) + +Steps: onboarding URL, login, staff hub, manual ISBN add (scraped preview), duplicate add +copies, manual record without ISBN, shelf/level location on edit (skipped when the instance +has no shelf with a level), airplane-mode offline search and detail, calendar link + feed. +Cleanup (only what this run created): created books deleted, the calendar link revoked when +it did not exist before, device tokens issued during the run revoked, app data cleared, +airplane mode off. Exit code 0 only when every step passed. +""" +import json +import os +import random +import re +import sys +import time +import traceback +import urllib.error +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import ui # noqa: E402 + +PKG = "com.pinakes.app" +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +def env(name, default=None, required=True): + value = os.environ.get(name, default) + if required and not value: + sys.exit(f"missing environment variable {name}") + return value + + +DEVICE_URL = env("PINAKES_E2E_URL").rstrip("/") +HOST_URL = env("PINAKES_E2E_HOST_URL", DEVICE_URL.replace("10.0.2.2", "127.0.0.1")).rstrip("/") +EMAIL = env("PINAKES_E2E_EMAIL") +PASSWORD = env("PINAKES_E2E_PASSWORD") +ISBN = env("PINAKES_E2E_ISBN", "9788845292613") +RUN = time.strftime("%Y%m%d%H%M%S") +MANUAL_TITLE = f"E2E smoke {RUN}" + + +# ---------------------------------------------------------------- strings (device language) + +def load_strings(): + locale = ui.shell("getprop persist.sys.locale").strip() or ui.shell("getprop ro.product.locale").strip() + lang = (locale or "en").split("-")[0].lower() + path = os.path.join(ROOT, "i18n", f"{lang}.json") + if not os.path.exists(path): + path = os.path.join(ROOT, "i18n", "en.json") + with open(os.path.join(ROOT, "i18n", "en.json"), encoding="utf-8") as fh: + base = json.load(fh) + with open(path, encoding="utf-8") as fh: + base.update(json.load(fh)) + return base + + +S = {} + + +def s(key, *args): + value = S[key] + for i, a in enumerate(args, 1): + value = re.sub(r"%" + str(i) + r"\$[sd]", str(a), value) + return value + + +def prefix(key): + """The text of a format string before its first placeholder (for substring matching).""" + return re.split(r"%\d\$[sd]", S[key])[0].strip() + + +# ---------------------------------------------------------------- API + +class Api: + def __init__(self): + self.token = None + + def call(self, method, path, body=None, raw=False, auth=True, base=None): + url = (base or HOST_URL) + "/api/v1/" + path.lstrip("/") if not path.startswith("http") else path + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Accept", "application/json") + if data is not None: + req.add_header("Content-Type", "application/json") + if auth and self.token: + req.add_header("Authorization", "Bearer " + self.token) + try: + with urllib.request.urlopen(req, timeout=180) as res: + payload = res.read() + return res.status, (payload if raw else json.loads(payload or b"{}")), res.headers + except urllib.error.HTTPError as e: + payload = e.read() + try: + return e.code, (payload if raw else json.loads(payload or b"{}")), e.headers + except ValueError: + return e.code, payload, e.headers + + def login(self): + status, body, _ = self.call("POST", "auth/login", { + "email": EMAIL, "password": PASSWORD, "device_name": f"e2e-smoke-{RUN}", + "device_id": f"e2e-{RUN}-{random.randint(1000, 9999)}", "platform": "other", + }, auth=False) + if status != 200 or not body.get("data", {}).get("token"): + raise AssertionError(f"API login failed with HTTP {status}") + self.token = body["data"]["token"] + role = body["data"]["user"].get("tipo_utente") + if role not in ("admin", "staff"): + raise AssertionError(f"PINAKES_E2E_EMAIL must be admin or staff (is {role!r})") + + def find_book(self, q): + status, body, _ = self.call("GET", f"staff/books?q={urllib.request.quote(q)}&limit=20") + assert status == 200, f"GET staff/books HTTP {status}" + return body.get("data") or [] + + def book(self, book_id): + status, body, _ = self.call("GET", f"staff/books/{book_id}") + assert status == 200, f"GET staff/books/{book_id} HTTP {status}" + return body["data"] + + +API = Api() +CREATED = [] # book ids to delete +RESULTS = [] # (step, ok, detail) +DEVICES_BEFORE = set() +STATE = {"devices_known": False, "calendar_was_enabled": None} + + +def step(name): + def wrap(fn): + def run(*a, **kw): + print(f"--> {name}", flush=True) + try: + detail = fn(*a, **kw) + RESULTS.append((name, "PASS", detail or "")) + print(f" PASS {detail or ''}", flush=True) + return True + except SkipStep as e: + RESULTS.append((name, "SKIP", str(e))) + print(f" SKIP {e}", flush=True) + return True + except Exception as e: # noqa: BLE001 - report and continue with the next step + RESULTS.append((name, "FAIL", str(e))) + print(f" FAIL {e}", flush=True) + traceback.print_exc() + try: + ui.screenshot(f"/tmp/pinakes-e2e-{RUN}-{len(RESULTS)}.png") + except Exception: # noqa: BLE001 + pass + return False + return run + return wrap + + +class SkipStep(Exception): + pass + + +# ---------------------------------------------------------------- navigation helpers + +def open_tab(key): + """Go back to the bottom-navigation screen if needed, then open the tab [key].""" + w, h = ui.screen_size() + for _ in range(6): + ui.hide_keyboard() + for n in ui.dump(): + # Bottom navigation labels sit in the lowest part of the screen. + if n.text == S[key] and n.center[1] > h * 0.8: + ui.tap_xy(*n.center) + return + ui.back() + raise AssertionError(f"bottom navigation not reachable for {S[key]!r}") + + +def open_staff_hub(): + open_tab("nav_profile") + ui.wait(S["profile_action_edit"], 90) + ui.scroll_to(S["profile_action_staff"]) + ui.tap(S["profile_action_staff"]) + ui.wait(S["staff_scan"]) + + +def deny_camera_prompt(): + for res in ("com.android.permissioncontroller:id/permission_deny_button", + "com.android.permissioncontroller:id/permission_deny_and_dont_ask_again_button"): + deadline = time.time() + 4 + while time.time() < deadline: + n = ui.find_res(res) + if n: + ui.tap_xy(*n.center) + return + time.sleep(0.5) + + +def scanner_lookup(code): + ui.tap(S["staff_scan_manual_label"]) + ui.type_text(code) + ui.hide_keyboard() + ui.tap(S["staff_scan_lookup"]) + + +# ---------------------------------------------------------------- steps + +@step("preflight: API login and clean ISBN") +def preflight(): + API.login() + # Snapshot what already exists, so cleanup only removes what this run created. + _, devs, _ = API.call("GET", "me/devices") + DEVICES_BEFORE.update(d["id"] for d in devs.get("data") or []) + STATE["devices_known"] = True + status, cal, _ = API.call("GET", "me/calendar") + if status == 200: + STATE["calendar_was_enabled"] = bool((cal.get("data") or {}).get("enabled")) + status, body, _ = API.call("GET", "health", auth=False) + feats = body["data"]["features"] + assert feats.get("staff_catalog") and feats.get("catalog_sync"), f"server lacks Mobile API 1.6.0 features: {feats}" + if any(r.get("isbn13") == ISBN or r.get("ean") == ISBN for r in API.find_book(ISBN)): + raise AssertionError(f"ISBN {ISBN} is already in the catalogue: set PINAKES_E2E_ISBN to an unused one") + return f"calendar_feed={feats.get('calendar_feed')}" + + +@step("onboarding and login in the app") +def onboarding_and_login(): + ui.adb("shell", "pm", "clear", PKG) + ui.airplane(False) + ui.shell(f"monkey -p {PKG} -c android.intent.category.LAUNCHER 1") + ui.wait(S["onboarding_url_label"], 30) + ui.tap(S["onboarding_url_label"]) + ui.type_text(DEVICE_URL) + ui.hide_keyboard() + ui.tap(S["action_discover_library"]) + ui.wait(S["action_continue"], 30) + ui.tap(S["action_continue"]) + ui.wait(S["login_email_label"], 30) + ui.tap(S["login_email_label"], exact=True) + ui.type_text(EMAIL) + ui.hide_keyboard() + ui.tap(S["field_password"], exact=True) + ui.type_text(PASSWORD, secret=True) + ui.hide_keyboard() + # A loaded dev server can time out the first request: retry the sign-in like a user would. + for attempt in range(3): + ui.tap(S["login_action_sign_in"]) + which, _ = ui.wait_any([S["nav_profile"], S["login_error_network"]], 120) + if which == S["nav_profile"]: + return f"signed in (attempt {attempt + 1})" + time.sleep(5) + raise AssertionError("sign-in kept failing with a network error") + + +@step("staff hub is reachable") +def staff_hub(): + open_staff_hub() + for key in ("staff_add_manual", "staff_books", "staff_authors", "staff_shelves"): + ui.scroll_to(S[key]) + + +@step("scan: manual ISBN, scraped preview, add with 2 copies") +def scan_add(): + ui.tap(S["staff_scan"]) + deny_camera_prompt() + ui.wait(S["staff_scan_manual_label"]) + scanner_lookup(ISBN) + found, _ = ui.wait_any([S["staff_scan_confirm"], S["staff_scan_not_found"], S["staff_scan_duplicate"]], 90) + assert found == S["staff_scan_confirm"], f"expected a preview for {ISBN}, got {found!r}" + time.sleep(1.5) # let the sheet finish expanding + ui.tap(S["staff_cd_increase"]) # 1 → 2 copies + ui.tap(S["staff_scan_confirm"]) + which, _ = ui.wait_any([s("staff_scan_session_count", 1), S["staff_error_save"], S["staff_error_offline"]], 150) + rows = [r for r in API.find_book(ISBN) if r.get("isbn13") == ISBN or r.get("ean") == ISBN] + CREATED.extend(r["id"] for r in rows if r["id"] not in CREATED) + assert which == s("staff_scan_session_count", 1), f"the app reported {which!r} (server rows: {len(rows)})" + assert len(rows) == 1, f"expected one record with {ISBN}, found {len(rows)}" + book = API.book(rows[0]["id"]) + assert book["copies_total"] == 2, f"copies_total {book['copies_total']} != 2" + return f"book #{book['id']} {book['title']!r}" + + +@step("scan the same ISBN: duplicate sheet adds a copy") +def scan_duplicate(): + if not CREATED: + raise SkipStep("no scanned book") + scanner_lookup(ISBN) + ui.wait(S["staff_scan_duplicate"], 60) + ui.tap(S["staff_scan_add_copy"]) + ui.wait(s("staff_scan_added_copies", 1), 30) + book = API.book(CREATED[0]) + assert book["copies_total"] == 3, f"copies_total {book['copies_total']} != 3" + ui.back() + + +@step("manual record without ISBN") +def manual_record(): + ui.tap(S["staff_add_manual"]) + ui.wait(S["staff_field_title"]) + ui.tap(S["staff_field_title"]) + ui.type_text(MANUAL_TITLE) + ui.hide_keyboard() + ui.tap(S["staff_book_save"]) + ui.wait(S["staff_add_manual"], 120) # back on the hub after the save + rows = [r for r in API.find_book(MANUAL_TITLE) if r["title"] == MANUAL_TITLE] + CREATED.extend(r["id"] for r in rows if r["id"] not in CREATED) + assert len(rows) == 1, f"expected one {MANUAL_TITLE!r}, found {len(rows)}" + book = API.book(rows[0]["id"]) + assert not book.get("isbn13") and not book.get("ean"), "manual record got an identifier" + return f"book #{book['id']}" + + +@step("edit location: shelf + level, everything else unchanged") +def edit_location(): + status, body, _ = API.call("GET", "staff/shelves") + shelves = [sh for sh in body.get("data") or [] if sh.get("levels")] + if not shelves: + raise SkipStep("the instance has no shelf with a level") + if len(CREATED) < 2: + raise SkipStep("no manual record") + shelf, level = shelves[0], shelves[0]["levels"][0] + before = API.book(CREATED[1]) + ui.tap(S["staff_books"]) + ui.tap(S["staff_books_search"]) + ui.type_text(MANUAL_TITLE) + ui.hide_keyboard() + ui.tap(MANUAL_TITLE, timeout=30) + ui.wait(S["staff_book_save"], 30) + ui.scroll_to(S["staff_shelf"]) + ui.tap(S["staff_shelf"]) + display = " · ".join(x for x in (shelf.get("code"), shelf.get("name")) if x) + ui.tap(display) + ui.scroll_to(S["staff_level"]) + ui.tap(S["staff_level"]) + ui.tap(s("staff_level_number", level["number"])) + ui.tap(S["staff_book_save"]) + ui.gone(S["staff_book_save"], 120) # the edit screen closes after the save + after = API.book(CREATED[1]) + loc = after.get("location") or {} + assert loc.get("shelf_id") == shelf["id"] and loc.get("level_id") == level["id"], f"location {loc}" + for key in ("title", "subtitle", "isbn13", "ean", "language", "media_type", "copies_total", "authors", "publishers", "genre"): + assert before.get(key) == after.get(key), f"{key} changed: {before.get(key)!r} → {after.get(key)!r}" + ui.back() + return f"{display} / level {level['number']}" + + +@step("offline: airplane mode, cached search and detail") +def offline(): + # Foreground again so the app syncs the catalogue with the records created above. + ui.shell("input keyevent KEYCODE_HOME") + time.sleep(1) + ui.shell(f"monkey -p {PKG} -c android.intent.category.LAUNCHER 1") + time.sleep(25) # the foreground sync (slow dev servers need time) + ui.airplane(True) + try: + open_tab("nav_catalog") + ui.tap(S["catalog_field_placeholder"]) + ui.type_text(MANUAL_TITLE) + ui.hide_keyboard() + ui.wait(S["offline_results"], 30) + ui.tap(MANUAL_TITLE, timeout=30) + ui.wait(S["offline_actions_unavailable"], 30) + # The availability box is below the cover: scroll to it, checking each screen on the way. + last_known = prefix("offline_availability_last_known") + for _ in range(8): + nodes = ui.dump() + for bad in (S["book_request_loan"], S["book_reserve"], "Failed to connect"): + assert not ui.find(bad, nodes=nodes), f"{bad!r} shown offline" + if ui.find(last_known, nodes=nodes) or ui.find(S["offline_availability_unknown"], nodes=nodes): + break + ui.swipe_up() + else: + raise AssertionError("no 'last known' availability wording on the offline page") + ui.back() + finally: + ui.airplane(False) + + +@step("calendar: create link, fetch the feed") +def calendar(): + status, body, _ = API.call("GET", "health", auth=False) + if not body["data"]["features"].get("calendar_feed"): + raise SkipStep("calendar_feed is off on this instance") + open_tab("nav_profile") + ui.wait(S["profile_action_edit"], 90) # the profile has loaded + ui.scroll_to(S["profile_action_calendar"]) + ui.tap(S["profile_action_calendar"]) + which, _ = ui.wait_any([S["calendar_enable"], S["calendar_copy_link"]], 30) + if which == S["calendar_enable"]: + ui.tap(S["calendar_enable"]) + STATE["calendar_created"] = True + ui.wait(S["calendar_copy_link"], 30) + status, body, _ = API.call("GET", "me/calendar") + data = body["data"] + assert data["enabled"] and data["feed_url"], f"calendar status {data}" + feed = data["feed_url"].replace("10.0.2.2", "127.0.0.1") if "127.0.0.1" in HOST_URL else data["feed_url"] + status, payload, headers = API.call("GET", feed, raw=True, auth=False) + assert status == 200, f"feed HTTP {status}" + assert b"BEGIN:VCALENDAR" in payload, "feed is not iCalendar" + assert "text/calendar" in (headers.get("Content-Type") or ""), "feed content type" + ui.back() + + +def cleanup(): + print("--> cleanup", flush=True) + try: + _cleanup_server() + except Exception as e: # noqa: BLE001 - the device must be reset even if the server is slow + print(f" server cleanup incomplete: {e} (left: books {CREATED})") + ui.adb("shell", "pm", "clear", PKG, check=False) + + +def _cleanup_server(): + # Records this run created even when a step failed half-way (the ISBN was absent at + # preflight and the manual title is unique to the run). + if API.token: + for q, match in ((ISBN, lambda r: r.get("isbn13") == ISBN or r.get("ean") == ISBN), + (MANUAL_TITLE, lambda r: r.get("title") == MANUAL_TITLE)): + for r in API.find_book(q): + if match(r) and r["id"] not in CREATED: + CREATED.append(r["id"]) + try: + ui.airplane(False) + except Exception: # noqa: BLE001 + pass + for book_id in CREATED: + status, _, _ = API.call("DELETE", f"staff/books/{book_id}") + print(f" delete book #{book_id}: HTTP {status}") + if API.token: + # Only undo what this run did: a link that existed before the run is left alone. + if STATE["calendar_was_enabled"] is False and STATE.get("calendar_created"): + status, _, _ = API.call("DELETE", "me/calendar/token") + print(f" revoke calendar link created by this run: HTTP {status}") + # Only tokens issued during this run (the app's login); never pre-existing sessions. + if STATE["devices_known"]: + _, devs, _ = API.call("GET", "me/devices") + for d in devs.get("data") or []: + if d["id"] not in DEVICES_BEFORE and not d.get("is_current"): + status, _, _ = API.call("DELETE", f"me/devices/{d['id']}") + print(f" revoke app device token #{d['id']} created by this run: HTTP {status}") + API.call("POST", "auth/logout") + + +def main(): + S.update(load_strings()) + ok = preflight() + try: + # Without a signed-in app the remaining steps cannot run: stop there. + if ok and onboarding_and_login(): + for fn in (staff_hub, scan_add, scan_duplicate, manual_record, edit_location, offline, calendar): + fn() + finally: + cleanup() + passed = sum(1 for r in RESULTS if r[1] == "PASS") + skipped = sum(1 for r in RESULTS if r[1] == "SKIP") + failed = sum(1 for r in RESULTS if r[1] == "FAIL") + print(f"\nlive smoke: {passed} passed, {skipped} skipped, {failed} failed") + for name, result, detail in RESULTS: + print(f" {result:4} {name} {detail}") + return 1 if failed or not passed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/e2e/ui.py b/tools/e2e/ui.py new file mode 100755 index 0000000..140793c --- /dev/null +++ b/tools/e2e/ui.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +"""Small adb + uiautomator driver for the live smoke test (no extra dependencies). + +Usable as a module (``import ui``) or from the shell for poking at a screen: + + ANDROID_SERIAL=emulator-5554 python3 tools/e2e/ui.py dump + python3 tools/e2e/ui.py tap "Staff area" + python3 tools/e2e/ui.py shot /tmp/screen.png + +Texts are matched case-insensitively against a node's text or content description. The +device is chosen by ANDROID_SERIAL (adb's own variable). +""" +import os +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + +SERIAL = os.environ.get("ANDROID_SERIAL", "") +ADB = ["adb"] + (["-s", SERIAL] if SERIAL else []) + + +def adb(*args, check=True, capture=True): + res = subprocess.run(ADB + list(args), capture_output=capture) + if check and res.returncode != 0: + raise RuntimeError(f"adb {' '.join(args[:3])} failed: {res.stderr.decode(errors='replace').strip()}") + return res.stdout + + +def shell(cmd): + return adb("shell", cmd).decode(errors="replace") + + +class Node: + def __init__(self, el): + self.text = el.get("text") or "" + self.desc = el.get("content-desc") or "" + self.res = el.get("resource-id") or "" + self.cls = el.get("class") or "" + self.clickable = el.get("clickable") == "true" + self.checked = el.get("checked") == "true" + b = [int(v) for v in re.findall(r"\d+", el.get("bounds", "[0,0][0,0]"))] + self.bounds = b + self.center = ((b[0] + b[2]) // 2, (b[1] + b[3]) // 2) + + def label(self): + return (self.text + " " + self.desc).strip() + + def __repr__(self): + return f"Node({self.label()!r} {self.bounds})" + + +def dump(): + """All nodes of the current screen (retries: uiautomator dump fails while animating).""" + for _ in range(5): + out = adb("exec-out", "uiautomator", "dump", "/dev/tty", check=False).decode(errors="replace") + start, end = out.find("") + if start >= 0 and end > start: + root = ET.fromstring(out[start:end + len("")]) + return [Node(el) for el in root.iter("node")] + time.sleep(0.5) + raise RuntimeError("uiautomator dump failed") + + +def find(text, exact=False, nodes=None, include_edit=False): + """First node showing [text]. Editable fields are skipped unless [include_edit]: a field + that contains what was just typed must not shadow the list row with the same text.""" + want = text.lower() + for n in nodes if nodes is not None else dump(): + if not include_edit and n.cls == "android.widget.EditText" and n.text: + continue + hay = [n.text.lower(), n.desc.lower()] + if (want in hay) if exact else any(want in h for h in hay if h): + return n + return None + + +def find_res(res_id, nodes=None): + for n in nodes if nodes is not None else dump(): + if n.res == res_id: + return n + return None + + +def wait(text, timeout=20, exact=False): + """Wait until [text] is on screen; returns its node or raises.""" + deadline = time.time() + timeout + while time.time() < deadline: + n = find(text, exact) + if n: + return n + time.sleep(0.7) + raise AssertionError(f"not on screen after {timeout}s: {text!r}") + + +def wait_any(texts, timeout=20): + deadline = time.time() + timeout + while time.time() < deadline: + nodes = dump() + for t in texts: + n = find(t, nodes=nodes) + if n: + return t, n + time.sleep(0.7) + raise AssertionError(f"none on screen after {timeout}s: {texts!r}") + + +def gone(text, timeout=15): + deadline = time.time() + timeout + while time.time() < deadline: + if not find(text): + return True + time.sleep(0.7) + raise AssertionError(f"still on screen after {timeout}s: {text!r}") + + +def tap_xy(x, y): + shell(f"input tap {x} {y}") + time.sleep(0.6) + + +def tap(text, timeout=20, exact=False): + n = wait(text, timeout, exact) + tap_xy(*n.center) + return n + + +_SIZE = None + + +def screen_size(): + global _SIZE + if _SIZE is None: + m = re.search(r"(\d+)x(\d+)", shell("wm size").splitlines()[-1]) + _SIZE = (int(m.group(1)), int(m.group(2))) if m else (1080, 2400) + return _SIZE + + +def swipe_up(): + w, h = screen_size() + shell(f"input swipe {w // 2} {int(h * 0.75)} {w // 2} {int(h * 0.30)} 300") + time.sleep(0.6) + + +def swipe_down(): + w, h = screen_size() + shell(f"input swipe {w // 2} {int(h * 0.30)} {w // 2} {int(h * 0.75)} 300") + time.sleep(0.6) + + +def scroll_to(text, max_swipes=8, exact=False, passes=2): + """Scroll down looking for [text], then back up (content can appear late), [passes] times.""" + for p in range(passes): + for _ in range(max_swipes): + n = find(text, exact) + if n: + return n + (swipe_up if p % 2 == 0 else swipe_down)() + raise AssertionError(f"not found while scrolling: {text!r}") + + +def _escape(value): + # `input text` goes through the device shell: escape every non-alphanumeric character. + out = [] + for ch in value: + if ch == " ": + out.append("%s") + elif ch.isalnum(): + out.append(ch) + else: + out.append("\\" + ch) + return "".join(out) + + +def type_text(value, secret=False): + """Type into the focused field. With secret=True nothing is ever echoed.""" + adb("shell", "input text " + _escape(value), check=True) + time.sleep(0.4) + + +def fill(label, value, secret=False): + tap(label) + shell("input keyevent KEYCODE_MOVE_END") + type_text(value, secret=secret) + hide_keyboard() + + +def keyboard_shown(): + """The IME insets source is visible (more reliable than input_method's mInputShown).""" + for line in shell("dumpsys window").splitlines(): + if "InsetsSource" in line and "type=ime" in line: + return "visible=true" in line + return "mInputShown=true" in shell("dumpsys input_method") + + +def hide_keyboard(): + """Close the soft keyboard (BACK only when it is really shown, or BACK would navigate).""" + if keyboard_shown(): + shell("input keyevent KEYCODE_BACK") + time.sleep(0.6) + + +def back(): + shell("input keyevent KEYCODE_BACK") + time.sleep(0.6) + + +def airplane(on): + shell(f"cmd connectivity airplane-mode {'enable' if on else 'disable'}") + time.sleep(3) + + +def screenshot(path): + with open(path, "wb") as fh: + fh.write(adb("exec-out", "screencap", "-p")) + return path + + +def main(argv): + cmd = argv[1] if len(argv) > 1 else "dump" + if cmd == "dump": + for n in dump(): + if n.label(): + print(n.label(), n.bounds, "click" if n.clickable else "") + elif cmd in ("tap", "find"): + n = (tap if cmd == "tap" else wait)(argv[2]) + print("match", n) + elif cmd == "shot": + print(screenshot(argv[2])) + else: + print(__doc__) + return 2 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From 18a9f582cbddd53e93d6187bc7f1f7ca6ca20553 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 21:47:34 +0200 Subject: [PATCH 20/62] Hide gender and tax code when the library hides them The administrator can now hide the gender and tax code fields. The app reads it from GET /me (hidden_fields, the keys themselves omitted), from the registration schema (profile_fields.*.visible) and from /health (registration.show_*): a hidden field is not rendered in the profile editor and never sent in PATCH /me. Servers that send none of these keep both fields, as before. Registration never asked for them and still does not. Profile re-reads the schema with /me when it comes back to the foreground. Tests: ProfileFieldVisibilityTest for the decision and the payloads; ProfileFieldsFlowTest on the device for hidden and shown fields, registration, and the birth-date picker fitting a 360dp screen. --- .../fixtures/health_registration_hidden.json | 32 +++++++++ .../assets/fixtures/me_fields_hidden.json | 17 +++++ .../assets/fixtures/me_fields_visible.json | 16 +++++ .../fixtures/registration_fields_hidden.json | 28 ++++++++ .../fixtures/registration_fields_visible.json | 28 ++++++++ .../app/staffe2e/ProfileFieldsFlowTest.kt | 66 +++++++++++++++++++ .../java/com/pinakes/app/data/model/Models.kt | 22 +++++++ .../screens/profile/ProfileFieldVisibility.kt | 22 +++++++ .../app/ui/screens/profile/ProfileScreen.kt | 11 +++- .../ui/screens/profile/ProfileViewModel.kt | 22 ++++++- .../app/staff/ProfileFieldVisibilityTest.kt | 62 +++++++++++++++++ 11 files changed, 321 insertions(+), 5 deletions(-) create mode 100644 app/src/androidTest/assets/fixtures/health_registration_hidden.json create mode 100644 app/src/androidTest/assets/fixtures/me_fields_hidden.json create mode 100644 app/src/androidTest/assets/fixtures/me_fields_visible.json create mode 100644 app/src/androidTest/assets/fixtures/registration_fields_hidden.json create mode 100644 app/src/androidTest/assets/fixtures/registration_fields_visible.json create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/ProfileFieldsFlowTest.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileFieldVisibility.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/ProfileFieldVisibilityTest.kt diff --git a/app/src/androidTest/assets/fixtures/health_registration_hidden.json b/app/src/androidTest/assets/fixtures/health_registration_hidden.json new file mode 100644 index 0000000..51fec8c --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_registration_hidden.json @@ -0,0 +1,32 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": true, + "catalog_sync": true, + "calendar_feed": true + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": true, + "registration": { + "show_sesso": false, + "show_cod_fiscale": false + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/me_fields_hidden.json b/app/src/androidTest/assets/fixtures/me_fields_hidden.json new file mode 100644 index 0000000..d789a0b --- /dev/null +++ b/app/src/androidTest/assets/fixtures/me_fields_hidden.json @@ -0,0 +1,17 @@ +{ + "data": { + "id": 1, + "nome": "Ada", + "cognome": "Admin", + "email": "ada@example.org", + "tipo_utente": "admin", + "email_verificata": true, + "data_nascita": "1980-01-01", + "hidden_fields": [ + "sesso", + "cod_fiscale" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/me_fields_visible.json b/app/src/androidTest/assets/fixtures/me_fields_visible.json new file mode 100644 index 0000000..dc5b1ff --- /dev/null +++ b/app/src/androidTest/assets/fixtures/me_fields_visible.json @@ -0,0 +1,16 @@ +{ + "data": { + "id": 1, + "nome": "Ada", + "cognome": "Admin", + "email": "ada@example.org", + "tipo_utente": "admin", + "email_verificata": true, + "sesso": "F", + "cod_fiscale": "RSSMRA80A01H501U", + "data_nascita": "1980-01-01", + "hidden_fields": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/registration_fields_hidden.json b/app/src/androidTest/assets/fixtures/registration_fields_hidden.json new file mode 100644 index 0000000..6531b22 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/registration_fields_hidden.json @@ -0,0 +1,28 @@ +{ + "data": { + "registration_enabled": true, + "builtin_fields": { + "cognome": { + "required": true, + "configurable": false + } + }, + "custom_fields": [], + "profile_fields": { + "data_nascita": { + "visible": true, + "configurable": false + }, + "sesso": { + "visible": false, + "configurable": true + }, + "cod_fiscale": { + "visible": false, + "configurable": true + } + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/registration_fields_visible.json b/app/src/androidTest/assets/fixtures/registration_fields_visible.json new file mode 100644 index 0000000..e670809 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/registration_fields_visible.json @@ -0,0 +1,28 @@ +{ + "data": { + "registration_enabled": true, + "builtin_fields": { + "cognome": { + "required": true, + "configurable": false + } + }, + "custom_fields": [], + "profile_fields": { + "data_nascita": { + "visible": true, + "configurable": false + }, + "sesso": { + "visible": true, + "configurable": true + }, + "cod_fiscale": { + "visible": true, + "configurable": true + } + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ProfileFieldsFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ProfileFieldsFlowTest.kt new file mode 100644 index 0000000..114088a --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ProfileFieldsFlowTest.kt @@ -0,0 +1,66 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Gender and tax code follow the library's "visible fields" setting. */ +class ProfileFieldsFlowTest : AppFlowTest() { + + private fun openProfileEditor() { + openTab(R.string.nav_profile) + clickUntil(text(R.string.profile_action_edit), text(R.string.profile_edit_title)) + waitText(text(R.string.profile_first_name)) + } + + @Test fun hiddenFieldsAreAbsentFromTheProfileEditor() { + launch { + json("GET me", "me_fields_hidden") + json("GET auth/registration-fields", "registration_fields_hidden") + } + openProfileEditor() + assertFalse(exists(text(R.string.profile_tax_code))) + assertFalse(exists(text(R.string.profile_gender))) + // The birth date (never hideable) is still there. + assertTrue(exists(text(R.string.profile_birth_date))) + } + + @Test fun visibleFieldsArePresentInTheProfileEditor() { + launch { + json("GET me", "me_fields_visible") + json("GET auth/registration-fields", "registration_fields_visible") + } + openProfileEditor() + waitText(text(R.string.profile_tax_code)) + waitText(text(R.string.profile_gender)) + } + + @Test fun registrationNeverAsksForHiddenFields() { + launch(health = "health_registration_hidden") { json("GET auth/registration-fields", "registration_fields_hidden") } + // Sign out to reach the login screen, then open the registration form. + deps.session().clearToken() + clickUntil(text(R.string.login_create_account), text(R.string.register_title)) + assertFalse(exists(text(R.string.profile_tax_code))) + assertFalse(exists(text(R.string.profile_gender))) + } + + @Test fun birthDatePickerMonthFitsThePhoneScreen() { + launch { json("GET me", "me_fields_visible") } + openProfileEditor() + click("1980-01-01") + waitText(text(R.string.action_save)) + val width = context.resources.displayMetrics.widthPixels.toFloat() + // Material's day cells carry the full date ("…January 15, 1980") as their text. + compose.waitUntil(10_000) { + compose.onAllNodes(androidx.compose.ui.test.hasText("1980", substring = true) and androidx.compose.ui.test.hasClickAction()) + .fetchSemanticsNodes().size >= 28 + } + val days = compose.onAllNodes(androidx.compose.ui.test.hasText("1980", substring = true) and androidx.compose.ui.test.hasClickAction()) + .fetchSemanticsNodes() + days.forEach { n -> + val b = n.boundsInWindow + assertTrue("day cell outside the screen: $b", b.left >= 0f && b.right <= width && b.width > 0f) + } + } +} 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 86b3fc7..9a1d4db 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 @@ -54,6 +54,15 @@ data class HealthPayload( @SerialName("registration_enabled") val registrationEnabled: Boolean = false, @SerialName("private_mode") val privateMode: Boolean = false, @SerialName("vapid_public_key") val vapidPublicKey: String? = null, + /** Registration settings mirror (server 0.8.5+); absent on older servers. */ + val registration: HealthRegistration? = null, +) + +/** `data.registration` of `/health`: which optional profile fields the library shows. */ +@Serializable +data class HealthRegistration( + @SerialName("show_sesso") val showSesso: Boolean? = null, + @SerialName("show_cod_fiscale") val showCodFiscale: Boolean? = null, ) @Serializable @@ -119,6 +128,17 @@ data class RegistrationFieldsPayload( // Config-driven built-ins only: keys are "cognome" | "telefono" | "indirizzo". @SerialName("builtin_fields") val builtinFields: Map = emptyMap(), @SerialName("custom_fields") val customFields: List = emptyList(), + /** + * Profile-only fields (`data_nascita`, `sesso`, `cod_fiscale`) and whether the library shows + * them. Absent on servers before 0.8.5: every field stays visible there. + */ + @SerialName("profile_fields") val profileFields: Map = emptyMap(), +) + +@Serializable +data class ProfileFieldRule( + val visible: Boolean = true, + val configurable: Boolean = false, ) @Serializable @@ -175,6 +195,8 @@ data class UserProfile( val locale: String? = null, // Instance-defined custom fields with the user's current values. @SerialName("custom_fields") val customFields: List = emptyList(), + /** Fields the library hides (`sesso`, `cod_fiscale`); their keys are omitted from `/me`. */ + @SerialName("hidden_fields") val hiddenFields: List = emptyList(), ) { val fullName: String get() = listOf(nome, cognome).filter { it.isNotBlank() }.joinToString(" ") } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileFieldVisibility.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileFieldVisibility.kt new file mode 100644 index 0000000..3241258 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileFieldVisibility.kt @@ -0,0 +1,22 @@ +package com.pinakes.app.ui.screens.profile + +import com.pinakes.app.data.model.ProfileFieldRule + +/** + * Which optional profile fields the library shows. The administrator can hide gender + * (`sesso`) and tax code (`cod_fiscale`); a hidden field is neither rendered nor sent. + * Sources, any of which hides the field: `GET /me` `hidden_fields`, the registration schema + * `profile_fields.*.visible`, `/health` `registration.show_*`. Servers that send none of them + * (before 0.8.5) keep every field visible. Pure, unit-tested. + */ +object ProfileFieldVisibility { + const val GENDER = "sesso" + const val TAX_CODE = "cod_fiscale" + + fun isVisible( + field: String, + hiddenFields: List = emptyList(), + profileFields: Map = emptyMap(), + healthShows: Boolean? = null, + ): Boolean = field !in hiddenFields && profileFields[field]?.visible != false && healthShows != false +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt index 6c4c87c..d028923 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt @@ -169,6 +169,8 @@ fun ProfileScreen( dataNascita = state.editDataNascita, codFiscale = state.editCodFiscale, sesso = state.editSesso, + showTaxCode = state.showTaxCode, + showGender = state.showGender, telefonoRequired = vm.builtinRequired("telefono"), indirizzoRequired = vm.builtinRequired("indirizzo"), customFields = editingProfile?.customFields.orEmpty(), @@ -576,6 +578,8 @@ private fun EditProfileDialog( dataNascita: String, codFiscale: String, sesso: String, + showTaxCode: Boolean, + showGender: Boolean, telefonoRequired: Boolean, indirizzoRequired: Boolean, customFields: List, @@ -623,8 +627,11 @@ private fun EditProfileDialog( label = stringResource(R.string.profile_birth_date), onValueChange = onDataNascita, ) - PinakesTextField(value = codFiscale, onValueChange = onCodFiscale, label = stringResource(R.string.profile_tax_code), modifier = Modifier.fillMaxWidth()) - GenderField(value = sesso, onValueChange = onSesso) + // Hidden by the library: not rendered (and not sent, see ProfileViewModel.saveEdit). + if (showTaxCode) { + PinakesTextField(value = codFiscale, onValueChange = onCodFiscale, label = stringResource(R.string.profile_tax_code), modifier = Modifier.fillMaxWidth()) + } + if (showGender) GenderField(value = sesso, onValueChange = onSesso) // Instance-defined custom fields, pre-filled from the user's current values. // Rendered with required=false on purpose: the server accepts blank custom // values on PATCH /me for existing members (see ProfileUiState diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt index c3e381e..d7dbb7b 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt @@ -3,6 +3,7 @@ package com.pinakes.app.ui.screens.profile import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.pinakes.app.data.model.BuiltinFieldRule +import com.pinakes.app.data.model.ProfileFieldRule import com.pinakes.app.data.model.CalendarStatus import com.pinakes.app.data.repository.CalendarRepository import com.pinakes.app.data.model.DeviceItem @@ -39,6 +40,8 @@ data class ProfileUiState( val editCustomValues: Map = emptyMap(), // Required-ness for telefono/indirizzo comes from the registration schema. val builtinFields: Map = emptyMap(), + // Profile-only field visibility from the registration schema (gender, tax code). + val profileFields: Map = emptyMap(), // Inline validation error shown in the edit dialog (e.g. a required field // left blank) — surfaced client-side instead of relying on a server 422. val editErrorRes: Int? = null, @@ -59,6 +62,14 @@ data class ProfileUiState( val calendar: UiState = UiState.Loading, val calendarBusy: Boolean = false, ) { + private val hiddenFields: List get() = (profile as? UiState.Success)?.data?.hiddenFields.orEmpty() + + /** Gender is shown unless the library hides it (`hidden_fields` or the schema). */ + val showGender: Boolean get() = ProfileFieldVisibility.isVisible(ProfileFieldVisibility.GENDER, hiddenFields, profileFields) + + /** Tax code is shown unless the library hides it. */ + val showTaxCode: Boolean get() = ProfileFieldVisibility.isVisible(ProfileFieldVisibility.TAX_CODE, hiddenFields, profileFields) + /** Profile-side required-ness defaults to false until discovery succeeds. */ fun builtinRequired(key: String): Boolean = builtinFields[key]?.required ?: false @@ -95,7 +106,9 @@ class ProfileViewModel @Inject constructor( fun loadSchema() { viewModelScope.launch { when (val res = auth.registrationFields()) { - is ApiResult.Success -> _state.update { it.copy(builtinFields = res.data.builtinFields) } + is ApiResult.Success -> _state.update { + it.copy(builtinFields = res.data.builtinFields, profileFields = res.data.profileFields) + } is ApiResult.Failure -> { /* keep defaults */ } } } @@ -129,6 +142,8 @@ class ProfileViewModel @Inject constructor( /** Re-read the profile without the loading state (on resume); failures keep what is shown. */ fun refreshQuietly() { + // The field-visibility settings may have changed on the server too. + loadSchema() viewModelScope.launch { when (val res = profile.me()) { is ApiResult.Success -> { @@ -211,8 +226,9 @@ class ProfileViewModel @Inject constructor( telefono = changed(s.editTelefono, original?.telefono), indirizzo = changed(s.editIndirizzo, original?.indirizzo), dataNascita = changed(s.editDataNascita, original?.dataNascita), - codFiscale = changed(s.editCodFiscale, original?.codFiscale), - sesso = changed(s.editSesso, original?.sesso), + // A field the library hides is never sent (the server would ignore it anyway). + codFiscale = if (s.showTaxCode) changed(s.editCodFiscale, original?.codFiscale) else null, + sesso = if (s.showGender) changed(s.editSesso, original?.sesso) else null, customFields = customPatch.takeIf { it.isNotEmpty() }, ) diff --git a/app/src/test/java/com/pinakes/app/staff/ProfileFieldVisibilityTest.kt b/app/src/test/java/com/pinakes/app/staff/ProfileFieldVisibilityTest.kt new file mode 100644 index 0000000..f0c33ca --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/ProfileFieldVisibilityTest.kt @@ -0,0 +1,62 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.ProfileFieldRule +import com.pinakes.app.data.model.RegistrationFieldsPayload +import com.pinakes.app.data.model.UserProfile +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.screens.profile.ProfileFieldVisibility +import com.pinakes.app.ui.screens.profile.ProfileFieldVisibility.GENDER +import com.pinakes.app.ui.screens.profile.ProfileFieldVisibility.TAX_CODE +import com.pinakes.app.ui.screens.profile.ProfileUiState +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The admin can hide gender and tax code; older servers say nothing and keep them visible. */ +class ProfileFieldVisibilityTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun visibleWhenTheServerSaysNothing() { + assertTrue(ProfileFieldVisibility.isVisible(GENDER)) + assertTrue(ProfileFieldVisibility.isVisible(TAX_CODE, emptyList(), emptyMap(), null)) + } + + @Test fun anySourceHidesTheField() { + assertFalse(ProfileFieldVisibility.isVisible(GENDER, hiddenFields = listOf("sesso"))) + assertFalse(ProfileFieldVisibility.isVisible(TAX_CODE, profileFields = mapOf("cod_fiscale" to ProfileFieldRule(visible = false)))) + assertFalse(ProfileFieldVisibility.isVisible(GENDER, healthShows = false)) + // Hiding one field leaves the other alone. + assertTrue(ProfileFieldVisibility.isVisible(TAX_CODE, hiddenFields = listOf("sesso"))) + } + + @Test fun explicitlyVisibleStaysVisible() { + assertTrue(ProfileFieldVisibility.isVisible(GENDER, emptyList(), mapOf("sesso" to ProfileFieldRule(visible = true)), true)) + } + + @Test fun profileStateFollowsMeAndTheSchema() { + val hidden = ProfileUiState(profile = UiState.Success(UserProfile(hiddenFields = listOf("sesso", "cod_fiscale")))) + assertFalse(hidden.showGender) + assertFalse(hidden.showTaxCode) + val schemaHidden = ProfileUiState(profileFields = mapOf("cod_fiscale" to ProfileFieldRule(visible = false))) + assertTrue(schemaHidden.showGender) + assertFalse(schemaHidden.showTaxCode) + assertTrue(ProfileUiState().showGender && ProfileUiState().showTaxCode) + } + + @Test fun payloadsDecodeTheNewKeysAndToleratePreviousServers() { + val me = json.decodeFromString("""{"id":1,"nome":"A","hidden_fields":["sesso","cod_fiscale"]}""") + assertEquals(listOf("sesso", "cod_fiscale"), me.hiddenFields) + assertNull(me.sesso) + val old = json.decodeFromString("""{"id":1,"nome":"A","sesso":"F"}""") + assertTrue(old.hiddenFields.isEmpty()) + val reg = json.decodeFromString( + """{"registration_enabled":true,"profile_fields":{"sesso":{"visible":false,"configurable":true},"data_nascita":{"visible":true,"configurable":false}}}""" + ) + assertEquals(false, reg.profileFields["sesso"]?.visible) + assertTrue(json.decodeFromString("""{"registration_enabled":true}""").profileFields.isEmpty()) + } +} From db5a2d4c8ea43bbd9655872c96a390a593aed728 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 21:47:35 +0200 Subject: [PATCH 21/62] Give authors and publishers a page with an info card and their books Staff Authors and Publishers rows now open a detail page: a padded info card (books in the catalogue, life dates, nationality, website, GND id or the publisher's contacts, and created/updated when the server sends them) and the record's books from GET /staff/books?author_id= (publishers by name), drawn with the whole-cover book cards. Editing moved to the page's edit action. The catalogue filtered by an author gets the same kind of card above the results. The pending cover preview in the book form and the covers in My reviews now fit instead of cropping. --- .../assets/fixtures/staff_author_3.json | 19 ++ .../assets/fixtures/staff_authors.json | 17 ++ .../app/staffe2e/StaffRecordDetailFlowTest.kt | 29 ++ .../com/pinakes/app/data/model/StaffModels.kt | 7 + .../com/pinakes/app/data/network/StaffApi.kt | 2 + .../app/data/repository/StaffRepository.kt | 12 +- .../app/ui/navigation/PinakesNavHost.kt | 26 +- .../com/pinakes/app/ui/navigation/Routes.kt | 5 + .../app/ui/screens/reviews/MyReviewsScreen.kt | 2 +- .../app/ui/screens/search/SearchScreen.kt | 28 +- .../ui/screens/staff/StaffBookEditScreen.kt | 2 +- .../screens/staff/StaffRecordDetailScreen.kt | 268 ++++++++++++++++++ .../ui/screens/staff/StaffRecordsScreens.kt | 23 +- i18n/de.json | 9 +- i18n/en.json | 9 +- i18n/fr.json | 9 +- i18n/it.json | 9 +- 17 files changed, 457 insertions(+), 19 deletions(-) create mode 100644 app/src/androidTest/assets/fixtures/staff_author_3.json create mode 100644 app/src/androidTest/assets/fixtures/staff_authors.json create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/StaffRecordDetailFlowTest.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordDetailScreen.kt diff --git a/app/src/androidTest/assets/fixtures/staff_author_3.json b/app/src/androidTest/assets/fixtures/staff_author_3.json new file mode 100644 index 0000000..41fc805 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_author_3.json @@ -0,0 +1,19 @@ +{ + "data": { + "id": 3, + "name": "Virginia Woolf", + "pseudonym": null, + "born": "1882-01-25", + "died": "1941-03-28", + "nationality": "britannica", + "biography": null, + "website": null, + "photo_url": null, + "gnd_id": "118635506", + "books_count": 1, + "created_at": "2026-01-10 09:00:00", + "updated_at": "2026-10-01 18:30:00" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_authors.json b/app/src/androidTest/assets/fixtures/staff_authors.json new file mode 100644 index 0000000..c75a7b4 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_authors.json @@ -0,0 +1,17 @@ +{ + "data": [ + { + "id": 3, + "name": "Virginia Woolf", + "pseudonym": null, + "label": "Virginia Woolf (1882–1941)", + "born": "1882-01-25", + "died": "1941-03-28" + } + ], + "meta": { + "count": 1, + "limit": 20 + }, + "error": null +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffRecordDetailFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffRecordDetailFlowTest.kt new file mode 100644 index 0000000..a82e46c --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffRecordDetailFlowTest.kt @@ -0,0 +1,29 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasTestTag +import com.pinakes.app.R +import org.junit.Test + +/** Staff → Authors → an author: padded info card (count, dates) and the author's books. */ +class StaffRecordDetailFlowTest : AppFlowTest() { + + @Test fun authorDetailShowsTheInfoCardAndTheBooks() { + launch { + json("GET staff/authors", "staff_authors") + json("GET staff/authors/3", "staff_author_3") + } + openTab(R.string.nav_profile) + clickUntil(text(R.string.profile_action_staff), text(R.string.staff_scan_subtitle)) + click(text(R.string.staff_authors)) + click("Virginia Woolf") + waitFor(hasTestTag("record-info-card")) + waitText(text(R.string.staff_record_books_count)) + waitText(text(R.string.staff_record_created)) + waitText("118635506") + // The book list (GET /staff/books?author_id=3) uses the whole-cover cards. + waitText("Orlando") + waitRequest("GET", "staff/books") + val query = api.recorded("GET", "staff/books").last().requestUrl?.queryParameter("author_id") + org.junit.Assert.assertEquals("3", query) + } +} diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt index 1312b42..94f39e3 100644 --- a/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt @@ -283,6 +283,10 @@ data class StaffAuthor( val website: String? = null, @SerialName("photo_url") val photoUrl: String? = null, @SerialName("gnd_id") val gndId: String? = null, + // Record facts shown on the detail card when the server sends them (additive). + @SerialName("books_count") val booksCount: Int? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, ) @Serializable @@ -305,6 +309,9 @@ data class StaffPublisher( val address: String? = null, val phone: String? = null, val email: String? = null, + @SerialName("books_count") val booksCount: Int? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, ) @Serializable diff --git a/app/src/main/java/com/pinakes/app/data/network/StaffApi.kt b/app/src/main/java/com/pinakes/app/data/network/StaffApi.kt index 37a52be..affb14e 100644 --- a/app/src/main/java/com/pinakes/app/data/network/StaffApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/StaffApi.kt @@ -54,6 +54,8 @@ interface StaffApi { @Query("cursor") cursor: String? = null, @Query("limit") limit: Int? = null, @Query("sort") sort: String? = null, + @Query("author_id") authorId: Int? = null, + @Query("publisher") publisher: String? = null, ): Envelope> @GET("staff/books/{id}") diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt index 7ff2231..b7b5873 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt @@ -61,8 +61,16 @@ class StaffRepository( suspend fun isbnLookup(code: String): ApiResult = staffCall { api.isbnLookup(code) } - suspend fun books(query: String?, cursor: String?, limit: Int = 30): ApiResult = - when (val res = staffCall { api.books(q = query?.takeIf { it.isNotBlank() }, cursor = cursor, limit = limit) }) { + suspend fun books( + query: String?, + cursor: String?, + limit: Int = 30, + authorId: Int? = null, + publisher: String? = null, + ): ApiResult = + when (val res = staffCall { + api.books(q = query?.takeIf { it.isNotBlank() }, cursor = cursor, limit = limit, authorId = authorId, publisher = publisher) + }) { is ApiResult.Success -> { val meta = res.meta val next = meta?.nextCursor?.takeIf { it.isNotBlank() && meta.hasMore != false } diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index 74b1bdb..39d96f5 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -43,6 +43,8 @@ import com.pinakes.app.ui.screens.staff.StaffBooksScreen import com.pinakes.app.ui.screens.staff.StaffGenresScreen import com.pinakes.app.ui.screens.staff.StaffHubScreen import com.pinakes.app.ui.screens.staff.StaffPublishersScreen +import com.pinakes.app.ui.screens.staff.StaffRecordDetailScreen +import com.pinakes.app.ui.screens.staff.RecordKind import com.pinakes.app.ui.screens.staff.StaffShelvesScreen import com.pinakes.app.ui.screens.staff.scan.StaffScanScreen @@ -198,10 +200,26 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { ) } composable(Routes.STAFF_AUTHORS, enterTransition = slideIn, popExitTransition = slideOut) { - StaffAuthorsScreen(onNavigateUp = { navController.popBackStack() }) + StaffAuthorsScreen(onNavigateUp = { navController.popBackStack() }, onOpen = { navController.navigate(Routes.staffAuthor(it)) }) } composable(Routes.STAFF_PUBLISHERS, enterTransition = slideIn, popExitTransition = slideOut) { - StaffPublishersScreen(onNavigateUp = { navController.popBackStack() }) + StaffPublishersScreen(onNavigateUp = { navController.popBackStack() }, onOpen = { navController.navigate(Routes.staffPublisher(it)) }) + } + composable( + Routes.STAFF_AUTHOR, + arguments = listOf(navArgument(Routes.ARG_RECORD_ID) { type = NavType.IntType }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StaffRecordDetailScreen(RecordKind.AUTHOR, onNavigateUp = { navController.popBackStack() }, + onOpenBook = { navController.navigate(Routes.staffBookEdit(it)) }) + } + composable( + Routes.STAFF_PUBLISHER, + arguments = listOf(navArgument(Routes.ARG_RECORD_ID) { type = NavType.IntType }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StaffRecordDetailScreen(RecordKind.PUBLISHER, onNavigateUp = { navController.popBackStack() }, + onOpenBook = { navController.navigate(Routes.staffBookEdit(it)) }) } composable(Routes.STAFF_GENRES, enterTransition = slideIn, popExitTransition = slideOut) { StaffGenresScreen(onNavigateUp = { navController.popBackStack() }) @@ -287,8 +305,10 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { composable(Routes.AUTHOR_WORKS, arguments = listOf(navArgument("author") { defaultValue = "" }, navArgument("authorId") { type = NavType.IntType; defaultValue = 0 })) { entry -> androidx.compose.material3.Scaffold(topBar = { com.pinakes.app.ui.components.PinakesTopBar(entry.arguments?.getString("author").orEmpty(), onNavigateUp = { navController.popBackStack() }) }) { padding -> androidx.compose.foundation.layout.Box(androidx.compose.ui.Modifier.padding(padding)) { + val authorName = entry.arguments?.getString("author").orEmpty() com.pinakes.app.ui.screens.search.SearchScreen(onBookClick = { navController.navigate(Routes.bookDetail(it)) }, - onArticleClick = { navController.navigate(Routes.standaloneArticle(it)) }, initialAuthor = entry.arguments?.getString("author"), initialAuthorId = entry.arguments?.getInt("authorId")) + onArticleClick = { navController.navigate(Routes.standaloneArticle(it)) }, initialAuthor = entry.arguments?.getString("author"), initialAuthorId = entry.arguments?.getInt("authorId"), + header = { count, more -> com.pinakes.app.ui.screens.search.AuthorInfoCard(authorName, count, more) }) } } } diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt index c9e19ec..1d67672 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt @@ -68,6 +68,11 @@ object Routes { fun staffBookEdit(bookId: Int): String = "staff/books/$bookId/edit" const val STAFF_AUTHORS = "staff/authors" const val STAFF_PUBLISHERS = "staff/publishers" + const val ARG_RECORD_ID = "recordId" + const val STAFF_AUTHOR = "staff/authors/{recordId}" + fun staffAuthor(id: Int): String = "staff/authors/$id" + const val STAFF_PUBLISHER = "staff/publishers/{recordId}" + fun staffPublisher(id: Int): String = "staff/publishers/$id" const val STAFF_GENRES = "staff/genres" const val STAFF_SHELVES = "staff/shelves" diff --git a/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt index fc4cfa6..76b2dd3 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt @@ -148,7 +148,7 @@ private fun MyReviewRow( SubcomposeAsyncImage( model = review.coverUrl, contentDescription = review.bookTitle, - contentScale = ContentScale.Crop, + contentScale = ContentScale.Fit, modifier = Modifier.size(width = 48.dp, height = 72.dp), error = { Icon( diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt index a8656b6..a4e1617 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchScreen.kt @@ -1,5 +1,6 @@ package com.pinakes.app.ui.screens.search +import androidx.compose.ui.platform.testTag import androidx.compose.animation.Crossfade import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable @@ -72,7 +73,9 @@ import com.pinakes.app.ui.theme.Spacing @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchScreen(onBookClick: (Int) -> Unit, initialQuery: String? = null, onQueryConsumed: () -> Unit = {}, - onArticleClick: (Int) -> Unit = {}, onWantedClick: (Int) -> Unit = {}, onArchiveClick: (Int) -> Unit = {}, initialAuthor: String? = null, initialAuthorId: Int? = null) { + onArticleClick: (Int) -> Unit = {}, onWantedClick: (Int) -> Unit = {}, onArchiveClick: (Int) -> Unit = {}, initialAuthor: String? = null, initialAuthorId: Int? = null, + /** Shown above the results (the author page): receives the titles loaded and whether more exist. */ + header: (@Composable (count: Int, hasMore: Boolean) -> Unit)? = null) { val vm: SearchViewModel = hiltViewModel() val state by vm.state.collectAsStateWithLifecycle() val app: AppViewModel = hiltViewModel() @@ -237,6 +240,7 @@ fun SearchScreen(onBookClick: (Int) -> Unit, initialQuery: String? = null, onQue ), verticalArrangement = Arrangement.spacedBy(if (gridView) 28.dp else Spacing.md), ) { + if (header != null) item(key = "page-header") { header(state.items.size, state.hasMore) } item { val browsingAll = state.query.isBlank() && !state.hasActiveFilters val header = when { @@ -391,3 +395,25 @@ private fun SearchCollectionRow(title: String, metadata: String, cover: String?, } } } + +/** + * The author page header in the catalogue: a padded card with the name and how many titles the + * library has (a lower bound with "+" while more pages exist). The results below use the + * regular cards, which always show the whole cover. + */ +@Composable +fun AuthorInfoCard(name: String, count: Int, hasMore: Boolean) { + androidx.compose.material3.Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(bottom = Spacing.sm).testTag("author-info-card"), + ) { + Column(Modifier.padding(com.pinakes.app.ui.theme.CardPadding)) { + Text(name, style = MaterialTheme.typography.headlineSmall.copy(fontFamily = com.pinakes.app.ui.theme.Fraunces)) + com.pinakes.app.ui.components.MetadataRow( + stringResource(R.string.staff_record_books_count), + count.toString() + if (hasMore) "+" else "", + ) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt index 55e1c50..6e0f6a1 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt @@ -408,7 +408,7 @@ private fun CoverSection(state: StaffBookEditUiState, vm: StaffBookEditViewModel val coverModifier = Modifier.width(96.dp).aspectRatio(2f / 3f) when (pending) { is PendingCover.FromImage -> AsyncImage( - model = pending.uri, contentDescription = null, contentScale = ContentScale.Crop, + model = pending.uri, contentDescription = null, contentScale = ContentScale.Fit, modifier = coverModifier.clip(RoundedCornerShape(4.dp)), ) is PendingCover.FromUrl -> BookCover(state.form.title, pending.url, coverModifier, compact = true) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordDetailScreen.kt new file mode 100644 index 0000000..b52e700 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordDetailScreen.kt @@ -0,0 +1,268 @@ +package com.pinakes.app.ui.screens.staff + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.LibraryBooks +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.StaffBookRow +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage +import com.pinakes.app.ui.components.BookCard +import com.pinakes.app.ui.components.EmptyState +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.MetadataRow +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.theme.CardPadding +import com.pinakes.app.ui.theme.Fraunces +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** Name, facts and record dates of an author or publisher, before its books are loaded. */ +data class RecordHead(val name: String, val facts: List, val booksCount: Int?, val createdAt: String?, val updatedAt: String?) + +/** One labelled fact of the info card (label resource + value). */ +data class RecordFact(val labelRes: Int, val value: String) + +data class RecordDetailUiState( + val loading: Boolean = true, + val error: String? = null, + val errorOffline: Boolean = false, + val name: String = "", + val facts: List = emptyList(), + val books: List = emptyList(), + /** True when the list stopped at [MAX_PAGES] pages: the count is a lower bound ("50+"). */ + val booksTruncated: Boolean = false, + /** The server's own count when it sends one (newer servers), else the loaded list size. */ + val booksCount: Int? = null, + val forbidden: Boolean = false, +) { + val displayedCount: Int get() = booksCount ?: books.size +} + +/** + * Author or publisher detail: a padded info card (books count, record facts, created/updated + * when the server sends them) and the record's books with whole covers. Nav args: `recordId` + * plus the route decides the kind ([RecordKind]). + */ +@HiltViewModel +class StaffRecordDetailViewModel @Inject constructor( + saved: SavedStateHandle, + private val staff: StaffRepository, +) : ViewModel() { + private val id: Int = saved.get(Routes.ARG_RECORD_ID) ?: 0 + val recordId: Int get() = id + private val _state = MutableStateFlow(RecordDetailUiState()) + val state: StateFlow = _state.asStateFlow() + private var kind: RecordKind = RecordKind.AUTHOR + + fun start(kind: RecordKind) { + if (this.kind == kind && !_state.value.loading) return + this.kind = kind + load() + } + + fun load() { + _state.update { it.copy(loading = it.name.isBlank(), error = null) } + viewModelScope.launch { + val record: ApiResult = when (kind) { + RecordKind.AUTHOR -> when (val res = staff.author(id)) { + is ApiResult.Success -> res.data.let { a -> + val life = listOfNotNull(a.born?.takeIf { it.isNotBlank() }, a.died?.takeIf { it.isNotBlank() }).joinToString(" – ") + ApiResult.Success(RecordHead(a.name, listOfNotNull( + a.pseudonym?.takeIf { it.isNotBlank() }?.let { RecordFact(R.string.staff_field_pseudonym, it) }, + life.takeIf { it.isNotBlank() }?.let { RecordFact(R.string.staff_record_life, it) }, + a.nationality?.takeIf { it.isNotBlank() }?.let { RecordFact(R.string.staff_field_nationality, it) }, + a.website?.takeIf { it.isNotBlank() }?.let { RecordFact(R.string.staff_field_website, it) }, + a.gndId?.takeIf { it.isNotBlank() }?.let { RecordFact(R.string.staff_field_gnd, it) }, + ), a.booksCount, a.createdAt, a.updatedAt)) + } + is ApiResult.Failure -> res + } + RecordKind.PUBLISHER -> when (val res = staff.publisher(id)) { + is ApiResult.Success -> res.data.let { p -> + ApiResult.Success(RecordHead(p.name, listOfNotNull( + p.website?.takeIf { it.isNotBlank() }?.let { RecordFact(R.string.staff_field_website, it) }, + p.address?.takeIf { it.isNotBlank() }?.let { RecordFact(R.string.staff_field_address, it) }, + p.phone?.takeIf { it.isNotBlank() }?.let { RecordFact(R.string.staff_field_phone, it) }, + p.email?.takeIf { it.isNotBlank() }?.let { RecordFact(R.string.staff_field_email, it) }, + ), p.booksCount, p.createdAt, p.updatedAt)) + } + is ApiResult.Failure -> res + } + } + when (record) { + is ApiResult.Failure -> _state.update { + it.copy(loading = false, forbidden = record.isForbidden(), error = record.serverMessage() ?: "", errorOffline = record.isOffline) + } + is ApiResult.Success -> { + val head = record.data + val name = head.name + val dated = head.facts + listOfNotNull( + head.createdAt?.takeIf { it.isNotBlank() }?.let { RecordFact(R.string.staff_record_created, DateFormat.dateTime(it)) }, + head.updatedAt?.takeIf { it.isNotBlank() }?.let { RecordFact(R.string.staff_record_updated, DateFormat.dateTime(it)) }, + ) + val count = head.booksCount + val (books, truncated) = loadBooks(name) + _state.update { + it.copy(loading = false, name = name, facts = dated, booksCount = count, books = books, booksTruncated = truncated) + } + } + } + } + } + + /** Every page of the record's books (bounded): authors by id, publishers by name. */ + private suspend fun loadBooks(name: String): Pair, Boolean> { + val out = ArrayList() + var cursor: String? = null + var pages = 0 + do { + val res = if (kind == RecordKind.AUTHOR) staff.books(null, cursor, limit = 50, authorId = id) + else staff.books(null, cursor, limit = 50, publisher = name) + if (res !is ApiResult.Success) break + out += res.data.items + cursor = res.data.nextCursor + } while (cursor != null && ++pages < MAX_PAGES) + return out.distinctBy { it.id } to (cursor != null) + } + + private companion object { + const val MAX_PAGES = 10 + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffRecordDetailScreen(kind: RecordKind, onNavigateUp: () -> Unit, onOpenBook: (Int) -> Unit) { + val vm: StaffRecordDetailViewModel = hiltViewModel() + // The list ViewModel of the same kind drives the shared editor sheet. + val editor: RecordsViewModel = if (kind == RecordKind.AUTHOR) hiltViewModel() else hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val editorState by editor.state.collectAsStateWithLifecycle() + val snackbarHost = remember { SnackbarHostState() } + LaunchedEffect(kind) { vm.start(kind) } + LaunchedEffect(editorState.savedCount) { if (editorState.savedCount > 0) vm.load() } + val message = editorState.snackbarRes?.let { stringResource(it) } + LaunchedEffect(message) { message?.let { snackbarHost.showSnackbar(it); editor.consumeSnackbar() } } + ForbiddenExit(state.forbidden || editorState.forbidden, onNavigateUp) + + Scaffold( + topBar = { + PinakesTopBar( + title = stringResource(if (kind == RecordKind.AUTHOR) R.string.staff_record_author else R.string.staff_record_publisher), + onNavigateUp = onNavigateUp, + actions = { + IconButton(onClick = { editor.startEdit(vm.recordId) }, enabled = !state.loading && state.error == null) { + Icon(Icons.Outlined.Edit, contentDescription = stringResource(R.string.staff_record_edit)) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHost) }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + when { + state.loading -> LoadingState() + state.error != null -> ErrorState( + message = if (state.errorOffline) stringResource(R.string.offline_generic) + else state.error?.takeIf { it.isNotBlank() } ?: stringResource(R.string.staff_error_load), + onRetry = vm::load, + ) + else -> LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = Spacing.lg, vertical = Spacing.md), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + item(key = "card") { RecordInfoCard(state) } + item(key = "books-title") { FormSectionTitle(stringResource(R.string.staff_books)) } + if (state.books.isEmpty()) { + item(key = "empty") { + EmptyState( + title = stringResource(R.string.staff_record_no_books), + icon = Icons.AutoMirrored.Outlined.LibraryBooks, + modifier = Modifier.fillMaxWidth(), + ) + } + } + items(state.books, key = { it.id }) { book -> + // BookCard draws the whole cover (ContentScale.Fit in a 2:3 box): never cropped. + BookCard( + title = book.title, + author = book.author.orEmpty(), + coverUrl = book.coverUrl, + status = null, + year = book.year?.toString(), + publisher = book.publisher, + subtitle = book.subtitle, + mediaType = book.mediaType, + onClick = { onOpenBook(book.id) }, + ) + } + } + } + } + } + RecordDialogs(editorState, editor) +} + +@Composable +private fun RecordInfoCard(state: RecordDetailUiState) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().testTag("record-info-card"), + ) { + Column(Modifier.padding(CardPadding)) { + Text(state.name, style = MaterialTheme.typography.headlineSmall.copy(fontFamily = Fraunces), color = MaterialTheme.colorScheme.onSurface) + val count = state.displayedCount.toString() + if (state.booksTruncated && state.booksCount == null) "+" else "" + MetadataRow(stringResource(R.string.staff_record_books_count), count) + state.facts.forEach { fact -> + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + MetadataRow(stringResource(fact.labelRes), fact.value) + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt index 82e5904..2d280eb 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt @@ -113,6 +113,8 @@ data class RecordsUiState( val duplicate: IdName? = null, val snackbarRes: Int? = null, val forbidden: Boolean = false, + /** Bumped after every successful save, so a detail page can reload its record. */ + val savedCount: Int = 0, ) private fun String.orNullIfBlank(): String? = trim().takeIf { it.isNotEmpty() } @@ -204,7 +206,7 @@ abstract class RecordsViewModel(protected val staff: StaffRepository, kind: Reco viewModelScope.launch { when (val res = save(form)) { is ApiResult.Success -> { - _state.update { it.copy(saving = false, editing = null, snackbarRes = R.string.staff_record_saved) } + _state.update { it.copy(saving = false, editing = null, snackbarRes = R.string.staff_record_saved, savedCount = it.savedCount + 1) } reload() } is ApiResult.Failure -> _state.update { @@ -280,20 +282,20 @@ class StaffPublishersViewModel @Inject constructor(staff: StaffRepository) : Rec } @Composable -fun StaffAuthorsScreen(onNavigateUp: () -> Unit) { +fun StaffAuthorsScreen(onNavigateUp: () -> Unit, onOpen: (Int) -> Unit) { val vm: StaffAuthorsViewModel = hiltViewModel() - RecordsScreen(vm, stringResource(R.string.staff_authors), Icons.Outlined.Person, onNavigateUp) + RecordsScreen(vm, stringResource(R.string.staff_authors), Icons.Outlined.Person, onNavigateUp, onOpen) } @Composable -fun StaffPublishersScreen(onNavigateUp: () -> Unit) { +fun StaffPublishersScreen(onNavigateUp: () -> Unit, onOpen: (Int) -> Unit) { val vm: StaffPublishersViewModel = hiltViewModel() - RecordsScreen(vm, stringResource(R.string.staff_publishers), Icons.Outlined.Business, onNavigateUp) + RecordsScreen(vm, stringResource(R.string.staff_publishers), Icons.Outlined.Business, onNavigateUp, onOpen) } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun RecordsScreen(vm: RecordsViewModel, title: String, icon: ImageVector, onNavigateUp: () -> Unit) { +private fun RecordsScreen(vm: RecordsViewModel, title: String, icon: ImageVector, onNavigateUp: () -> Unit, onOpen: (Int) -> Unit) { val state by vm.state.collectAsStateWithLifecycle() val snackbarHost = remember { SnackbarHostState() } ForbiddenExit(state.forbidden, onNavigateUp) @@ -328,7 +330,8 @@ private fun RecordsScreen(vm: RecordsViewModel, title: String, icon: ImageVector contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, top = Spacing.sm, bottom = 96.dp), ) { items(state.items, key = { it.id }) { row -> - StaffActionRow(icon, row.name, row.detail) { vm.startEdit(row.id) } + // Opens the detail page (info card + the record's books); edit is there. + StaffActionRow(icon, row.name, row.detail) { onOpen(row.id) } } } } @@ -336,6 +339,12 @@ private fun RecordsScreen(vm: RecordsViewModel, title: String, icon: ImageVector } } + RecordDialogs(state, vm) +} + +/** The editor sheet and the duplicate-name dialog, shared by the list and the detail page. */ +@Composable +internal fun RecordDialogs(state: RecordsUiState, vm: RecordsViewModel) { if (state.editing != null) RecordEditorSheet(state, vm) state.duplicate?.let { existing -> ConfirmDialog( diff --git a/i18n/de.json b/i18n/de.json index 695204d..07f8bc1 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -821,5 +821,12 @@ "offline_availability_hidden": "Sie sind offline: Verfügbarkeit und Ausleihaktionen sind ausgeblendet, bis die Verbindung zurück ist.", "staff_search_authors": "Autoren suchen", "staff_search_publishers": "Verlage suchen", - "staff_search_genres": "Genres suchen" + "staff_search_genres": "Genres suchen", + "staff_record_author": "Autor", + "staff_record_publisher": "Verlag", + "staff_record_books_count": "Bücher im Katalog", + "staff_record_life": "Lebensdaten", + "staff_record_created": "Erstellt", + "staff_record_updated": "Aktualisiert", + "staff_record_no_books": "Noch keine Bücher im Katalog" } diff --git a/i18n/en.json b/i18n/en.json index 16b32f2..ad8ce4d 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -821,5 +821,12 @@ "offline_availability_hidden": "You are offline: availability and loan actions are hidden until the connection is back.", "staff_search_authors": "Search authors", "staff_search_publishers": "Search publishers", - "staff_search_genres": "Search genres" + "staff_search_genres": "Search genres", + "staff_record_author": "Author", + "staff_record_publisher": "Publisher", + "staff_record_books_count": "Books in the catalogue", + "staff_record_life": "Life", + "staff_record_created": "Created", + "staff_record_updated": "Updated", + "staff_record_no_books": "No books in the catalogue yet" } diff --git a/i18n/fr.json b/i18n/fr.json index 88107f8..72398e8 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -821,5 +821,12 @@ "offline_availability_hidden": "Vous êtes hors ligne : la disponibilité et les actions de prêt sont masquées jusqu’au retour de la connexion.", "staff_search_authors": "Rechercher des auteurs", "staff_search_publishers": "Rechercher des éditeurs", - "staff_search_genres": "Rechercher des genres" + "staff_search_genres": "Rechercher des genres", + "staff_record_author": "Auteur", + "staff_record_publisher": "Éditeur", + "staff_record_books_count": "Livres au catalogue", + "staff_record_life": "Vie", + "staff_record_created": "Créé", + "staff_record_updated": "Mis à jour", + "staff_record_no_books": "Aucun livre au catalogue pour l’instant" } diff --git a/i18n/it.json b/i18n/it.json index 919461a..3655a5f 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -821,5 +821,12 @@ "offline_availability_hidden": "Sei offline: disponibilità e azioni di prestito sono nascoste finché la connessione non torna.", "staff_search_authors": "Cerca autori", "staff_search_publishers": "Cerca editori", - "staff_search_genres": "Cerca generi" + "staff_search_genres": "Cerca generi", + "staff_record_author": "Autore", + "staff_record_publisher": "Editore", + "staff_record_books_count": "Libri in catalogo", + "staff_record_life": "Vita", + "staff_record_created": "Creato", + "staff_record_updated": "Aggiornato", + "staff_record_no_books": "Ancora nessun libro in catalogo" } From 4ccf9cee7b49d91c76ae4475c692c0e191fa6dcf Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 21:47:35 +0200 Subject: [PATCH 22/62] Fit the loan calendar on phones The loan request used Material's DatePickerDialog, which is a fixed 360dp wide with a capped height, and day cells were a fixed 40dp: on 320-360dp phones the month was clipped and cells ran into each other. The request is now a plain dialog sized to the screen (16dp margins, at most 420dp, scrollable), day cells fill their column (at most 44dp), weekday headers share the grid's spacing, and the legend wraps. When availability cannot be loaded the same grid lets the user pick any future day instead of a second date picker. CalendarLayoutTest checks the 7 weekday columns, that every day cell stays inside the screen and inside a 320dp column, and month navigation. --- .../com/pinakes/app/CalendarLayoutTest.kt | 87 +++++++++ .../app/ui/screens/detail/BookDetailScreen.kt | 183 ++++++++---------- .../app/ui/screens/detail/LoanCalendar.kt | 43 +++- 3 files changed, 201 insertions(+), 112 deletions(-) create mode 100644 app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt diff --git a/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt b/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt new file mode 100644 index 0000000..189db7b --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/CalendarLayoutTest.kt @@ -0,0 +1,87 @@ +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.geometry.Rect +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onChildren +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.unit.dp +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.LoanCalendar +import com.pinakes.app.ui.screens.detail.LoanRequestSheet +import com.pinakes.app.ui.theme.PinakesTheme +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import java.time.LocalDate +import java.time.YearMonth + +/** + * The loan calendar's month grid fits a phone: 7 weekday columns and every day of the month + * inside the screen (and inside a 320dp column), months navigable. The test device is 360dp + * wide. + */ +class CalendarLayoutTest { + @get:Rule val compose = createComposeRule() + private val context get() = InstrumentationRegistry.getInstrumentation().targetContext + + private fun calendar(): AvailabilityCalendar { + val today = LocalDate.now() + val days = (0 until 120).map { i -> + val d = today.plusDays(i.toLong()) + AvailabilityDay(date = d.toString(), available = if (i % 9 == 0) 0 else 1, state = if (i % 9 == 0) "full" else "free") + } + return AvailabilityCalendar(totalCopies = 2, earliestAvailable = today.toString(), days = days) + } + + private fun assertMonthInside(month: YearMonth, container: Rect) { + compose.onNodeWithTag("calendar-weekdays").onChildren().assertCountEquals(7) + for (day in 1..month.lengthOfMonth()) { + val node = compose.onNodeWithTag("calendar-day-${month.atDay(day)}", useUnmergedTree = true).fetchSemanticsNode() + val b = node.boundsInRoot + assertTrue("day $day of $month outside ($b vs $container)", + b.left >= container.left - 0.5f && b.right <= container.right + 0.5f && b.top >= container.top - 0.5f && b.width > 0f) + } + } + + @Test fun loanDialogMonthFitsThePhoneScreenAndNavigates() { + compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = calendar(), fallback = false, submitting = false, onDismiss = {}, onConfirm = {}) } } + val metrics = context.resources.displayMetrics + val screen = Rect(0f, 0f, metrics.widthPixels.toFloat(), metrics.heightPixels.toFloat()) + val dialog = compose.onNodeWithTag("loan-dialog").fetchSemanticsNode().boundsInRoot + assertTrue("dialog wider than the screen: $dialog", dialog.left >= 0f && dialog.right <= screen.right) + val month = YearMonth.now() + assertMonthInside(month, dialog) + compose.onNodeWithContentDescription(context.getString(R.string.cd_next_month)).performClick() + compose.waitForIdle() + assertMonthInside(month.plusMonths(1), dialog) + compose.onNodeWithContentDescription(context.getString(R.string.cd_prev_month)).performClick() + compose.waitForIdle() + assertMonthInside(month, dialog) + } + + @Test fun fallbackDialogUsesTheSameFittingGrid() { + compose.setContent { PinakesTheme { LoanRequestSheet(loading = false, calendar = null, fallback = true, submitting = false, onDismiss = {}, onConfirm = {}) } } + val dialog = compose.onNodeWithTag("loan-dialog").fetchSemanticsNode().boundsInRoot + assertMonthInside(YearMonth.now(), dialog) + } + + @Test fun monthGridFitsANarrow320dpColumn() { + compose.setContent { PinakesTheme { Box(Modifier.width(320.dp).testTag("narrow")) { LoanCalendar(calendar(), selected = null, onSelect = {}) } } } + val column = compose.onNodeWithTag("narrow").fetchSemanticsNode().boundsInRoot + assertMonthInside(YearMonth.now(), column) + assertTrue(compose.onAllNodesHelper("calendar-grid")) + } + + private fun androidx.compose.ui.test.junit4.ComposeContentTestRule.onAllNodesHelper(tag: String): Boolean = + onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().size == 1 +} 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 07c7d63..67a4d03 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 @@ -1,5 +1,8 @@ package com.pinakes.app.ui.screens.detail +import androidx.compose.foundation.layout.widthIn +import androidx.compose.ui.window.DialogProperties +import androidx.compose.ui.window.Dialog import android.content.ActivityNotFoundException import android.content.Intent import android.net.Uri @@ -38,20 +41,16 @@ import androidx.compose.material.icons.outlined.HourglassEmpty 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.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold -import androidx.compose.material3.SelectableDates 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 import androidx.compose.runtime.getValue @@ -96,10 +95,7 @@ import com.pinakes.app.ui.components.PinakesTopBar import com.pinakes.app.ui.components.PrimaryButton import com.pinakes.app.ui.components.SecondaryButton import com.pinakes.app.ui.theme.Spacing -import java.time.Instant import java.time.LocalDate -import java.time.ZoneId -import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Locale @@ -211,13 +207,16 @@ fun BookDetailScreen( } /** - * Loan-request sheet. While [loading], shows a spinner. With a [calendar], renders the colored + * Loan-request dialog. While [loading], shows a spinner. With a [calendar], renders the colored * [LoanCalendar] (availability-tinted month grid). If [fallback] (availability fetch failed), - * shows a plain today-or-future [DatePicker] and a note. Returns the chosen date as yyyy-MM-dd. + * 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. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun LoanRequestSheet( +internal fun LoanRequestSheet( loading: Boolean, calendar: AvailabilityCalendar?, fallback: Boolean, @@ -225,110 +224,92 @@ private 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() }) } - DatePickerDialog( - 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)) } - }, - ) { - 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)) - Text( - stringResource(R.string.book_loan_availability_loading), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + 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"), + ) { + 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 }, ) } - } - 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_availability_error), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = Spacing.sm), + ) + } } - else -> { - // Fallback: plain today-or-future picker. - FallbackDatePicker( - today = today, - selectedIso = selectedIso, - onSelect = { selectedIso = it }, - ) - 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), + ) + 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)) } } } - - // 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), - ) } } } -/** Plain Material3 date picker limited to today-or-future, used only when availability load fails. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun FallbackDatePicker( - today: LocalDate, - selectedIso: String?, - onSelect: (String) -> Unit, -) { - val todayUtcMillis = today.atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() - val initialMillis = selectedIso?.let { - runCatching { LocalDate.parse(it).atStartOfDay(ZoneOffset.UTC).toInstant().toEpochMilli() }.getOrNull() - } ?: todayUtcMillis - val datePickerState = rememberDatePickerState( - initialSelectedDateMillis = initialMillis, - selectableDates = object : SelectableDates { - override fun isSelectableDate(utcTimeMillis: Long): Boolean = utcTimeMillis >= todayUtcMillis - }, - ) - LaunchedEffect(datePickerState.selectedDateMillis) { - datePickerState.selectedDateMillis?.let { onSelect(millisToIsoDate(it)) } - } - DatePicker(state = datePickerState, title = null, headline = null, showModeToggle = false) -} - @OptIn(ExperimentalLayoutApi::class) @Composable internal fun DetailContent( @@ -714,7 +695,3 @@ private val displayDateFormatter: DateTimeFormatter = /** "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) - -/** DatePicker UTC-midnight millis → yyyy-MM-dd. */ -private fun millisToIsoDate(millis: Long): String = - Instant.ofEpochMilli(millis).atZone(ZoneId.of("UTC")).toLocalDate().toString() 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..1d02fb1 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 @@ -1,5 +1,10 @@ package com.pinakes.app.ui.screens.detail +import androidx.compose.ui.platform.testTag +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -103,8 +108,10 @@ private class CalendarModel(calendar: AvailabilityCalendar) { } } - fun isSelectable(date: LocalDate, today: LocalDate): Boolean { + fun isSelectable(date: LocalDate, today: LocalDate, anyFutureDay: Boolean = false): Boolean { if (date.isBefore(today)) return false + // Availability unknown (fetch failed): every future day can be requested; the server decides. + if (anyFutureDay) return true return when (kind(date, today)) { DayKind.Free, DayKind.Partial -> true else -> false @@ -123,6 +130,8 @@ fun LoanCalendar( selected: String?, onSelect: (isoDate: String) -> Unit, modifier: Modifier = Modifier, + /** 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 today = remember { LocalDate.now() } @@ -152,9 +161,12 @@ fun LoanCalendar( selected = selected, dark = dark, onSelect = onSelect, + anyFutureDay = anyFutureDay, ) - Spacer(Modifier.height(Spacing.md)) - Legend(dark = dark) + if (!anyFutureDay) { + Spacer(Modifier.height(Spacing.md)) + Legend(dark = dark) + } } } @@ -194,7 +206,8 @@ private fun WeekdayHeader() { DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY, DayOfWeek.SATURDAY, DayOfWeek.SUNDAY, ) - Row(Modifier.fillMaxWidth()) { + // Same 7 weighted columns and gaps as the grid below, so headers sit over their days. + Row(Modifier.fillMaxWidth().testTag("calendar-weekdays"), horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { days.forEach { dow -> Text( text = dow.getDisplayName(TextStyle.NARROW, locale), @@ -215,6 +228,7 @@ private fun MonthGrid( selected: String?, dark: Boolean, onSelect: (String) -> Unit, + anyFutureDay: Boolean = false, ) { val firstOfMonth = month.atDay(1) // Mon=1 .. Sun=7 → leading blanks before day 1. @@ -223,7 +237,7 @@ private fun MonthGrid( val totalCells = leadingBlanks + daysInMonth val rows = (totalCells + 6) / 7 - Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Column(Modifier.testTag("calendar-grid"), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { for (row in 0 until rows) { Row( Modifier.fillMaxWidth(), @@ -242,6 +256,7 @@ private fun MonthGrid( selected = selected == date.toString(), dark = dark, onSelect = onSelect, + anyFutureDay = anyFutureDay, ) } } @@ -259,17 +274,24 @@ private fun DayCell( selected: Boolean, dark: Boolean, onSelect: (String) -> Unit, + anyFutureDay: Boolean = false, ) { val iso = date.toString() val kind = model.kind(date, today) - val selectable = model.isSelectable(date, today) - val tint = tintFor(kind, dark) + val selectable = model.isSelectable(date, today, anyFutureDay) + // 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 + // 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. val cellModifier = Modifier - .size(40.dp) + .sizeIn(maxWidth = 44.dp, maxHeight = 44.dp) + .fillMaxSize() + .padding(1.dp) + .testTag("calendar-day-$iso") .clip(CircleShape) .background(container) .then( @@ -291,11 +313,14 @@ private fun DayCell( } } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun Legend(dark: Boolean) { - Row( + // Wraps on narrow screens and with long translations instead of running off the edge. + FlowRow( Modifier.fillMaxWidth(), 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)) From fd2a0994ed14ee3d6619bd0867bdd09b016da6d8 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 21:47:35 +0200 Subject: [PATCH 23/62] Check hidden profile fields in the live smoke test With database access configured, the smoke test hides gender and tax code in the settings table, waits for /health to report it, checks that /me omits them and that the app's profile editor no longer shows them, then restores the previous values. STATUS.md lists the new device tests. --- STATUS.md | 4 +- tools/e2e/live_smoke.py | 103 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/STATUS.md b/STATUS.md index 406caa2..4f2d30d 100644 --- a/STATUS.md +++ b/STATUS.md @@ -9,8 +9,8 @@ Requires Mobile API 1.6.0 (Pinakes 0.8.5). Every new entry point is gated on the - **Offline catalogue**: Room v2 holds the whole catalogue, kept current by `GET /catalog/sync` (delta with a server watermark, forced full resync every 7 days or when the server asks); loans, reservations and wishlist are written through on every fetch. Without a connection, search, book pages, Library and Wishlist answer from the cache with an offline banner. Loan and reservation actions are hidden offline (never shown disabled) and cached availability is shown only as "last known on ". The cache is excluded from backups. - **Loans calendar**: Profile → Loans in your calendar (`calendar_feed`): create the private ICS link, copy it (primary, Google Calendar can only subscribe from its website), open it as `webcal://` with an HTTPS fallback, regenerate (old link stops) or turn it off. - Local verification: 283 debug unit tests (new: ISBN/EAN codec, scan debouncer, staff access, staff form and write bodies, sync merge, offline policy, calendar links, tolerant 1.6.0 parsing, Room v2 catalogue/library DAOs), zero lint errors (111 warnings, mostly newer-dependency notices), debug and R8 release builds. -- Device tests (re-runnable): `app/src/androidTest/java/com/pinakes/app/staffe2e/` drives the real app (MainActivity, real Hilt graph, ViewModels, Room) against `FakePinakes`, a MockWebServer on the device loopback answering JSON fixtures from `app/src/androidTest/assets/fixtures/` and recording every request. Covered: staff entry only for admin/staff with `staff_catalog` (hidden for standard users, for the flag off and after a server-side downgrade, header included); scanner manual ISBN → preview → add → counter, duplicate → add copies, not found → manual form with the code, invalid code rejected, camera denied keeps manual entry (the camera prompt is denied through UiAutomator); manual form title required and shelf-without-level both send nothing, edit sends only the changed key (recorded PUT body); offline search banner with cached results and no availability or loan actions, offline detail "last known" wording without loan buttons, Library/Wishlist offline empty state with no raw exception, going offline after an online load hides the loan button and coming back restores it (real airplane mode); calendar create → masked link, copy flagged sensitive, regenerate and turn off both confirm. Run on a disposable emulator (it replaces the app's stored session and cache): `ANDROID_SERIAL=emulator-5554 ./gradlew connectedDebugAndroidTest` (one class: add `-Pandroid.testInstrumentationRunnerArguments.class=com.pinakes.app.staffe2e.ScannerFlowTest`). -- Live smoke (real app, real server): `tools/e2e/live-smoke.sh [--install]` with `PINAKES_E2E_URL` (as the device reaches it, e.g. `http://10.0.2.2:8081`), `PINAKES_E2E_EMAIL`, `PINAKES_E2E_PASSWORD` (admin or staff), optional `PINAKES_E2E_HOST_URL`, `PINAKES_E2E_ISBN` (default 9788845292613, must not be catalogued) and `ANDROID_SERIAL`. It clears the app, onboards, signs in, opens the staff hub, scan-adds an ISBN with 2 copies, adds a copy through the duplicate sheet, creates a record without ISBN, sets its shelf/level (skipped when the instance has none) and checks nothing else changed, searches and opens it in airplane mode, creates the calendar link and fetches the feed; every step is checked through the API. Cleanup removes only what the run created (books, a calendar link that did not exist before, device tokens issued during the run) and clears the app. Texts follow the device language through `i18n/*.json`; `tools/e2e/ui.py` is the adb/uiautomator driver (`python3 tools/e2e/ui.py dump`). +- Device tests (re-runnable): `app/src/androidTest/java/com/pinakes/app/staffe2e/` drives the real app (MainActivity, real Hilt graph, ViewModels, Room) against `FakePinakes`, a MockWebServer on the device loopback answering JSON fixtures from `app/src/androidTest/assets/fixtures/` and recording every request. Covered: staff entry only for admin/staff with `staff_catalog` (hidden for standard users, for the flag off and after a server-side downgrade, header included); scanner manual ISBN → preview → add → counter, duplicate → add copies, not found → manual form with the code, invalid code rejected, camera denied keeps manual entry (the camera prompt is denied through UiAutomator); manual form title required and shelf-without-level both send nothing, edit sends only the changed key (recorded PUT body); offline search banner with cached results and no availability or loan actions, offline detail "last known" wording without loan buttons, Library/Wishlist offline empty state with no raw exception, going offline after an online load hides the loan button and coming back restores it (real airplane mode); calendar create → masked link, copy flagged sensitive, regenerate and turn off both confirm; gender and tax code absent from the profile editor when the library hides them (present when shown) and never asked at registration; author detail with the padded info card and the author's books (`author_id` sent); loan-calendar month (7 weekday columns, every day cell inside the screen and inside a 320dp column, month navigation) and the birth-date picker within a 360dp screen. Run on a disposable emulator (it replaces the app's stored session and cache): `ANDROID_SERIAL=emulator-5554 ./gradlew connectedDebugAndroidTest` (one class: add `-Pandroid.testInstrumentationRunnerArguments.class=com.pinakes.app.staffe2e.ScannerFlowTest`). +- Live smoke (real app, real server): `tools/e2e/live-smoke.sh [--install]` with `PINAKES_E2E_URL` (as the device reaches it, e.g. `http://10.0.2.2:8081`), `PINAKES_E2E_EMAIL`, `PINAKES_E2E_PASSWORD` (admin or staff), optional `PINAKES_E2E_HOST_URL`, `PINAKES_E2E_ISBN` (default 9788845292613, must not be catalogued) and `ANDROID_SERIAL`. It clears the app, onboards, signs in, opens the staff hub, scan-adds an ISBN with 2 copies, adds a copy through the duplicate sheet, creates a record without ISBN, sets its shelf/level (skipped when the instance has none) and checks nothing else changed, searches and opens it in airplane mode, creates the calendar link and fetches the feed, and (with `PINAKES_E2E_DB_NAME/USER/PASSWORD/SOCKET` or `_HOST/_PORT`) hides gender and tax code in the settings table, checks they vanish from `/me` and from the profile editor, then restores the previous values; every step is checked through the API. Cleanup removes only what the run created (books, a calendar link that did not exist before, device tokens issued during the run) and clears the app. Texts follow the device language through `i18n/*.json`; `tools/e2e/ui.py` is the adb/uiautomator driver (`python3 tools/e2e/ui.py dump`). - Not covered by automation: real camera decoding and photo capture (emulators have no barcode in view). ## 2026 restyle (2026-10-08) diff --git a/tools/e2e/live_smoke.py b/tools/e2e/live_smoke.py index dfe7dbc..19e5fbc 100755 --- a/tools/e2e/live_smoke.py +++ b/tools/e2e/live_smoke.py @@ -13,6 +13,11 @@ PINAKES_E2E_ISBN ISBN to scan-add (default 9788845292613); must not be in the catalogue ANDROID_SERIAL target device (adb's own variable) +Optional, for the "hidden profile fields" step (skipped without them): direct access to the +instance database, used to switch the gender / tax code visibility and restore it afterwards. + PINAKES_E2E_DB_NAME, PINAKES_E2E_DB_USER, PINAKES_E2E_DB_PASSWORD, + PINAKES_E2E_DB_SOCKET (or PINAKES_E2E_DB_HOST + PINAKES_E2E_DB_PORT) + Steps: onboarding URL, login, staff hub, manual ISBN add (scraped preview), duplicate add copies, manual record without ISBN, shelf/level location on edit (skipped when the instance has no shelf with a level), airplane-mode offline search and detail, calendar link + feed. @@ -24,6 +29,7 @@ import os import random import re +import subprocess import sys import time import traceback @@ -410,6 +416,101 @@ def calendar(): ui.back() +# ---------------------------------------------------------------- database (optional) + +DB_FLAGS = ("show_sesso", "show_cod_fiscale") +DB_BEFORE = {} + + +def db_configured(): + return bool(os.environ.get("PINAKES_E2E_DB_NAME") and os.environ.get("PINAKES_E2E_DB_USER")) + + +def sql(query): + """Run one statement with the mysql client; the password goes through MYSQL_PWD, never argv.""" + cmd = ["mysql", "-N", "-B", "-u", os.environ["PINAKES_E2E_DB_USER"]] + if os.environ.get("PINAKES_E2E_DB_SOCKET"): + cmd += ["--socket", os.environ["PINAKES_E2E_DB_SOCKET"]] + else: + cmd += ["-h", os.environ.get("PINAKES_E2E_DB_HOST", "127.0.0.1"), "-P", os.environ.get("PINAKES_E2E_DB_PORT", "3306")] + cmd += [os.environ["PINAKES_E2E_DB_NAME"], "-e", query] + envp = dict(os.environ, MYSQL_PWD=os.environ.get("PINAKES_E2E_DB_PASSWORD", "")) + res = subprocess.run(cmd, capture_output=True, text=True, env=envp) + if res.returncode != 0: + raise RuntimeError("mysql failed: " + res.stderr.strip().splitlines()[-1] if res.stderr.strip() else "mysql failed") + return res.stdout + + +def set_visibility(values): + """values: {flag: "0"/"1"/None}; None deletes the row (back to the default: shown).""" + for flag, value in values.items(): + if value is None: + sql(f"DELETE FROM system_settings WHERE category='registration' AND setting_key='{flag}'") + else: + sql("INSERT INTO system_settings (category, setting_key, setting_value, updated_at) " + f"VALUES ('registration', '{flag}', '{value}', NOW()) " + "ON DUPLICATE KEY UPDATE setting_value=VALUES(setting_value), updated_at=NOW()") + + +def wait_health_visibility(expected, timeout=120): + """The server caches settings for up to 60 s: wait until /health reports [expected] + ({flag: bool}).""" + deadline = time.time() + timeout + while time.time() < deadline: + _, body, _ = API.call("GET", "health", auth=False) + reg = (body.get("data") or {}).get("registration") or {} + if all(reg.get(flag) is shown for flag, shown in expected.items()): + return + time.sleep(5) + raise AssertionError(f"/health never reported {expected}") + + +def open_profile_editor(): + open_tab("nav_profile") + ui.wait(S["profile_action_edit"], 90) + ui.tap(S["profile_action_edit"]) + ui.wait(S["profile_first_name"], 30) + + +def editor_has(label): + for _ in range(4): + if ui.find(label, exact=True): + return True + ui.swipe_up() + return False + + +@step("profile: hidden gender / tax code are not shown, then shown again") +def hidden_profile_fields(): + if not db_configured(): + raise SkipStep("PINAKES_E2E_DB_* not set") + rows = sql("SELECT setting_key, setting_value FROM system_settings WHERE category='registration' " + "AND setting_key IN ('show_sesso','show_cod_fiscale')") + DB_BEFORE.update({k: None for k in DB_FLAGS}) + for line in rows.splitlines(): + key, _, value = line.partition("\t") + DB_BEFORE[key] = value + try: + set_visibility({k: "0" for k in DB_FLAGS}) + wait_health_visibility({k: False for k in DB_FLAGS}) + _, me, _ = API.call("GET", "me") + assert set(me["data"].get("hidden_fields") or []) == {"sesso", "cod_fiscale"}, me["data"].get("hidden_fields") + assert "sesso" not in me["data"] and "cod_fiscale" not in me["data"], "hidden keys still in /me" + # Background and foreground the app: Profile re-reads /me and the field schema on resume. + ui.shell("input keyevent KEYCODE_HOME") + time.sleep(1) + ui.shell(f"monkey -p {PKG} -c android.intent.category.LAUNCHER 1") + time.sleep(4) + open_profile_editor() + assert not editor_has(S["profile_tax_code"]), "tax code shown while hidden" + assert not editor_has(S["profile_gender"]), "gender shown while hidden" + ui.back() + finally: + set_visibility(DB_BEFORE) + wait_health_visibility({k: DB_BEFORE.get(k) not in ("0", "false") for k in DB_FLAGS}) + return "hidden, checked, restored" + + def cleanup(): print("--> cleanup", flush=True) try: @@ -456,7 +557,7 @@ def main(): try: # Without a signed-in app the remaining steps cannot run: stop there. if ok and onboarding_and_login(): - for fn in (staff_hub, scan_add, scan_duplicate, manual_record, edit_location, offline, calendar): + for fn in (staff_hub, scan_add, scan_duplicate, manual_record, edit_location, offline, calendar, hidden_profile_fields): fn() finally: cleanup() From 97225a8fa75834f41e2a8ce175670b6d9b5c2e15 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 22:32:26 +0200 Subject: [PATCH 24/62] Never save an author or publisher form that failed to load When GET /staff/authors/{id} or /staff/publishers/{id} failed, the editor kept a blank placeholder with Save enabled; since saves send every field and an empty string clears it, saving wiped the stored record. The placeholder is now marked as not loaded: it shows the error with Retry only (no fields, no Save), edits and submit ignore it, and a late answer for an editor already closed is dropped. --- .../ui/screens/staff/StaffRecordsScreens.kt | 35 ++++++++++++++++--- .../com/pinakes/app/staff/RecordWritesTest.kt | 8 +++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt index 2d280eb..d3a085b 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffRecordsScreens.kt @@ -95,7 +95,16 @@ data class RecordForm( val address: String = "", val phone: String = "", val email: String = "", -) + /** + * False while an existing record has not been read from the server (loading, or the load + * failed). Such a form is a placeholder: saving it would send blank fields and wipe the + * stored record, so it is never editable or savable. + */ + val loaded: Boolean = true, +) { + /** A new record, or an existing one that was loaded; and a name. */ + val canSubmit: Boolean get() = loaded && name.isNotBlank() +} data class RecordsUiState( val kind: RecordKind = RecordKind.AUTHOR, @@ -180,10 +189,13 @@ abstract class RecordsViewModel(protected val staff: StaffRepository, kind: Reco } fun startEdit(id: Int) { - _state.update { it.copy(editing = RecordForm(id = id), editLoading = true, formError = null, formErrorRes = null) } + _state.update { it.copy(editing = RecordForm(id = id, loaded = false), editLoading = true, formError = null, formErrorRes = null) } viewModelScope.launch { when (val res = fetch(id)) { - is ApiResult.Success -> _state.update { it.copy(editing = res.data, editLoading = false) } + is ApiResult.Success -> _state.update { + // Ignore a late answer for an editor that was closed or switched meanwhile. + if (it.editing?.id == id) it.copy(editing = res.data.copy(loaded = true), editLoading = false) else it + } is ApiResult.Failure -> _state.update { it.copy(editLoading = false, forbidden = res.isForbidden(), formError = res.serverMessage(), formErrorRes = R.string.staff_error_load) } @@ -191,12 +203,18 @@ abstract class RecordsViewModel(protected val staff: StaffRepository, kind: Reco } } - fun edit(transform: (RecordForm) -> RecordForm) = _state.update { s -> s.copy(editing = s.editing?.let(transform), formError = null, formErrorRes = null) } + /** Edits apply only to a loaded form; the placeholder of a failed load stays untouched. */ + fun edit(transform: (RecordForm) -> RecordForm) = _state.update { s -> + val form = s.editing + if (form == null || !form.loaded) s else s.copy(editing = transform(form), formError = null, formErrorRes = null) + } fun closeEditor() = _state.update { it.copy(editing = null, duplicate = null) } fun submit() { val form = _state.value.editing ?: return + // Never save a form that was not read from the server (it would wipe the record). + if (!form.loaded) return if (form.name.isBlank()) { _state.update { it.copy(formErrorRes = R.string.staff_field_required) } return @@ -381,6 +399,15 @@ private fun RecordEditorSheet(state: RecordsUiState, vm: RecordsViewModel) { LoadingState(modifier = Modifier.heightIn(max = 160.dp)) return@Column } + if (!form.loaded) { + // The record could not be read: retry only, no fields and no Save. + InlineErrorBanner( + message = state.formError ?: stringResource(state.formErrorRes ?: R.string.staff_error_load), + onRetry = { vm.startEdit(form.id) }, + ) + Spacer(Modifier.height(Spacing.lg)) + return@Column + } val errorText = state.formError ?: state.formErrorRes?.let { stringResource(it) } if (errorText != null) InlineErrorBanner(message = errorText) Field(stringResource(R.string.staff_field_name), form.name) { v -> vm.edit { it.copy(name = v) } } diff --git a/app/src/test/java/com/pinakes/app/staff/RecordWritesTest.kt b/app/src/test/java/com/pinakes/app/staff/RecordWritesTest.kt index f8aaf7f..f06bbd8 100644 --- a/app/src/test/java/com/pinakes/app/staff/RecordWritesTest.kt +++ b/app/src/test/java/com/pinakes/app/staff/RecordWritesTest.kt @@ -50,4 +50,12 @@ class RecordWritesTest { encodeToJsonElement(serializer, form.toPublisherWrite()) private fun JsonObject.str(k: String) = this[k]!!.jsonPrimitive.content + + @Test fun aFormThatWasNotLoadedCanNeverBeSubmitted() { + // Placeholder after a failed GET /staff/authors/{id}: saving it would wipe the record. + assertEquals(false, RecordForm(id = 3, name = "Italo Calvino", loaded = false).canSubmit) + assertEquals(true, RecordForm(id = 3, name = "Italo Calvino").canSubmit) + assertEquals(true, RecordForm(name = "New author").canSubmit) + assertEquals(false, RecordForm(name = " ").canSubmit) + } } From a89ee35074026ad7b3c6051bcd84c3064c857072 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 22:32:26 +0200 Subject: [PATCH 25/62] Collect connectivity once in the catalogue search submitSearch(), setSort() and clearFilters() each started another connectivity collector that was never cancelled, so one reconnect re-ran the search once per earlier interaction. Only the collector started in init remains. ConnectivityCollectorTest checks that a single collector is left after repeated searches, sorts and filter resets. --- .../app/staffe2e/ConnectivityCollectorTest.kt | 31 +++++++++++++++++++ .../data/connectivity/ConnectivityObserver.kt | 3 ++ .../app/ui/screens/search/SearchViewModel.kt | 21 ------------- 3 files changed, 34 insertions(+), 21 deletions(-) create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/ConnectivityCollectorTest.kt diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ConnectivityCollectorTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ConnectivityCollectorTest.kt new file mode 100644 index 0000000..cf154a7 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ConnectivityCollectorTest.kt @@ -0,0 +1,31 @@ +package com.pinakes.app.staffe2e + +import android.app.Application +import androidx.test.platform.app.InstrumentationRegistry +import com.pinakes.app.data.connectivity.ConnectivityObserver +import com.pinakes.app.data.sync.CatalogSyncWorker +import com.pinakes.app.ui.screens.search.BookSort +import com.pinakes.app.ui.screens.search.SearchViewModel +import dagger.hilt.android.EntryPointAccessors +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Search listens to connectivity once, however many searches, sorts and filter resets run. */ +class ConnectivityCollectorTest { + + @Test fun searchKeepsASingleConnectivityCollector() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val deps = EntryPointAccessors.fromApplication(instrumentation.targetContext.applicationContext as Application, CatalogSyncWorker.Deps::class.java) + val connectivity = ConnectivityObserver.Fixed(true) + lateinit var vm: SearchViewModel + instrumentation.runOnMainSync { vm = SearchViewModel(deps.catalogRepository(), connectivity) } + instrumentation.runOnMainSync { + repeat(3) { vm.submitSearch() } + vm.setSort(BookSort.TITLE_ASC) + vm.setSort(BookSort.TITLE_DESC) + vm.clearFilters() + } + instrumentation.waitForIdleSync() + assertEquals(1, connectivity.subscribers) + } +} diff --git a/app/src/main/java/com/pinakes/app/data/connectivity/ConnectivityObserver.kt b/app/src/main/java/com/pinakes/app/data/connectivity/ConnectivityObserver.kt index fe7626e..19426c0 100644 --- a/app/src/main/java/com/pinakes/app/data/connectivity/ConnectivityObserver.kt +++ b/app/src/main/java/com/pinakes/app/data/connectivity/ConnectivityObserver.kt @@ -60,5 +60,8 @@ open class ConnectivityObserver(context: Context?) { private val state = MutableStateFlow(online) override val isOnline: StateFlow = state.asStateFlow() fun set(online: Boolean) { state.value = online } + + /** How many collectors listen right now (lets tests catch leaked collectors). */ + val subscribers: Int get() = state.subscriptionCount.value } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt index 580aa38..1ab64b9 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/search/SearchViewModel.kt @@ -179,13 +179,6 @@ class SearchViewModel @Inject constructor( fun submitSearch() { searchJob?.cancel() runSearch(reset = true) - viewModelScope.launch { - connectivity.isOnline.drop(1).collect { online -> - _state.update { it.copy(connected = online) } - // Back online after answering from the cache: ask the server again. - if (online && _state.value.offline) runSearch(reset = true) - } - } } fun toggleFilters() = _state.update { it.copy(filtersOpen = !it.filtersOpen) } @@ -212,13 +205,6 @@ class SearchViewModel @Inject constructor( if (_state.value.sort == sort) return _state.update { it.copy(sort = sort) } runSearch(reset = true) - viewModelScope.launch { - connectivity.isOnline.drop(1).collect { online -> - _state.update { it.copy(connected = online) } - // Back online after answering from the cache: ask the server again. - if (online && _state.value.offline) runSearch(reset = true) - } - } } /** Run the search with the currently staged facet filters. */ @@ -236,13 +222,6 @@ class SearchViewModel @Inject constructor( ) } runSearch(reset = true) - viewModelScope.launch { - connectivity.isOnline.drop(1).collect { online -> - _state.update { it.copy(connected = online) } - // Back online after answering from the cache: ask the server again. - if (online && _state.value.offline) runSearch(reset = true) - } - } } fun retry() = runSearch(reset = true) From b7969dd0e1471a5cefe27632b9642f0569cea5a8 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 22:32:26 +0200 Subject: [PATCH 26/62] Keep raw network errors out of the Profile snackbars Creating, regenerating or turning off the calendar link, saving the profile and changing the password showed the IOException text when offline. They now use the server's own message or the localized offline wording. --- .../ui/screens/profile/ProfileViewModel.kt | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt index d7dbb7b..c21516a 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt @@ -15,6 +15,8 @@ import com.pinakes.app.data.repository.AuthRepository import com.pinakes.app.data.repository.ProfileRepository import com.pinakes.app.R import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow @@ -239,8 +241,8 @@ class ProfileViewModel @Inject constructor( it.copy(profile = UiState.Success(res.data), savingProfile = false, editing = false, snackbar = null, snackbarRes = R.string.profile_updated) } is ApiResult.Failure -> _state.update { - if (res.message.isNotBlank()) it.copy(savingProfile = false, snackbar = res.message, snackbarRes = null) - else it.copy(savingProfile = false, snackbar = null, snackbarRes = R.string.profile_save_error) + if (res.serverMessage() != null) it.copy(savingProfile = false, snackbar = res.serverMessage(), snackbarRes = null) + else it.copy(savingProfile = false, snackbar = null, snackbarRes = if (res.isOffline) R.string.offline_generic else R.string.profile_save_error) } } } @@ -271,10 +273,13 @@ class ProfileViewModel @Inject constructor( when { res.code == ErrorCodes.INVALID_CREDENTIALS || res.code == ErrorCodes.VALIDATION -> _state.update { it.copy(savingPassword = false, pwError = null, pwErrorRes = R.string.profile_current_password_incorrect) } - res.message.isNotBlank() -> - _state.update { it.copy(savingPassword = false, pwError = res.message, pwErrorRes = null) } + res.serverMessage() != null -> + _state.update { it.copy(savingPassword = false, pwError = res.serverMessage(), pwErrorRes = null) } else -> - _state.update { it.copy(savingPassword = false, pwError = null, pwErrorRes = R.string.profile_change_password_error) } + _state.update { + it.copy(savingPassword = false, pwError = null, + pwErrorRes = if (res.isOffline) R.string.offline_generic else R.string.profile_change_password_error) + } } } } @@ -327,7 +332,12 @@ class ProfileViewModel @Inject constructor( it.copy(calendarBusy = false, calendar = UiState.Success(res.data), snackbarRes = successRes, snackbar = null) } is ApiResult.Failure -> _state.update { - it.copy(calendarBusy = false, snackbar = res.message.ifBlank { null }, snackbarRes = if (res.message.isBlank()) R.string.calendar_error else null) + // Never the raw IOException text: offline gets the localized wording. + it.copy( + calendarBusy = false, + snackbar = res.serverMessage(), + snackbarRes = if (res.isOffline) R.string.offline_generic else R.string.calendar_error, + ) } } } From aea2f9f9ea515fdfe6f2ba3cccd3ea0f443dedbd Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 22:32:26 +0200 Subject: [PATCH 27/62] Add one copy from the duplicate dialog when editing "Add copy" sent the form's copy count, which in edit mode is the edited record's own total: an ISBN change that collided with another book added that many copies to it. Editing now adds exactly one; creating keeps the copies the user asked for. --- .../app/ui/screens/staff/StaffBookEditViewModel.kt | 11 +++++++++-- .../com/pinakes/app/staff/StaffBookFormStateTest.kt | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt index 940e278..b806e35 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt @@ -97,6 +97,13 @@ data class StaffBookEditUiState( val isEdit: Boolean get() = bookId > 0 val errors: Set get() = if (showErrors) form.validate(isEdit) else emptySet() val levels get() = shelves.firstOrNull { it.id == form.shelfId }?.levels.orEmpty() + + /** + * Copies "Add copy" on the duplicate dialog adds to the existing record: the copies the + * user asked for when creating, but exactly one when editing (the form's count is then the + * edited record's own total, which must never be added to another book). + */ + val duplicateAddQuantity: Int get() = if (isEdit) 1 else form.copies.coerceIn(StaffBookForm.COPIES_RANGE) } /** @@ -387,13 +394,13 @@ class StaffBookEditViewModel @Inject constructor( _state.update { it.copy(duplicate = null, event = StaffEditEvent.OpenExisting(id)) } } - /** "Add copy" on the duplicate dialog: one more copy of the existing record. */ + /** "Add copy" on the duplicate dialog: [StaffBookEditUiState.duplicateAddQuantity] copies of the existing record. */ fun addCopyToExisting() { val existing = _state.value.duplicate ?: return if (existing.id <= 0) return _state.update { it.copy(saving = true) } viewModelScope.launch { - when (val res = staff.addCopies(existing.id, _state.value.form.copies.coerceIn(1, 100))) { + when (val res = staff.addCopies(existing.id, _state.value.duplicateAddQuantity)) { is ApiResult.Success -> _state.update { it.copy(saving = false, duplicate = null, event = StaffEditEvent.Saved(existing.id, created = false)) } diff --git a/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt index 4545133..83ea47a 100644 --- a/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt +++ b/app/src/test/java/com/pinakes/app/staff/StaffBookFormStateTest.kt @@ -198,4 +198,12 @@ class StaffBookFormStateTest { assertTrue(form.canSave(isEdit = true)) assertEquals(JsonObject(emptyMap()), StaffBookWrite.update(noLevel, form)) } + + @Test fun duplicateAddCopyAddsOneWhenEditingAndTheRequestedCopiesWhenCreating() { + val creating = com.pinakes.app.ui.screens.staff.StaffBookEditUiState(bookId = 0, form = valid.copy(copies = 3)) + assertEquals(3, creating.duplicateAddQuantity) + // Editing a record with 5 copies whose new ISBN collides: never add 5 copies to the other book. + val editing = com.pinakes.app.ui.screens.staff.StaffBookEditUiState(bookId = 10, form = valid.copy(copies = 5)) + assertEquals(1, editing.duplicateAddQuantity) + } } From d030981812418607fd7167d114cc7f6d0803b8db Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 22:32:26 +0200 Subject: [PATCH 28/62] Restart the scan suppression window when a sheet closes The 3 s window started when a code was accepted, but the lookup and the open sheet usually last longer, so after Skip or Add the book still under the camera reopened the same sheet. resume(now) restarts the window for the last accepted code; other codes are not held back. --- .../pinakes/app/data/staff/ScanDebouncer.kt | 8 ++++++-- .../screens/staff/scan/StaffScanViewModel.kt | 8 ++++---- .../com/pinakes/app/staff/ScanDebouncerTest.kt | 18 ++++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/data/staff/ScanDebouncer.kt b/app/src/main/java/com/pinakes/app/data/staff/ScanDebouncer.kt index 2b9f8eb..05a5d62 100644 --- a/app/src/main/java/com/pinakes/app/data/staff/ScanDebouncer.kt +++ b/app/src/main/java/com/pinakes/app/data/staff/ScanDebouncer.kt @@ -7,7 +7,9 @@ package com.pinakes.app.data.staff * misread while the camera focuses never triggers a lookup. * - Once accepted, the same code is suppressed for [suppressMs] milliseconds, so holding the * book under the camera does not add it twice. - * - [pause] ignores every decode (a sheet is open); [resume] starts counting afresh. + * - [pause] ignores every decode (a sheet is open); [resume] starts counting afresh and + * restarts the suppression window, so the book still under the camera after its sheet + * closes is not accepted again straight away. * * Pure and clock-injected ([offer] takes `nowMs`) so it is unit-testable. Not thread-safe: * the analyzer delivers decodes on one executor and the caller hops to the main thread. @@ -52,10 +54,12 @@ class ScanDebouncer( candidateCount = 0 } - fun resume() { + /** Accept decodes again. [nowMs] restarts the suppression window of the last accepted code. */ + fun resume(nowMs: Long? = null) { paused = false candidate = null candidateCount = 0 + if (nowMs != null && lastAccepted != null) lastAcceptedAt = nowMs } /** Forget the last accepted code, so it can be scanned again immediately (e.g. "Rescan"). */ diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffScanViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffScanViewModel.kt index 8cad488..20e8169 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffScanViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffScanViewModel.kt @@ -143,7 +143,7 @@ class StaffScanViewModel @Inject constructor( res.code == ErrorCodes.NETWORK -> _state.update { it.copy(phase = ScanPhase.Offline) } res.httpStatus == 422 || res.code == ErrorCodes.VALIDATION -> { _state.update { it.copy(phase = ScanPhase.Scanning, messageRes = R.string.staff_identifier_invalid) } - debouncer.resume() + debouncer.resume(SystemClock.elapsedRealtime()) } // Scrapers unreachable or failing: the record can still be created by hand. else -> _state.update { @@ -239,13 +239,13 @@ class StaffScanViewModel @Inject constructor( acceptedTick = it.acceptedTick + 1, ) } - debouncer.resume() + debouncer.resume(SystemClock.elapsedRealtime()) } /** Close the sheet without changes and keep scanning ("Skip"). */ fun skip() { _state.update { it.copy(phase = ScanPhase.Scanning, saving = false) } - debouncer.resume() + debouncer.resume(SystemClock.elapsedRealtime()) } /** Scan the same code again right away ("Rescan", or retry after going offline). */ @@ -257,7 +257,7 @@ class StaffScanViewModel @Inject constructor( /** Pause decoding while the screen hands off to the edit form. */ fun leaveForEdit() { _state.update { it.copy(phase = ScanPhase.Scanning, saving = false) } - debouncer.resume() + debouncer.resume(SystemClock.elapsedRealtime()) } fun consumeMessage() = _state.update { it.copy(message = null, messageRes = null) } diff --git a/app/src/test/java/com/pinakes/app/staff/ScanDebouncerTest.kt b/app/src/test/java/com/pinakes/app/staff/ScanDebouncerTest.kt index add926e..63950b8 100644 --- a/app/src/test/java/com/pinakes/app/staff/ScanDebouncerTest.kt +++ b/app/src/test/java/com/pinakes/app/staff/ScanDebouncerTest.kt @@ -58,4 +58,22 @@ class ScanDebouncerTest { assertNull(d.offer(a, 20)) assertEquals(a, d.offer(a, 30)) } + + @Test fun resumeWithTimeRestartsTheSuppressionWindow() { + val d = ScanDebouncer() + d.offer(a, 0); assertEquals(a, d.offer(a, 10)) + d.pause() + // The sheet stayed open 10 s; the book is still under the camera when it closes. + d.resume(nowMs = 10_000) + assertNull(d.offer(a, 10_100)) + assertNull(d.offer(a, 10_200)) + assertNull(d.offer(a, 12_900)) + // After the restarted window it can be scanned again (two fresh reads). + assertNull(d.offer(a, 13_100)) + assertEquals(a, d.offer(a, 13_200)) + // Other codes are never held back. + d.pause(); d.resume(nowMs = 20_000) + assertNull(d.offer(b, 20_050)) + assertEquals(b, d.offer(b, 20_100)) + } } From e2e4b5f407ec05305a48366ee7d0619d9b0d1080 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 22:32:26 +0200 Subject: [PATCH 29/62] Update the offline loans and wishlist after each change The cached lists only changed on a full fetch, so a title removed from the wishlist, a cancelled request or a new one stayed stale offline. Removing a wishlist title and cancelling a loan or reservation now drop the row at once; adding to the wishlist and requesting a loan re-read the lists. --- .../com/pinakes/app/data/local/LibraryDao.kt | 7 +++++++ .../app/data/repository/LibraryRepository.kt | 14 +++++++++++--- .../app/data/repository/WishlistRepository.kt | 10 ++++++++-- .../test/java/com/pinakes/app/LibraryDaoTest.kt | 16 ++++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/data/local/LibraryDao.kt b/app/src/main/java/com/pinakes/app/data/local/LibraryDao.kt index 87abea8..59e3266 100644 --- a/app/src/main/java/com/pinakes/app/data/local/LibraryDao.kt +++ b/app/src/main/java/com/pinakes/app/data/local/LibraryDao.kt @@ -30,6 +30,13 @@ interface LibraryDao { insertLoans(rows) } + /** Drop one loan/reservation from the given buckets (after a successful cancel). */ + @Query("DELETE FROM cached_loans WHERE id = :id AND bucket IN (:buckets)") + suspend fun deleteLoan(id: Int, buckets: List) + + @Query("DELETE FROM cached_wishlist WHERE bookId = :bookId") + suspend fun deleteWishlistItem(bookId: Int) + @Query("SELECT * FROM cached_wishlist ORDER BY position ASC") fun observeWishlist(): Flow> 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 aaf389d..eea5f36 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 @@ -69,16 +69,24 @@ class LibraryRepository( desiredDate: String? = null, ): ApiResult { val api = network.api() - return apiCall { api.createReservation(ReservationRequest(bookId = bookId, desiredDate = desiredDate)) } + val res = apiCall { api.createReservation(ReservationRequest(bookId = bookId, desiredDate = desiredDate)) } + // The request lands in pending loans or in reservations: refresh the offline copy. + if (res is ApiResult.Success) runCatching { refreshCache() } + return res } suspend fun cancelReservation(reservationId: Int): ApiResult { val api = network.api() - return apiCall { api.cancelReservation(reservationId) } + val res = apiCall { api.cancelReservation(reservationId) } + if (res is ApiResult.Success) runCatching { cache?.deleteLoan(reservationId, listOf(CachedLoan.RESERVATION)) } + return res } suspend fun cancelLoan(loanId: Int): ApiResult { val api = network.api() - return apiCall { api.cancelLoan(loanId) } + val res = apiCall { api.cancelLoan(loanId) } + // A cancelled request leaves the current lists (the next fetch files it in history). + if (res is ApiResult.Success) runCatching { cache?.deleteLoan(loanId, listOf(CachedLoan.PENDING, CachedLoan.ACTIVE)) } + return res } } diff --git a/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt index f4b07a9..adab5a4 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt @@ -35,13 +35,19 @@ class WishlistRepository( wishlist() } + /** Adds, then re-reads the list so the offline copy includes the new title. */ suspend fun add(bookId: Int): ApiResult { val api = network.api() - return apiCall { api.addWishlist(WishlistAddRequest(bookId)) } + val res = apiCall { api.addWishlist(WishlistAddRequest(bookId)) } + if (res is ApiResult.Success) runCatching { wishlist() } + return res } + /** Removes, and drops the title from the offline copy at once. */ suspend fun remove(bookId: Int): ApiResult { val api = network.api() - return apiCall { api.removeWishlist(bookId) } + val res = apiCall { api.removeWishlist(bookId) } + if (res is ApiResult.Success) runCatching { cache?.deleteWishlistItem(bookId) } + return res } } diff --git a/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt index 3a057a7..ff3e4ea 100644 --- a/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt +++ b/app/src/test/java/com/pinakes/app/LibraryDaoTest.kt @@ -100,4 +100,20 @@ class LibraryDaoTest { meta.clear() assertNull(meta.get(SyncMeta.CATALOG_SERVER_TIME)) } + + @Test fun cancelAndRemoveUpdateTheOfflineCopyWithoutAFullFetch() = runBlocking { + dao.replaceLoans(CachedLoan.LOAN_BUCKETS, loans.toCached()) + dao.replaceLoans(listOf(CachedLoan.RESERVATION), listOf(ReservationItem(id = 9, bookId = 90, title = "R").toCached(0))) + dao.deleteLoan(3, listOf(CachedLoan.PENDING, CachedLoan.ACTIVE)) + dao.deleteLoan(9, listOf(CachedLoan.RESERVATION)) + val back = dao.loans(CachedLoan.LOAN_BUCKETS).toLoansData() + assertTrue(back.pending.isEmpty()) + assertEquals(2, back.active.size) + assertEquals(1, back.history.size) // other buckets untouched + assertTrue(dao.loans(listOf(CachedLoan.RESERVATION)).isEmpty()) + + dao.replaceWishlist(listOf(WishlistItem(bookId = 5, title = "B").toCached(0), WishlistItem(bookId = 2, title = "A").toCached(1))) + dao.deleteWishlistItem(5) + assertEquals(listOf(2), dao.wishlist().map { it.bookId }) + } } From 2b977f3c3894c01d8ffa4414a2373ca9ed417ecb Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 23:26:07 +0200 Subject: [PATCH 30/62] Confirm a loan request or wishlist add before refreshing the offline copy reserve() and the wishlist add re-read the lists before returning, so the confirmation waited for up to two more round trips on a slow connection although the request had already gone through. The repositories now return at once and the book page refreshes the offline copy in the background after showing the confirmation. --- .../app/data/repository/LibraryRepository.kt | 8 +++---- .../app/data/repository/WishlistRepository.kt | 9 ++++---- .../ui/screens/detail/BookDetailViewModel.kt | 21 ++++++++++++------- 3 files changed, 23 insertions(+), 15 deletions(-) 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 eea5f36..42f1120 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 @@ -69,10 +69,10 @@ class LibraryRepository( desiredDate: String? = null, ): ApiResult { val api = network.api() - val res = apiCall { api.createReservation(ReservationRequest(bookId = bookId, desiredDate = desiredDate)) } - // The request lands in pending loans or in reservations: refresh the offline copy. - if (res is ApiResult.Success) runCatching { refreshCache() } - return res + // The request lands in pending loans or in reservations; the caller refreshes the + // offline copy afterwards (refreshCache) so the confirmation is not held back by + // two more round trips. + return apiCall { api.createReservation(ReservationRequest(bookId = bookId, desiredDate = desiredDate)) } } suspend fun cancelReservation(reservationId: Int): ApiResult { diff --git a/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt index adab5a4..f9945c2 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/WishlistRepository.kt @@ -35,12 +35,13 @@ class WishlistRepository( wishlist() } - /** Adds, then re-reads the list so the offline copy includes the new title. */ + /** + * Adds the title. The caller refreshes the offline copy afterwards (refreshCache), so the + * confirmation does not wait for a full list re-fetch. + */ suspend fun add(bookId: Int): ApiResult { val api = network.api() - val res = apiCall { api.addWishlist(WishlistAddRequest(bookId)) } - if (res is ApiResult.Success) runCatching { wishlist() } - return res + return apiCall { api.addWishlist(WishlistAddRequest(bookId)) } } /** Removes, and drops the title from the offline copy at once. */ 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 1f1ef31..084def9 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 @@ -114,13 +114,17 @@ class BookDetailViewModel @Inject constructor( viewModelScope.launch { val res = if (nowWishlisted) wishlist.add(bookId) else wishlist.remove(bookId) when (res) { - is ApiResult.Success -> _state.update { - it.copy( - wishlisted = nowWishlisted, - wishlistBusy = false, - snackbar = null, - snackbarRes = if (nowWishlisted) R.string.snackbar_added_to_wishlist else R.string.snackbar_removed_from_wishlist, - ) + is ApiResult.Success -> { + _state.update { + it.copy( + wishlisted = nowWishlisted, + wishlistBusy = false, + snackbar = null, + snackbarRes = if (nowWishlisted) R.string.snackbar_added_to_wishlist else R.string.snackbar_removed_from_wishlist, + ) + } + // Bring the offline copy up to date after the confirmation, not before it. + if (nowWishlisted) launch { runCatching { wishlist.refreshCache() } } } is ApiResult.Failure -> _state.update { if (res.serverMessage() != null) it.copy(wishlistBusy = false, snackbar = res.serverMessage(), snackbarRes = null) @@ -177,6 +181,9 @@ class BookDetailViewModel @Inject constructor( ) } load() + // The request is now a pending loan or a reservation: refresh the offline + // copy after the confirmation instead of holding it back. + launch { runCatching { library.refreshCache() } } } is ApiResult.Failure -> { // Close the calendar dialog so the error is visible: the snackbar From 258500299df98ee4ac93ff405c6f7ede9e6576eb Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 23:52:50 +0200 Subject: [PATCH 31/62] Format dates in the language picked in the app The date formatters were built once with the locale of the first call, so after switching the app to Italian from Profile the dates stayed "Oct 9, 2026" until the process restarted. They are now built per call from AppLocale, the AppCompat per-app language with the device locale as fallback; the loan calendar reads the same source. DateFormatLocaleTest switches language inside one process and fails on the old formatters. --- .../com/pinakes/app/ui/common/AppLocale.kt | 15 +++++++ .../com/pinakes/app/ui/common/DateFormat.kt | 11 +++--- .../app/ui/screens/detail/BookDetailScreen.kt | 4 +- .../app/ui/screens/detail/LoanCalendar.kt | 4 +- .../com/pinakes/app/DateFormatLocaleTest.kt | 39 +++++++++++++++++++ 5 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/ui/common/AppLocale.kt create mode 100644 app/src/test/java/com/pinakes/app/DateFormatLocaleTest.kt diff --git a/app/src/main/java/com/pinakes/app/ui/common/AppLocale.kt b/app/src/main/java/com/pinakes/app/ui/common/AppLocale.kt new file mode 100644 index 0000000..d411ff8 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/common/AppLocale.kt @@ -0,0 +1,15 @@ +package com.pinakes.app.ui.common + +import androidx.appcompat.app.AppCompatDelegate +import java.util.Locale + +/** + * The language the user picked in Profile (AppCompat per-app locale), falling back to the + * device locale. Read it at formatting time, never cache it in a formatter: the user can + * switch language while the process keeps running, and a formatter built at start-up would + * keep the old language (dates stayed "Oct 9, 2026" in an Italian UI). + */ +object AppLocale { + fun current(): Locale = + runCatching { AppCompatDelegate.getApplicationLocales()[0] }.getOrNull() ?: Locale.getDefault() +} diff --git a/app/src/main/java/com/pinakes/app/ui/common/DateFormat.kt b/app/src/main/java/com/pinakes/app/ui/common/DateFormat.kt index 094dbf1..974400d 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/DateFormat.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/DateFormat.kt @@ -17,12 +17,13 @@ import java.util.Locale */ object DateFormat { - private val dateFormatter: DateTimeFormatter = - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) + // Built per call: the app language can change while the process runs (AppLocale). + private val dateFormatter: DateTimeFormatter + get() = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(AppLocale.current()) - private val dateTimeFormatter: DateTimeFormatter = - DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) - .withLocale(Locale.getDefault()) + private val dateTimeFormatter: DateTimeFormatter + get() = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT) + .withLocale(AppLocale.current()) private val wallClockSeconds = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss") private val wallClockMinutes = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") 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 67a4d03..95e7f79 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 @@ -689,8 +689,8 @@ private fun SectionTitle(text: String) { private fun digitalFilename(url: String, fallback: String): String = Uri.parse(url).lastPathSegment?.takeIf { it.isNotBlank() } ?: fallback -private val displayDateFormatter: DateTimeFormatter = - DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) +private val displayDateFormatter: DateTimeFormatter + get() = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(com.pinakes.app.ui.common.AppLocale.current()) /** "2026-06-20" (yyyy-MM-dd) → locale-formatted date for display. Falls back to the raw input. */ private fun formatDisplayDate(iso: String): String = 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 1d02fb1..84d043a 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 @@ -200,7 +200,7 @@ private fun MonthHeader(month: YearMonth, onPrev: () -> Unit, onNext: () -> Unit @Composable private fun WeekdayHeader() { - val locale = Locale.getDefault() + val locale = com.pinakes.app.ui.common.AppLocale.current() // Mon-first week order. val days = listOf( DayOfWeek.MONDAY, DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, @@ -348,7 +348,7 @@ private fun LegendItem(color: Color, label: String) { /** "June 2026" style month + year title, localized. */ private fun monthTitle(month: YearMonth): String { - val locale = Locale.getDefault() + val locale = com.pinakes.app.ui.common.AppLocale.current() val name = month.month.getDisplayName(TextStyle.FULL, locale) .replaceFirstChar { if (it.isLowerCase()) it.titlecase(locale) else it.toString() } return "$name ${month.year}" diff --git a/app/src/test/java/com/pinakes/app/DateFormatLocaleTest.kt b/app/src/test/java/com/pinakes/app/DateFormatLocaleTest.kt new file mode 100644 index 0000000..b664abc --- /dev/null +++ b/app/src/test/java/com/pinakes/app/DateFormatLocaleTest.kt @@ -0,0 +1,39 @@ +package com.pinakes.app + +import com.pinakes.app.ui.common.DateFormat +import java.util.Locale +import org.junit.After +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * The app language can change while the process keeps running (Profile → Language). Dates + * must follow it on the next render instead of keeping the language of the first format call. + */ +class DateFormatLocaleTest { + private lateinit var saved: Locale + + @Before fun save() { saved = Locale.getDefault() } + + @After fun restore() { Locale.setDefault(saved) } + + @Test fun date_followsALanguageChangeInTheSameProcess() { + Locale.setDefault(Locale.US) + val english = DateFormat.date("2026-10-09") + Locale.setDefault(Locale.ITALY) + val italian = DateFormat.date("2026-10-09") + assertTrue("english was $english", english.contains("Oct")) + assertTrue("italian was $italian", italian.contains("ott")) + } + + @Test fun dateTime_followsALanguageChangeInTheSameProcess() { + Locale.setDefault(Locale.US) + val english = DateFormat.dateTime("2026-10-09 19:53:00") + Locale.setDefault(Locale.GERMANY) + val german = DateFormat.dateTime("2026-10-09 19:53:00") + assertTrue("english was $english", english.contains("Oct")) + // German MEDIUM dates are numeric (09.10.2026). + assertTrue("german was $german", german.startsWith("09.10.2026")) + } +} From dceb7c146d2cf7550b59f57bd639d8eebf10795a Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Tue, 13 Oct 2026 23:52:50 +0200 Subject: [PATCH 32/62] Mention reservations and loan start dates in the calendar sheet The personal feed now carries active reservations as well, and every loan spans from its start to the due date; the sheet description says so in all four languages. --- i18n/de.json | 2 +- i18n/en.json | 2 +- i18n/fr.json | 2 +- i18n/it.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/i18n/de.json b/i18n/de.json index 07f8bc1..fcb418d 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -636,7 +636,7 @@ "profile_action_staff": "Mitarbeiterbereich", "profile_action_calendar": "Ausleihen im Kalender", "calendar_subscribe_title": "Ausleihen im Kalender", - "calendar_subscribe_body": "Ein privater Kalenderlink mit Ihren Ausleihen: Rückgabefristen, abholbereite Bücher und vorgemerkte Ausleihen. Kalender-Apps aktualisieren ihn selbst.", + "calendar_subscribe_body": "Ein privater Kalenderlink mit Ihren Ausleihen vom Beginn bis zur Rückgabefrist, abholbereiten Büchern, vorgemerkten Ausleihen und Ihren Vormerkungen. Kalender-Apps aktualisieren ihn selbst.", "calendar_status_disabled": "Der Kalenderlink ist ausgeschaltet.", "calendar_enable": "Kalenderlink erstellen", "calendar_created_on": "Erstellt am %1$s", diff --git a/i18n/en.json b/i18n/en.json index ad8ce4d..486a90e 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -636,7 +636,7 @@ "profile_action_staff": "Staff area", "profile_action_calendar": "Loans in your calendar", "calendar_subscribe_title": "Loans in your calendar", - "calendar_subscribe_body": "A private calendar link with your loans: due dates, books ready for pickup and booked loans. Calendar apps refresh it on their own.", + "calendar_subscribe_body": "A private calendar link with your loans, from start to due date, books ready for pickup, booked loans and your reservations. Calendar apps refresh it on their own.", "calendar_status_disabled": "The calendar link is off.", "calendar_enable": "Create calendar link", "calendar_created_on": "Created on %1$s", diff --git a/i18n/fr.json b/i18n/fr.json index 72398e8..1998103 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -636,7 +636,7 @@ "profile_action_staff": "Espace personnel", "profile_action_calendar": "Prêts dans votre agenda", "calendar_subscribe_title": "Prêts dans votre agenda", - "calendar_subscribe_body": "Un lien privé avec vos prêts : échéances, livres prêts à retirer et prêts programmés. Les applications d’agenda le mettent à jour d’elles-mêmes.", + "calendar_subscribe_body": "Un lien privé avec vos prêts, du début à l’échéance, les livres prêts à retirer, les prêts programmés et vos réservations. Les applications d’agenda le mettent à jour d’elles-mêmes.", "calendar_status_disabled": "Le lien d’agenda est désactivé.", "calendar_enable": "Créer le lien d’agenda", "calendar_created_on": "Créé le %1$s", diff --git a/i18n/it.json b/i18n/it.json index 3655a5f..5bf2150 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -636,7 +636,7 @@ "profile_action_staff": "Area staff", "profile_action_calendar": "Prestiti nel calendario", "calendar_subscribe_title": "Prestiti nel calendario", - "calendar_subscribe_body": "Un link privato con i tuoi prestiti: scadenze, libri pronti per il ritiro e prestiti prenotati. Le app di calendario lo aggiornano da sole.", + "calendar_subscribe_body": "Un link privato con i tuoi prestiti dall'inizio alla scadenza, i libri pronti per il ritiro, i prestiti programmati e le tue prenotazioni. Le app di calendario lo aggiornano da sole.", "calendar_status_disabled": "Il link del calendario è disattivato.", "calendar_enable": "Crea link calendario", "calendar_created_on": "Creato il %1$s", From e6c9a607c2a8909c437d28e8947c22555de632f4 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 01:05:12 +0200 Subject: [PATCH 33/62] Add the Mobile API 1.7.0 staff data layer for circulation, books and plugin content Models, Retrofit interfaces and repositories for the circulation desk, per-copy management, series, merges, labels, imports, covers and enrichment, Desiderata and Archives, all parsed tolerantly against the frozen phase 2 contract. Every new repository method goes through the shared staffCall, so a 403 forbidden still drops the role. The fourteen staff_* health flags gate each area; circulation also needs a connection and is hidden offline. Staff exports (receipts, labels, error CSVs) are downloaded with the bearer token into cache/exports. Version 1.8.0 (21). --- app/build.gradle.kts | 4 +- .../java/com/pinakes/app/data/model/Models.kt | 24 ++ .../app/data/model/StaffCirculationModels.kt | 221 +++++++++++++++ .../app/data/model/StaffContentModels.kt | 259 ++++++++++++++++++ .../com/pinakes/app/data/model/StaffModels.kt | 49 ++++ .../app/data/model/StaffPluginModels.kt | 160 +++++++++++ .../pinakes/app/data/network/NetworkModule.kt | 68 +++++ .../app/data/network/StaffCirculationApi.kt | 131 +++++++++ .../app/data/network/StaffContentApi.kt | 164 +++++++++++ .../app/data/network/StaffPluginsApi.kt | 127 +++++++++ .../pinakes/app/data/repository/StaffCalls.kt | 39 +++ .../repository/StaffCirculationRepository.kt | 123 +++++++++ .../data/repository/StaffContentRepository.kt | 175 ++++++++++++ .../data/repository/StaffFilesRepository.kt | 82 ++++++ .../data/repository/StaffPluginsRepository.kt | 95 +++++++ .../app/data/repository/StaffRepository.kt | 17 +- .../pinakes/app/data/staff/CopyCodeCodec.kt | 27 ++ .../com/pinakes/app/data/staff/LoanActions.kt | 83 ++++++ .../com/pinakes/app/data/staff/StaffAreas.kt | 59 ++++ .../com/pinakes/app/data/staff/StaffErrors.kt | 47 ++++ .../pinakes/app/data/store/FeatureStore.kt | 72 +++++ .../main/java/com/pinakes/app/di/AppModule.kt | 21 ++ .../com/pinakes/app/ui/common/AppViewModel.kt | 15 + .../pinakes/app/ui/common/OfflinePolicy.kt | 7 + .../com/pinakes/app/ui/navigation/Routes.kt | 37 +++ .../pinakes/app/staff/CopyCodeCodecTest.kt | 42 +++ .../com/pinakes/app/staff/LoanActionsTest.kt | 53 ++++ .../app/staff/PenaltyValidationTest.kt | 37 +++ .../com/pinakes/app/staff/StaffAreasTest.kt | 55 ++++ .../StaffCirculationModelsParsingTest.kt | 147 ++++++++++ i18n/de.json | 197 ++++++++++++- i18n/en.json | 197 ++++++++++++- i18n/fr.json | 197 ++++++++++++- i18n/it.json | 197 ++++++++++++- 34 files changed, 3216 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt create mode 100644 app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt create mode 100644 app/src/main/java/com/pinakes/app/data/model/StaffPluginModels.kt create mode 100644 app/src/main/java/com/pinakes/app/data/network/StaffCirculationApi.kt create mode 100644 app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt create mode 100644 app/src/main/java/com/pinakes/app/data/network/StaffPluginsApi.kt create mode 100644 app/src/main/java/com/pinakes/app/data/repository/StaffCalls.kt create mode 100644 app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt create mode 100644 app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt create mode 100644 app/src/main/java/com/pinakes/app/data/repository/StaffFilesRepository.kt create mode 100644 app/src/main/java/com/pinakes/app/data/repository/StaffPluginsRepository.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/CopyCodeCodec.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/LoanActions.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/CopyCodeCodecTest.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/LoanActionsTest.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/PenaltyValidationTest.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/StaffAreasTest.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/StaffCirculationModelsParsingTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 037d6ed..f28b4a6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -166,8 +166,8 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" minSdk = 26 targetSdk = 35 - versionCode = 20 - versionName = "1.7.0" + versionCode = 21 + versionName = "1.8.0" vectorDrawables { useSupportLibrary = true 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 9a1d4db..4cbf6a4 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 @@ -29,6 +29,14 @@ data class Meta( // (e.g. `active_circulation`). Kept as raw JSON so each caller decodes its own shape. val existing: kotlinx.serialization.json.JsonElement? = null, val reason: String? = null, + // Mobile API 1.7.0 error/result context: the offending field of a 422, the next expected + // chunk start of a 409 `out_of_order`, a rename that merged into an existing series, a + // server message accompanying a successful action, the seconds of a 429. + val field: String? = null, + val processed: Int? = null, + val merged: Boolean? = null, + val message: String? = null, + @SerialName("retry_after") val retryAfter: Int? = null, ) @Serializable @@ -83,6 +91,22 @@ data class HealthFeatures( @SerialName("staff_catalog") val staffCatalog: Boolean = false, @SerialName("catalog_sync") val catalogSync: Boolean = false, @SerialName("calendar_feed") val calendarFeed: Boolean = false, + // Mobile API 1.7.0 staff areas. Each is a server decision (plugin active, catalogue mode + // off, legacy schema); older servers omit them, so every area stays hidden by default. + @SerialName("staff_circulation") val staffCirculation: Boolean = false, + @SerialName("staff_copies") val staffCopies: Boolean = false, + @SerialName("staff_series") val staffSeries: Boolean = false, + @SerialName("staff_merges") val staffMerges: Boolean = false, + @SerialName("staff_labels") val staffLabels: Boolean = false, + @SerialName("staff_imports") val staffImports: Boolean = false, + @SerialName("staff_covers") val staffCovers: Boolean = false, + @SerialName("staff_desiderata") val staffDesiderata: Boolean = false, + @SerialName("staff_archives") val staffArchives: Boolean = false, + @SerialName("staff_periodicals") val staffPeriodicals: Boolean = false, + @SerialName("staff_bookclub") val staffBookClub: Boolean = false, + @SerialName("staff_frbr") val staffFrbr: Boolean = false, + @SerialName("staff_digital_library") val staffDigitalLibrary: Boolean = false, + @SerialName("staff_librarything") val staffLibraryThing: Boolean = false, ) // ---------- Auth ---------- diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt new file mode 100644 index 0000000..3c89c55 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt @@ -0,0 +1,221 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +// Staff circulation (Mobile API 1.7.0, tag `staff-circulation`). Every field has a default: +// the server is free to add keys and older builds omit the newer ones. `status` carries the +// raw core enum (`prestiti.stato`, `prenotazioni.stato`), `status_label` its localised text. + +/** The book a loan, reservation or copy belongs to. */ +@Serializable +data class StaffLoanBook( + val id: Int = 0, + val title: String = "", + @SerialName("cover_url") val coverUrl: String? = null, + val isbn13: String? = null, +) + +@Serializable +data class StaffLoanCopy( + val id: Int = 0, + @SerialName("inventory_number") val inventoryNumber: String? = null, + val status: String? = null, +) + +/** The borrower as the desk sees it. */ +@Serializable +data class StaffUserRef( + val id: Int = 0, + val name: String = "", + val email: String? = null, + @SerialName("card_number") val cardNumber: String? = null, + /** `attivo` | `sospeso` | `scaduto`. */ + val status: String? = null, +) + +@Serializable +data class StaffLoan( + val id: Int = 0, + val status: String = "", + @SerialName("status_label") val statusLabel: String? = null, + /** `richiesta` | `prenotazione` | `diretto` | `ncip`. */ + val origin: String? = null, + val book: StaffLoanBook = StaffLoanBook(), + val copy: StaffLoanCopy? = null, + val user: StaffUserRef = StaffUserRef(), + @SerialName("processed_by") val processedBy: IdName? = null, + @SerialName("requested_at") val requestedAt: String? = null, + @SerialName("loaned_at") val loanedAt: String? = null, + @SerialName("due_at") val dueAt: String? = null, + @SerialName("pickup_deadline") val pickupDeadline: String? = null, + @SerialName("returned_at") val returnedAt: String? = null, + @SerialName("returned_late") val returnedLate: Boolean = false, + @SerialName("days_overdue") val daysOverdue: Int = 0, + val renewals: Int = 0, + @SerialName("max_renewals") val maxRenewals: Int = 0, + val penalty: Double? = null, + val notes: String? = null, + @SerialName("recall_count") val recallCount: Int = 0, + @SerialName("last_recall_at") val lastRecallAt: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + /** Presentation hint computed server-side; unknown values are ignored by the app. */ + val actions: List = emptyList(), +) + +@Serializable +data class StaffReservation( + val id: Int = 0, + val status: String = "", + @SerialName("status_label") val statusLabel: String? = null, + val book: StaffLoanBook = StaffLoanBook(), + val user: StaffUserRef = StaffUserRef(), + @SerialName("queue_position") val queuePosition: Int? = null, + @SerialName("reserved_at") val reservedAt: String? = null, + @SerialName("expires_at") val expiresAt: String? = null, + @SerialName("requested_from") val requestedFrom: String? = null, + @SerialName("requested_to") val requestedTo: String? = null, + val notified: Boolean = false, + @SerialName("created_at") val createdAt: String? = null, + val actions: List = emptyList(), +) + +/** A user found by the desk search, with what the loan form needs to know. */ +@Serializable +data class StaffUser( + val id: Int = 0, + val name: String = "", + val email: String? = null, + @SerialName("card_number") val cardNumber: String? = null, + val status: String? = null, + @SerialName("card_expires_at") val cardExpiresAt: String? = null, + val role: String? = null, + @SerialName("active_loans") val activeLoans: Int = 0, + @SerialName("active_reservations") val activeReservations: Int = 0, + val eligible: Boolean = true, + @SerialName("eligibility_reason") val eligibilityReason: String? = null, +) + +/** `GET /staff/users/{id}`: the user plus open loans and active reservations. */ +@Serializable +data class StaffUserDetail( + val id: Int = 0, + val name: String = "", + val email: String? = null, + @SerialName("card_number") val cardNumber: String? = null, + val status: String? = null, + @SerialName("card_expires_at") val cardExpiresAt: String? = null, + val role: String? = null, + @SerialName("active_loans") val activeLoans: Int = 0, + @SerialName("active_reservations") val activeReservations: Int = 0, + val eligible: Boolean = true, + @SerialName("eligibility_reason") val eligibilityReason: String? = null, + val loans: List = emptyList(), + val reservations: List = emptyList(), +) + +@Serializable +data class CirculationSettings( + @SerialName("loan_duration_days") val loanDurationDays: Int? = null, + @SerialName("pickup_expiry_days") val pickupExpiryDays: Int? = null, + @SerialName("max_renewals") val maxRenewals: Int? = null, + @SerialName("max_active_loans_per_user") val maxActiveLoansPerUser: Int? = null, + @SerialName("allow_multiple_loans_same_book") val allowMultipleLoansSameBook: Boolean? = null, +) + +/** `GET /staff/circulation/summary`: the desk counters. */ +@Serializable +data class CirculationSummary( + val pending: Int = 0, + @SerialName("pickup_ready") val pickupReady: Int = 0, + val scheduled: Int = 0, + val active: Int = 0, + val overdue: Int = 0, + val reservations: Int = 0, + @SerialName("due_today") val dueToday: Int = 0, + @SerialName("returned_today") val returnedToday: Int = 0, + val today: String? = null, + val settings: CirculationSettings? = null, +) + +@Serializable +data class LoanCreateRequest( + @SerialName("user_id") val userId: Int, + @SerialName("book_id") val bookId: Int, + @SerialName("copy_code") val copyCode: String? = null, + @SerialName("loaned_at") val loanedAt: String? = null, + @SerialName("due_at") val dueAt: String? = null, + val notes: String? = null, + /** Always sent: "hand over now" makes the loan `in_corso` straight away. */ + val immediate: Boolean, +) + +/** `PUT /staff/loans/{id}`: absent keys keep the stored value (the server merges). */ +@Serializable +data class LoanEditRequest( + @SerialName("user_id") val userId: Int? = null, + @SerialName("loaned_at") val loanedAt: String? = null, + @SerialName("due_at") val dueAt: String? = null, +) + +@Serializable +data class ReturnRequest( + val outcome: String, + val notes: String? = null, + val penalty: Double? = null, +) + +@Serializable +data class ReturnByCodeRequest( + val code: String, + val outcome: String? = null, + val notes: String? = null, + val penalty: Double? = null, +) + +@Serializable +data class ReasonRequest(val reason: String? = null) + +@Serializable +data class BulkExtendRequest(val ids: List, val days: Int) + +@Serializable +data class BulkExtendResult(val extended: Int = 0, val days: Int = 0) + +@Serializable +data class BulkIdsRequest(val ids: List) + +@Serializable +data class BulkRecallResult(val sent: Int = 0, val skipped: Int = 0) + +/** Recall / receipt email: SMTP failures still answer 200 with `sent=false`. */ +@Serializable +data class SentResult(val sent: Boolean = false, val message: String? = null) + +/** `GET /staff/copies/by-code`. */ +@Serializable +data class CopyLookup( + val found: Boolean = false, + val copy: StaffCopy? = null, +) + +/** `GET /staff/books/{id}/queue`. */ +@Serializable +data class BookQueue( + val reservations: List = emptyList(), + val loans: List = emptyList(), + val copies: List = emptyList(), +) + +/** Reservation create/edit. On edit absent keys keep the stored value. */ +@Serializable +data class ReservationWrite( + @SerialName("user_id") val userId: Int? = null, + @SerialName("book_id") val bookId: Int? = null, + val status: String? = null, + @SerialName("reserved_at") val reservedAt: String? = null, + @SerialName("expires_at") val expiresAt: String? = null, + @SerialName("requested_from") val requestedFrom: String? = null, + @SerialName("requested_to") val requestedTo: String? = null, +) diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt new file mode 100644 index 0000000..0a6447b --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt @@ -0,0 +1,259 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject + +// Staff content (Mobile API 1.7.0, tag `staff-content`): per-copy management, series, +// duplicate merges, labels, CSV/LibraryThing imports, covers and bulk enrichment. + +@Serializable +data class CopyUpdate(val status: String? = null, val notes: String? = null) + +// ---------- Series ---------- + +@Serializable +data class StaffSeries( + val id: Int = 0, + val name: String = "", + /** `serie` | `universo` | `ciclo` | `stagione` | `spin_off` | `arco` | `collezione_editoriale` | `altro`. */ + val type: String? = null, + @SerialName("parent_id") val parentId: Int? = null, + @SerialName("parent_name") val parentName: String? = null, + val group: String? = null, + val cycle: String? = null, + @SerialName("cycle_order") val cycleOrder: Int? = null, + val complete: Boolean = false, + @SerialName("book_count") val bookCount: Int = 0, + @SerialName("min_number") val minNumber: String? = null, + @SerialName("max_number") val maxNumber: String? = null, + val children: List = emptyList(), +) + +@Serializable +data class SeriesBook( + val id: Int = 0, + val title: String = "", + @SerialName("cover_url") val coverUrl: String? = null, + @SerialName("series_number") val seriesNumber: String? = null, + @SerialName("is_primary") val isPrimary: Boolean = false, + val year: String? = null, +) + +@Serializable +data class SeriesRelated( + val id: Int = 0, + val name: String = "", + val type: String? = null, + val relation: String? = null, +) + +@Serializable +data class StaffSeriesDetail( + val id: Int = 0, + val name: String = "", + val type: String? = null, + @SerialName("parent_id") val parentId: Int? = null, + @SerialName("parent_name") val parentName: String? = null, + val group: String? = null, + val cycle: String? = null, + @SerialName("cycle_order") val cycleOrder: Int? = null, + val complete: Boolean = false, + @SerialName("book_count") val bookCount: Int = 0, + @SerialName("min_number") val minNumber: String? = null, + @SerialName("max_number") val maxNumber: String? = null, + val children: List = emptyList(), + val description: String? = null, + val books: List = emptyList(), + val related: List = emptyList(), +) + +@Serializable +data class SeriesWrite( + val name: String, + val type: String? = null, + @SerialName("parent_id") val parentId: Int? = null, + @SerialName("parent_name") val parentName: String? = null, + val group: String? = null, + val cycle: String? = null, + @SerialName("cycle_order") val cycleOrder: Int? = null, + val description: String? = null, + val complete: Boolean? = null, +) + +@Serializable +data class SeriesRenameRequest(val name: String, @SerialName("merge_if_exists") val mergeIfExists: Boolean) + +@Serializable +data class TargetRequest(@SerialName("target_id") val targetId: Int) + +@Serializable +data class SeriesMergeResult(val target: IdName = IdName(), @SerialName("books_moved") val booksMoved: Int = 0) + +@Serializable +data class SeriesDeleteResult( + val id: Int = 0, + val deleted: Boolean = false, + @SerialName("books_updated") val booksUpdated: Int = 0, + @SerialName("children_reattached") val childrenReattached: Int = 0, +) + +@Serializable +data class BookIdsRequest(@SerialName("book_ids") val bookIds: List) + +@Serializable +data class AssignResult(val assigned: Int = 0) + +@Serializable +data class ParentWorkRequest(val title: String) + +@Serializable +data class ParentWorkResult(@SerialName("book_id") val bookId: Int = 0, val title: String = "") + +// ---------- Merges ---------- + +@Serializable +data class DuplicateAuthor(val id: Int = 0, val name: String = "", @SerialName("books_count") val booksCount: Int = 0) + +@Serializable +data class MergeRequest( + val ids: List, + @SerialName("primary_id") val primaryId: Int? = null, + @SerialName("new_name") val newName: String? = null, +) + +@Serializable +data class MergeResult(@SerialName("primary_id") val primaryId: Int = 0) + +@Serializable +data class GenreMergeResult( + @SerialName("target_id") val targetId: Int = 0, + @SerialName("books_updated") val booksUpdated: Int = 0, + @SerialName("children_moved") val childrenMoved: Int = 0, +) + +// ---------- Labels ---------- + +/** `GET|PUT /staff/settings/labels`: dimensions in millimetres (10..100). */ +@Serializable +data class LabelSettings( + val width: Double? = null, + val height: Double? = null, + val padding: Double? = null, + @SerialName("show_app_name") val showAppName: Boolean? = null, + @SerialName("show_title") val showTitle: Boolean? = null, + @SerialName("show_subtitle") val showSubtitle: Boolean? = null, + @SerialName("show_author_publisher") val showAuthorPublisher: Boolean? = null, + @SerialName("show_dewey") val showDewey: Boolean? = null, +) + +// ---------- Imports ---------- + +@Serializable +data class ImportJob( + val id: String = "", + /** `csv` | `librarything`. */ + val kind: String = "csv", + @SerialName("file_name") val fileName: String = "", + /** `ready` | `running` | `completed` | `failed` | `cancelled`. */ + val status: String = "ready", + @SerialName("total_rows") val totalRows: Int = 0, + val processed: Int = 0, + @SerialName("chunk_size") val chunkSize: Int = 10, + @SerialName("enable_scraping") val enableScraping: Boolean = false, + val imported: Int = 0, + val updated: Int = 0, + val scraped: Int = 0, + @SerialName("authors_created") val authorsCreated: Int = 0, + @SerialName("publishers_created") val publishersCreated: Int = 0, + val errors: Int = 0, + @SerialName("error_details") val errorDetails: List = emptyList(), + val complete: Boolean = false, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + @SerialName("expires_at") val expiresAt: String? = null, +) { + /** One readable line per error: a string, or an object's `message`/`error` (with its `row`). */ + val errorLines: List get() = errorDetails.mapNotNull { detail -> + when (detail) { + is JsonObject -> { + val text = (detail["message"] ?: detail["error"] ?: detail["title"])?.primitiveText() + val row = detail["row"]?.primitiveText() + when { + text != null && row != null && !text.contains(row) -> "$row: $text" + text != null -> text + else -> detail.toString() + } + } + else -> detail.displayText() + } + } +} + +@Serializable +data class ChunkRequest(val start: Int? = null, val size: Int? = null) + +@Serializable +data class ImportHistoryRow( + @SerialName("import_id") val importId: String = "", + val kind: String? = null, + @SerialName("file_name") val fileName: String? = null, + val user: IdName? = null, + @SerialName("total_rows") val totalRows: Int = 0, + val imported: Int = 0, + val updated: Int = 0, + val failed: Int = 0, + val status: String? = null, + @SerialName("started_at") val startedAt: String? = null, + @SerialName("completed_at") val completedAt: String? = null, +) + +@Serializable +data class ImportsOverview( + val jobs: List = emptyList(), + val history: List = emptyList(), +) + +// ---------- Covers / enrichment ---------- + +@Serializable +data class MissingCover(val id: Int = 0, val title: String = "", val isbn13: String? = null) + +@Serializable +data class FetchCoverResult( + val fetched: Boolean = false, + @SerialName("cover_url") val coverUrl: String? = null, + /** `already_has_cover` | `no_isbn` | `invalid_isbn` | `no_cover_found` | null. */ + val reason: String? = null, +) + +@Serializable +data class SyncResult(val synced: Int = 0, val skipped: Int = 0, val errors: Int = 0, val total: Int = 0) + +@Serializable +data class EnrichStats( + @SerialName("total_with_isbn") val totalWithIsbn: Int = 0, + @SerialName("missing_cover") val missingCover: Int = 0, + @SerialName("missing_description") val missingDescription: Int = 0, + val pending: Int = 0, +) + +@Serializable +data class EnrichOverview(val stats: EnrichStats = EnrichStats(), @SerialName("cron_enabled") val cronEnabled: Boolean = false) + +@Serializable +data class EnrichBatchResult( + val processed: Int = 0, + val enriched: Int = 0, + @SerialName("not_found") val notFound: Int = 0, + val skipped: Int = 0, + val errors: Int = 0, + val details: List = emptyList(), +) + +@Serializable +data class CronRequest(val enabled: Boolean) + +@Serializable +data class CronResult(val enabled: Boolean = false) diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt index 94f39e3..9af4550 100644 --- a/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/StaffModels.kt @@ -36,15 +36,38 @@ data class StaffLookups( @SerialName("acquisition_types") val acquisitionTypes: List = emptyList(), @SerialName("contributor_roles") val contributorRoles: List = emptyList(), @SerialName("copy_states") val copyStates: List = emptyList(), + // Mobile API 1.7.0 additions. + @SerialName("series_types") val seriesTypes: List = emptyList(), + @SerialName("return_outcomes") val returnOutcomes: List = emptyList(), ) { val languageOptions: List get() = options(languages) val mediaTypeOptions: List get() = options(mediaTypes) val formatOptions: List get() = options(formats) val acquisitionTypeOptions: List get() = options(acquisitionTypes) val copyStateOptions: List get() = options(copyStates) + val seriesTypeOptions: List get() = options(seriesTypes) + val returnOutcomeOptions: List get() = options(returnOutcomes) + + /** + * The copy states a staff member may set by hand. 1.7.0 marks them `editable`; an older + * server sends no flag, and then every state except the circulation ones qualifies. + */ + val editableCopyStateOptions: List + get() = copyStates.mapNotNull { raw -> + val option = raw.toLookupOption() ?: return@mapNotNull null + val flag = ((raw as? JsonObject)?.get("editable") as? JsonPrimitive)?.contentOrNull + val editable = when (flag) { + null -> option.value !in CIRCULATION_COPY_STATES + else -> flag == "true" || flag == "1" + } + option.takeIf { editable } + } companion object { fun options(raw: List): List = raw.mapNotNull { it.toLookupOption() } + + /** Copy states set only by circulation (a loan or a hold), never by hand. */ + val CIRCULATION_COPY_STATES = setOf("prestato", "prenotato") } } @@ -160,12 +183,36 @@ data class StaffLocation( val label: String? = null, ) +/** + * One physical copy. The book editor (1.6.0) sends the first four fields; `GET /staff/copies/{id}` + * and the copy-code lookup (1.7.0) add the rest. + */ @Serializable data class StaffCopy( val id: Int = 0, @SerialName("inventory_number") val inventoryNumber: String? = null, val status: String? = null, val notes: String? = null, + @SerialName("status_label") val statusLabel: String? = null, + val book: StaffLoanBook? = null, + /** Reserved for a borrower (a pickup or a scheduled loan holds it). */ + val held: Boolean = false, + /** The server would accept a delete (no circulation, no history). */ + val deletable: Boolean = false, + @SerialName("on_loan") val onLoan: CopyOnLoan? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, + /** Copy-code lookup only: whether a new loan can use this copy now. */ + val available: Boolean? = null, + /** Copy-code lookup only: the open loan on this copy. */ + val loan: StaffLoan? = null, +) + +@Serializable +data class CopyOnLoan( + @SerialName("loan_id") val loanId: Int = 0, + @SerialName("user_name") val userName: String? = null, + @SerialName("due_at") val dueAt: String? = null, ) @Serializable @@ -235,6 +282,8 @@ data class AddCopiesRequest( val quantity: Int, val status: String? = null, val notes: String? = null, + /** 1.7.0: an explicit inventory code, only with quantity 1. */ + @SerialName("inventory_number") val inventoryNumber: String? = null, ) @Serializable diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffPluginModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffPluginModels.kt new file mode 100644 index 0000000..ca9d9ce --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/StaffPluginModels.kt @@ -0,0 +1,160 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull + +// Plugin content managed by staff (Mobile API 1.7.0, tag `staff-plugins`): Desiderata +// donation offers and the Archives plugin (units, authorities, attachments). + +@Serializable +data class IdTitle(val id: Int = 0, val title: String = "") + +// ---------- Desiderata ---------- + +@Serializable +data class StaffOffer( + val id: Int = 0, + /** `pending` | `accepted` | `received` | `rejected`. */ + val status: String = "pending", + @SerialName("status_label") val statusLabel: String? = null, + @SerialName("donor_name") val donorName: String? = null, + @SerialName("donor_email") val donorEmail: String? = null, + val title: String = "", + val author: String? = null, + val publisher: String? = null, + val isbn: String? = null, + val notes: String? = null, + val book: IdTitle? = null, + @SerialName("received_book") val receivedBook: IdTitle? = null, + @SerialName("copy_id") val copyId: Int? = null, + /** `web` | `mobile`. */ + val source: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("received_at") val receivedAt: String? = null, +) + +@Serializable +data class ReceiveOfferRequest(@SerialName("received_book_id") val receivedBookId: Int? = null) + +@Serializable +data class StaffWantedBook( + val id: Int = 0, + val title: String = "", + val subtitle: String? = null, + val author: String? = null, + val publisher: String? = null, + val isbn: String? = null, + @SerialName("cover_url") val coverUrl: String? = null, + @SerialName("open_offers") val openOffers: Int = 0, +) + +@Serializable +data class WantedReceiveResult( + @SerialName("book_id") val bookId: Int = 0, + @SerialName("copy_id") val copyId: Int? = null, + @SerialName("offer_id") val offerId: Int? = null, +) + +// ---------- Archives ---------- + +@Serializable +data class StaffArchiveRow( + val id: Int = 0, + @SerialName("parent_id") val parentId: Int? = null, + val title: String = "", + @SerialName("reference_code") val referenceCode: String = "", + val level: String = "", + @SerialName("date_start") val dateStart: Int? = null, + @SerialName("date_end") val dateEnd: Int? = null, + @SerialName("cover_url") val coverUrl: String? = null, + val published: Boolean = true, +) { + val datesLabel: String? get() = dateStart?.let { start -> + dateEnd?.takeUnless { it == start }?.let { "$start–$it" } ?: start.toString() + } +} + +@Serializable +data class ArchiveFile(val id: Int = 0, val url: String = "", val mime: String? = null, val label: String? = null) + +@Serializable +data class ArchiveAuthorityRef(val id: Int = 0, val type: String? = null, val name: String = "", val role: String? = null) + +/** `GET /staff/archives/{id}`: the public record shape plus the editable columns. */ +@Serializable +data class StaffArchiveDetail( + val id: Int = 0, + @SerialName("parent_id") val parentId: Int? = null, + val title: String = "", + @SerialName("reference_code") val referenceCode: String = "", + val level: String = "", + @SerialName("cover_url") val coverUrl: String? = null, + val published: Boolean = true, + /** Every column of the row, raw (strings, numbers or null). */ + val fields: Map = emptyMap(), + val files: List = emptyList(), + val authorities: List = emptyList(), +) { + /** A column as text, "" when absent or null. */ + fun field(key: String): String = + (fields[key] as? JsonPrimitive)?.takeUnless { it is JsonNull }?.contentOrNull.orEmpty() +} + +/** `GET /staff/archives/lookups`: each list is `[{value,label}]`. */ +@Serializable +data class ArchiveLookups( + val levels: List = emptyList(), + @SerialName("specific_materials") val specificMaterials: List = emptyList(), + @SerialName("color_modes") val colorModes: List = emptyList(), + @SerialName("authority_types") val authorityTypes: List = emptyList(), + @SerialName("authority_roles") val authorityRoles: List = emptyList(), +) { + val levelOptions: List get() = StaffLookups.options(levels) + val materialOptions: List get() = StaffLookups.options(specificMaterials) + val colorModeOptions: List get() = StaffLookups.options(colorModes) + val authorityTypeOptions: List get() = StaffLookups.options(authorityTypes) + val authorityRoleOptions: List get() = StaffLookups.options(authorityRoles) +} + +@Serializable +data class AuthorityRow( + val id: Int = 0, + val type: String? = null, + val name: String = "", + val dates: String? = null, + @SerialName("units_count") val unitsCount: Int = 0, +) + +@Serializable +data class AuthorityUnitRef(val id: Int = 0, val title: String = "", val role: String? = null) + +/** + * `GET /staff/archives/authorities/{id}`: the full row (ISAAR(CPF) columns) plus the linked + * units and catalogue authors. Kept raw so a column the app does not know survives. + */ +@Serializable +data class AuthorityDetail( + val id: Int = 0, + val type: String? = null, + @SerialName("authorised_form") val authorisedForm: String? = null, + @SerialName("parallel_forms") val parallelForms: String? = null, + @SerialName("other_forms") val otherForms: String? = null, + val identifiers: String? = null, + @SerialName("dates_of_existence") val datesOfExistence: String? = null, + val history: String? = null, + val places: String? = null, + @SerialName("legal_status") val legalStatus: String? = null, + val functions: String? = null, + val mandates: String? = null, + @SerialName("internal_structure") val internalStructure: String? = null, + @SerialName("general_context") val generalContext: String? = null, + val units: List = emptyList(), + val authors: List = emptyList(), +) + +@Serializable +data class AttachAuthorityRequest(@SerialName("authority_id") val authorityId: Int, val role: String) diff --git a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt index ae3ed55..6950dda 100644 --- a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt +++ b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt @@ -102,6 +102,25 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { private var cachedCollectionsApi: CollectionsApi? = null @Volatile private var cachedStaffApi: StaffApi? = null + @Volatile + private var cachedStaffCirculationApi: StaffCirculationApi? = null + @Volatile + private var cachedStaffContentApi: StaffContentApi? = null + @Volatile + private var cachedStaffPluginsApi: StaffPluginsApi? = null + @Volatile + private var cachedLongStaffApi: StaffContentApi? = null + @Volatile + private var cachedLongBaseUrl: String? = null + + /** + * The same client with an 11-minute read timeout, for the synchronous server jobs that may + * run up to 600 s (covers sync, an enrichment batch, an import chunk with scraping). Only + * those calls use it; everything else keeps the normal timeouts. + */ + private val longOkHttpClient: OkHttpClient by lazy { + okHttpClient.newBuilder().readTimeout(660, TimeUnit.SECONDS).writeTimeout(120, TimeUnit.SECONDS).build() + } /** Shared Retrofit for a given base URL, rebuilt only when the instance URL changes. */ @Synchronized @@ -125,6 +144,9 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { cachedPeriodicalsApi = null cachedCollectionsApi = null cachedStaffApi = null + cachedStaffCirculationApi = null + cachedStaffContentApi = null + cachedStaffPluginsApi = null return retrofit } @@ -172,6 +194,47 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { return cachedStaffApi ?: retrofit.create(StaffApi::class.java).also { cachedStaffApi = it } } + /** Staff circulation desk (`/staff/circulation`, `/staff/loans`, `/staff/reservations`, ...). */ + @Synchronized + fun staffCirculationApi(baseUrl: String? = null): StaffCirculationApi { + val retrofit = retrofit(baseUrl) + return cachedStaffCirculationApi ?: retrofit.create(StaffCirculationApi::class.java).also { cachedStaffCirculationApi = it } + } + + /** Staff content: copies, series, merges, labels, imports, covers and enrichment. */ + @Synchronized + fun staffContentApi(baseUrl: String? = null): StaffContentApi { + val retrofit = retrofit(baseUrl) + return cachedStaffContentApi ?: retrofit.create(StaffContentApi::class.java).also { cachedStaffContentApi = it } + } + + /** Staff management of plugin content (Desiderata, Archives). */ + @Synchronized + fun staffPluginsApi(baseUrl: String? = null): StaffPluginsApi { + val retrofit = retrofit(baseUrl) + return cachedStaffPluginsApi ?: retrofit.create(StaffPluginsApi::class.java).also { cachedStaffPluginsApi = it } + } + + /** + * [StaffContentApi] on the long-timeout client. Use it only for `covers/sync`, + * `enrich/batch` and import chunks with scraping, which the server runs synchronously. + */ + @Synchronized + fun longStaffContentApi(): StaffContentApi { + val target = requireNotNull(session.instanceUrl) { "No instance URL configured. Complete onboarding first." } + val normalized = if (target.endsWith("/")) target else "$target/" + cachedLongStaffApi?.let { if (normalized == cachedLongBaseUrl) return it } + val api = Retrofit.Builder() + .baseUrl(normalized) + .client(longOkHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(StaffContentApi::class.java) + cachedLongStaffApi = api + cachedLongBaseUrl = normalized + return api + } + /** Drop the cached Retrofit so the next [api] call rebuilds against a new instance URL. */ @Synchronized fun invalidate() { @@ -182,6 +245,11 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { cachedPeriodicalsApi = null cachedCollectionsApi = null cachedStaffApi = null + cachedStaffCirculationApi = null + cachedStaffContentApi = null + cachedStaffPluginsApi = null + cachedLongStaffApi = null + cachedLongBaseUrl = null } /** diff --git a/app/src/main/java/com/pinakes/app/data/network/StaffCirculationApi.kt b/app/src/main/java/com/pinakes/app/data/network/StaffCirculationApi.kt new file mode 100644 index 0000000..b445533 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/StaffCirculationApi.kt @@ -0,0 +1,131 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.BookQueue +import com.pinakes.app.data.model.BulkExtendRequest +import com.pinakes.app.data.model.BulkExtendResult +import com.pinakes.app.data.model.BulkIdsRequest +import com.pinakes.app.data.model.BulkRecallResult +import com.pinakes.app.data.model.CirculationSummary +import com.pinakes.app.data.model.CopyLookup +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.LoanCreateRequest +import com.pinakes.app.data.model.LoanEditRequest +import com.pinakes.app.data.model.ReasonRequest +import com.pinakes.app.data.model.ReservationWrite +import com.pinakes.app.data.model.ReturnByCodeRequest +import com.pinakes.app.data.model.ReturnRequest +import com.pinakes.app.data.model.SentResult +import com.pinakes.app.data.model.StaffLoan +import com.pinakes.app.data.model.StaffReservation +import com.pinakes.app.data.model.StaffUser +import com.pinakes.app.data.model.StaffUserDetail +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Staff circulation desk (Mobile API 1.7.0). Same base URL and bearer token as [PinakesApi]; + * every route answers 403 `forbidden` to non-staff and 404 `feature_disabled` in catalogue mode. + */ +interface StaffCirculationApi { + + @GET("staff/circulation/summary") + suspend fun summary(): Envelope + + @GET("staff/loans") + suspend fun loans( + @Query("bucket") bucket: String? = null, + @Query("q") q: String? = null, + @Query("user_id") userId: Int? = null, + @Query("book_id") bookId: Int? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): Envelope> + + @GET("staff/loans/{id}") + suspend fun loan(@Path("id") id: Int): Envelope + + @POST("staff/loans") + suspend fun createLoan(@Body body: LoanCreateRequest): Envelope + + @PUT("staff/loans/{id}") + suspend fun updateLoan(@Path("id") id: Int, @Body body: LoanEditRequest): Envelope + + @POST("staff/loans/{id}/approve") + suspend fun approve(@Path("id") id: Int): Envelope + + @POST("staff/loans/{id}/reject") + suspend fun reject(@Path("id") id: Int, @Body body: ReasonRequest): Envelope + + @POST("staff/loans/{id}/pickup") + suspend fun pickup(@Path("id") id: Int): Envelope + + @POST("staff/loans/{id}/cancel-pickup") + suspend fun cancelPickup(@Path("id") id: Int, @Body body: ReasonRequest): Envelope + + @POST("staff/loans/{id}/return") + suspend fun returnLoan(@Path("id") id: Int, @Body body: ReturnRequest): Envelope + + @POST("staff/loans/return-by-code") + suspend fun returnByCode(@Body body: ReturnByCodeRequest): Envelope + + @POST("staff/loans/{id}/renew") + suspend fun renew(@Path("id") id: Int): Envelope + + @POST("staff/loans/bulk-extend") + suspend fun bulkExtend(@Body body: BulkExtendRequest): Envelope + + @POST("staff/loans/{id}/recall") + suspend fun recall(@Path("id") id: Int): Envelope + + @POST("staff/loans/bulk-recall") + suspend fun bulkRecall(@Body body: BulkIdsRequest): Envelope + + @POST("staff/loans/{id}/receipt/email") + suspend fun emailReceipt(@Path("id") id: Int): Envelope + + @GET("staff/reservations") + suspend fun reservations( + @Query("status") status: String? = null, + @Query("q") q: String? = null, + @Query("book_id") bookId: Int? = null, + @Query("user_id") userId: Int? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): Envelope> + + @GET("staff/reservations/{id}") + suspend fun reservation(@Path("id") id: Int): Envelope + + @POST("staff/reservations") + suspend fun createReservation(@Body body: ReservationWrite): Envelope + + @PUT("staff/reservations/{id}") + suspend fun updateReservation(@Path("id") id: Int, @Body body: ReservationWrite): Envelope + + @POST("staff/reservations/{id}/cancel") + suspend fun cancelReservation(@Path("id") id: Int, @Body body: ReasonRequest): Envelope + + @GET("staff/books/{id}/queue") + suspend fun queue(@Path("id") bookId: Int): Envelope + + @GET("staff/books/{id}/availability") + suspend fun availability( + @Path("id") bookId: Int, + @Query("user_id") userId: Int? = null, + @Query("days") days: Int? = null, + ): Envelope + + @GET("staff/users") + suspend fun users(@Query("q") q: String, @Query("limit") limit: Int? = null): Envelope> + + @GET("staff/users/{id}") + suspend fun user(@Path("id") id: Int): Envelope + + @GET("staff/copies/by-code") + suspend fun copyByCode(@Query("code") code: String): Envelope +} diff --git a/app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt b/app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt new file mode 100644 index 0000000..eaa7915 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt @@ -0,0 +1,164 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.AssignResult +import com.pinakes.app.data.model.BookIdsRequest +import com.pinakes.app.data.model.ChunkRequest +import com.pinakes.app.data.model.CopyUpdate +import com.pinakes.app.data.model.CronRequest +import com.pinakes.app.data.model.CronResult +import com.pinakes.app.data.model.DeleteResult +import com.pinakes.app.data.model.DuplicateAuthor +import com.pinakes.app.data.model.EnrichBatchResult +import com.pinakes.app.data.model.EnrichOverview +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.FetchCoverResult +import com.pinakes.app.data.model.GenreMergeResult +import com.pinakes.app.data.model.ImportJob +import com.pinakes.app.data.model.ImportsOverview +import com.pinakes.app.data.model.LabelSettings +import com.pinakes.app.data.model.MergeRequest +import com.pinakes.app.data.model.MergeResult +import com.pinakes.app.data.model.MissingCover +import com.pinakes.app.data.model.ParentWorkRequest +import com.pinakes.app.data.model.ParentWorkResult +import com.pinakes.app.data.model.SeriesDeleteResult +import com.pinakes.app.data.model.SeriesMergeResult +import com.pinakes.app.data.model.SeriesRenameRequest +import com.pinakes.app.data.model.SeriesWrite +import com.pinakes.app.data.model.StaffCopy +import com.pinakes.app.data.model.StaffSeries +import com.pinakes.app.data.model.StaffSeriesDetail +import com.pinakes.app.data.model.SyncResult +import com.pinakes.app.data.model.TargetRequest +import kotlinx.serialization.json.JsonObject +import okhttp3.MultipartBody +import okhttp3.RequestBody +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query +import retrofit2.http.Streaming +import retrofit2.http.Url + +/** + * Staff content (Mobile API 1.7.0): copies, series, merges, labels, imports, covers and + * enrichment. [download] fetches a PDF or CSV with the bearer token (labels, receipts, + * import errors): those never go through the unauthenticated media client. + */ +interface StaffContentApi { + + @GET("staff/copies/{id}") + suspend fun copy(@Path("id") id: Int): Envelope + + @PUT("staff/copies/{id}") + suspend fun updateCopy(@Path("id") id: Int, @Body body: CopyUpdate): Envelope + + @DELETE("staff/copies/{id}") + suspend fun deleteCopy(@Path("id") id: Int): Envelope + + @GET("staff/series") + suspend fun series(@Query("q") q: String? = null, @Query("flat") flat: Int? = null): Envelope> + + @GET("staff/series/{id}") + suspend fun seriesDetail(@Path("id") id: Int): Envelope + + @GET("staff/series/suggest") + suspend fun seriesSuggest(@Query("q") q: String, @Query("field") field: String? = null): Envelope> + + @POST("staff/series") + suspend fun createSeries(@Body body: SeriesWrite): Envelope + + @PUT("staff/series/{id}") + suspend fun updateSeries(@Path("id") id: Int, @Body body: SeriesWrite): Envelope + + @POST("staff/series/{id}/rename") + suspend fun renameSeries(@Path("id") id: Int, @Body body: SeriesRenameRequest): Envelope + + @POST("staff/series/{id}/merge") + suspend fun mergeSeries(@Path("id") id: Int, @Body body: TargetRequest): Envelope + + @DELETE("staff/series/{id}") + suspend fun deleteSeries(@Path("id") id: Int): Envelope + + @POST("staff/series/{id}/books") + suspend fun assignBooks(@Path("id") id: Int, @Body body: BookIdsRequest): Envelope + + /** Body `{series_number: string|null}`, built raw so a cleared number is sent as JSON null. */ + @PUT("staff/series/{id}/books/{bookId}") + suspend fun setSeriesNumber(@Path("id") id: Int, @Path("bookId") bookId: Int, @Body body: JsonObject): Envelope + + @DELETE("staff/series/{id}/books/{bookId}") + suspend fun removeFromSeries(@Path("id") id: Int, @Path("bookId") bookId: Int): Envelope + + @POST("staff/series/{id}/parent-work") + suspend fun parentWork(@Path("id") id: Int, @Body body: ParentWorkRequest): Envelope + + @GET("staff/authors/duplicates") + suspend fun authorDuplicates(): Envelope>> + + @POST("staff/authors/merge") + suspend fun mergeAuthors(@Body body: MergeRequest): Envelope + + @POST("staff/publishers/merge") + suspend fun mergePublishers(@Body body: MergeRequest): Envelope + + @POST("staff/genres/{id}/merge") + suspend fun mergeGenre(@Path("id") id: Int, @Body body: TargetRequest): Envelope + + @GET("staff/settings/labels") + suspend fun labelSettings(): Envelope + + @PUT("staff/settings/labels") + suspend fun saveLabelSettings(@Body body: LabelSettings): Envelope + + @Multipart + @POST("staff/imports") + suspend fun createImport( + @Part file: MultipartBody.Part, + @Part("kind") kind: RequestBody, + @Part("enable_scraping") enableScraping: RequestBody, + @Part("update_fields") updateFields: RequestBody?, + ): Envelope + + @POST("staff/imports/{id}/chunk") + suspend fun importChunk(@Path("id") id: String, @Body body: ChunkRequest): Envelope + + @GET("staff/imports/{id}") + suspend fun importJob(@Path("id") id: String): Envelope + + @DELETE("staff/imports/{id}") + suspend fun cancelImport(@Path("id") id: String): Envelope + + @GET("staff/imports") + suspend fun imports(@Query("cursor") cursor: String? = null, @Query("limit") limit: Int? = null): Envelope + + @GET("staff/covers/missing") + suspend fun missingCovers(@Query("cursor") cursor: String? = null, @Query("limit") limit: Int? = null): Envelope> + + @POST("staff/books/{id}/cover/fetch") + suspend fun fetchCover(@Path("id") bookId: Int): Envelope + + @POST("staff/covers/sync") + suspend fun syncCovers(): Envelope + + @GET("staff/enrich") + suspend fun enrich(): Envelope + + @POST("staff/enrich/batch") + suspend fun enrichBatch(): Envelope + + @PUT("staff/enrich/cron") + suspend fun enrichCron(@Body body: CronRequest): Envelope + + /** A binary staff export (PDF, CSV), relative to the API base URL. */ + @Streaming + @GET + suspend fun download(@Url path: String): Response +} diff --git a/app/src/main/java/com/pinakes/app/data/network/StaffPluginsApi.kt b/app/src/main/java/com/pinakes/app/data/network/StaffPluginsApi.kt new file mode 100644 index 0000000..986e198 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/StaffPluginsApi.kt @@ -0,0 +1,127 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.ArchiveLookups +import com.pinakes.app.data.model.AttachAuthorityRequest +import com.pinakes.app.data.model.AuthorityDetail +import com.pinakes.app.data.model.AuthorityRow +import com.pinakes.app.data.model.DeleteResult +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.ReceiveOfferRequest +import com.pinakes.app.data.model.StaffArchiveDetail +import com.pinakes.app.data.model.StaffArchiveRow +import com.pinakes.app.data.model.StaffOffer +import com.pinakes.app.data.model.StaffWantedBook +import com.pinakes.app.data.model.WantedReceiveResult +import kotlinx.serialization.json.JsonObject +import okhttp3.MultipartBody +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Multipart +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Staff management of plugin content (Mobile API 1.7.0): Desiderata and Archives. A plugin + * that is not active answers 404 `feature_disabled`; the app hides the entry from `/health`. + */ +interface StaffPluginsApi { + + @GET("staff/desiderata/offers") + suspend fun offers( + @Query("status") status: String? = null, + @Query("q") q: String? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): Envelope> + + @POST("staff/desiderata/offers/{id}/accept") + suspend fun acceptOffer(@Path("id") id: Int): Envelope + + @POST("staff/desiderata/offers/{id}/reject") + suspend fun rejectOffer(@Path("id") id: Int): Envelope + + @POST("staff/desiderata/offers/{id}/receive") + suspend fun receiveOffer(@Path("id") id: Int, @Body body: ReceiveOfferRequest): Envelope + + @DELETE("staff/desiderata/offers/{id}") + suspend fun deleteOffer(@Path("id") id: Int): Envelope + + @GET("staff/desiderata/wanted") + suspend fun wanted( + @Query("q") q: String? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): Envelope> + + @POST("staff/desiderata/wanted/{bookId}/receive") + suspend fun receiveWanted(@Path("bookId") bookId: Int): Envelope + + @GET("staff/archives/lookups") + suspend fun archiveLookups(): Envelope + + @GET("staff/archives") + suspend fun archives( + @Query("q") q: String? = null, + @Query("level") level: String? = null, + @Query("published") published: String? = null, + @Query("parent_id") parentId: Int? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): Envelope> + + @GET("staff/archives/{id}") + suspend fun archive(@Path("id") id: Int): Envelope + + @POST("staff/archives") + suspend fun createArchive(@Body body: JsonObject): Envelope + + @PUT("staff/archives/{id}") + suspend fun updateArchive(@Path("id") id: Int, @Body body: JsonObject): Envelope + + @DELETE("staff/archives/{id}") + suspend fun deleteArchive(@Path("id") id: Int): Envelope + + @Multipart + @POST("staff/archives/{id}/cover") + suspend fun uploadArchiveCover(@Path("id") id: Int, @Part cover: MultipartBody.Part): Envelope + + @DELETE("staff/archives/{id}/cover") + suspend fun deleteArchiveCover(@Path("id") id: Int): Envelope + + @Multipart + @POST("staff/archives/{id}/documents") + suspend fun uploadArchiveDocument(@Path("id") id: Int, @Part document: MultipartBody.Part): Envelope + + @DELETE("staff/archives/{id}/documents/{fileId}") + suspend fun deleteArchiveDocument(@Path("id") id: Int, @Path("fileId") fileId: Int): Envelope + + @POST("staff/archives/{id}/authorities") + suspend fun attachAuthority(@Path("id") id: Int, @Body body: AttachAuthorityRequest): Envelope + + @DELETE("staff/archives/{id}/authorities/{authorityId}") + suspend fun detachAuthority(@Path("id") id: Int, @Path("authorityId") authorityId: Int): Envelope + + @GET("staff/archives/authorities") + suspend fun authorities( + @Query("q") q: String? = null, + @Query("type") type: String? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): Envelope> + + @GET("staff/archives/authorities/{id}") + suspend fun authority(@Path("id") id: Int): Envelope + + @POST("staff/archives/authorities") + suspend fun createAuthority(@Body body: JsonObject): Envelope + + @PUT("staff/archives/authorities/{id}") + suspend fun updateAuthority(@Path("id") id: Int, @Body body: JsonObject): Envelope + + @DELETE("staff/archives/authorities/{id}") + suspend fun deleteAuthority(@Path("id") id: Int): Envelope +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffCalls.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffCalls.kt new file mode 100644 index 0000000..9b7f3f4 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffCalls.kt @@ -0,0 +1,39 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.apiCall +import com.pinakes.app.data.store.SessionStore + +/** + * One staff call: [apiCall] plus the Phase 1 role contract. A 403 `forbidden` from the API's + * own envelope means the server no longer considers this user staff, so the stored role is + * dropped (every staff entry point disappears) and the failure is returned for the screen to + * leave. Every staff repository method goes through this. + */ +internal suspend fun SessionStore.staffCall(block: suspend () -> Envelope): ApiResult { + val res = apiCall(block) + if (res is ApiResult.Failure && res.isForbidden()) saveRole(null) + return res +} + +/** One page of a staff list. */ +data class StaffPage(val items: List, val nextCursor: String?, val totalCount: Int? = null) + +/** A list answer as a page: the cursor only counts while the server says there is more. */ +internal fun ApiResult>.toPage(): ApiResult> = when (this) { + is ApiResult.Success -> { + val next = meta?.nextCursor?.takeIf { it.isNotBlank() && meta.hasMore != false } + ApiResult.Success(StaffPage(data, next, meta?.totalCount), meta) + } + is ApiResult.Failure -> this +} + +/** Map a success, keep a failure. */ +internal inline fun ApiResult.map(transform: (T) -> R): ApiResult = when (this) { + is ApiResult.Success -> ApiResult.Success(transform(data), meta) + is ApiResult.Failure -> this +} + +/** Blank text → null, so optional keys are left out of the request body. */ +internal fun String?.blankToNull(): String? = this?.trim()?.takeIf { it.isNotEmpty() } diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt new file mode 100644 index 0000000..1776740 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt @@ -0,0 +1,123 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.BookQueue +import com.pinakes.app.data.model.BulkExtendRequest +import com.pinakes.app.data.model.BulkExtendResult +import com.pinakes.app.data.model.BulkIdsRequest +import com.pinakes.app.data.model.BulkRecallResult +import com.pinakes.app.data.model.CirculationSummary +import com.pinakes.app.data.model.CopyLookup +import com.pinakes.app.data.model.LoanCreateRequest +import com.pinakes.app.data.model.LoanEditRequest +import com.pinakes.app.data.model.ReasonRequest +import com.pinakes.app.data.model.ReservationWrite +import com.pinakes.app.data.model.ReturnByCodeRequest +import com.pinakes.app.data.model.ReturnRequest +import com.pinakes.app.data.model.SentResult +import com.pinakes.app.data.model.StaffLoan +import com.pinakes.app.data.model.StaffReservation +import com.pinakes.app.data.model.StaffUser +import com.pinakes.app.data.model.StaffUserDetail +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.staff.CopyCodeCodec +import com.pinakes.app.data.store.SessionStore + +/** The loan list buckets of `GET /staff/loans`. */ +enum class LoanBucket(val wire: String) { + PENDING("pending"), PICKUP("pickup"), SCHEDULED("scheduled"), ACTIVE("active"), OVERDUE("overdue"), HISTORY("history"), ALL("all"); + + companion object { + fun fromWire(value: String?): LoanBucket = entries.firstOrNull { it.wire == value } ?: ALL + } +} + +/** + * The staff circulation desk (Mobile API 1.7.0). Every call goes through [staffCall], so a + * demoted operator loses the staff area on the first refused call. Writes answer the + * reloaded resource; the screens show it as is. + */ +class StaffCirculationRepository( + private val network: NetworkModule, + private val session: SessionStore, +) { + private val api get() = network.staffCirculationApi() + + suspend fun summary(): ApiResult = session.staffCall { api.summary() } + + suspend fun loans( + bucket: LoanBucket, + query: String? = null, + cursor: String? = null, + userId: Int? = null, + bookId: Int? = null, + limit: Int = 30, + ): ApiResult> = session.staffCall { + api.loans(bucket = bucket.wire, q = query.blankToNull(), userId = userId, bookId = bookId, cursor = cursor, limit = limit) + }.toPage() + + suspend fun loan(id: Int): ApiResult = session.staffCall { api.loan(id) } + + suspend fun createLoan(request: LoanCreateRequest): ApiResult = + session.staffCall { api.createLoan(request.copy(copyCode = request.copyCode?.let(CopyCodeCodec::normalize), notes = request.notes.blankToNull())) } + + suspend fun updateLoan(id: Int, request: LoanEditRequest): ApiResult = session.staffCall { api.updateLoan(id, request) } + + suspend fun approve(id: Int): ApiResult = session.staffCall { api.approve(id) } + + suspend fun reject(id: Int, reason: String?): ApiResult = + session.staffCall { api.reject(id, ReasonRequest(reason.blankToNull()?.take(500))) } + + suspend fun pickup(id: Int): ApiResult = session.staffCall { api.pickup(id) } + + suspend fun cancelPickup(id: Int, reason: String?): ApiResult = + session.staffCall { api.cancelPickup(id, ReasonRequest(reason.blankToNull()?.take(500))) } + + suspend fun returnLoan(id: Int, outcome: String, notes: String?, penalty: Double?): ApiResult = + session.staffCall { api.returnLoan(id, ReturnRequest(outcome, notes.blankToNull(), penalty)) } + + suspend fun returnByCode(code: String, outcome: String, notes: String?, penalty: Double?): ApiResult = + session.staffCall { api.returnByCode(ReturnByCodeRequest(CopyCodeCodec.normalize(code), outcome, notes.blankToNull(), penalty)) } + + suspend fun renew(id: Int): ApiResult = session.staffCall { api.renew(id) } + + suspend fun bulkExtend(ids: List, days: Int): ApiResult = + session.staffCall { api.bulkExtend(BulkExtendRequest(ids.take(500), days.coerceIn(1, 365))) } + + suspend fun recall(id: Int): ApiResult = session.staffCall { api.recall(id) } + + suspend fun bulkRecall(ids: List): ApiResult = session.staffCall { api.bulkRecall(BulkIdsRequest(ids.take(50))) } + + suspend fun emailReceipt(id: Int): ApiResult = session.staffCall { api.emailReceipt(id) } + + suspend fun reservations( + status: String, + query: String? = null, + cursor: String? = null, + bookId: Int? = null, + userId: Int? = null, + limit: Int = 50, + ): ApiResult> = session.staffCall { + api.reservations(status = status, q = query.blankToNull(), bookId = bookId, userId = userId, cursor = cursor, limit = limit) + }.toPage() + + suspend fun createReservation(body: ReservationWrite): ApiResult = session.staffCall { api.createReservation(body) } + + suspend fun updateReservation(id: Int, body: ReservationWrite): ApiResult = + session.staffCall { api.updateReservation(id, body) } + + suspend fun cancelReservation(id: Int, reason: String?): ApiResult = + session.staffCall { api.cancelReservation(id, ReasonRequest(reason.blankToNull()?.take(500))) } + + suspend fun queue(bookId: Int): ApiResult = session.staffCall { api.queue(bookId) } + + suspend fun availability(bookId: Int, userId: Int?): ApiResult = + session.staffCall { api.availability(bookId, userId?.takeIf { it > 0 }, 180) } + + suspend fun users(query: String, limit: Int = 20): ApiResult> = session.staffCall { api.users(query.trim(), limit) } + + suspend fun user(id: Int): ApiResult = session.staffCall { api.user(id) } + + suspend fun copyByCode(code: String): ApiResult = session.staffCall { api.copyByCode(CopyCodeCodec.normalize(code)) } +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt new file mode 100644 index 0000000..f82cd39 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt @@ -0,0 +1,175 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.AssignResult +import com.pinakes.app.data.model.BookIdsRequest +import com.pinakes.app.data.model.ChunkRequest +import com.pinakes.app.data.model.CopyUpdate +import com.pinakes.app.data.model.CronRequest +import com.pinakes.app.data.model.CronResult +import com.pinakes.app.data.model.DeleteResult +import com.pinakes.app.data.model.DuplicateAuthor +import com.pinakes.app.data.model.EnrichBatchResult +import com.pinakes.app.data.model.EnrichOverview +import com.pinakes.app.data.model.FetchCoverResult +import com.pinakes.app.data.model.GenreMergeResult +import com.pinakes.app.data.model.IdName +import com.pinakes.app.data.model.ImportJob +import com.pinakes.app.data.model.ImportsOverview +import com.pinakes.app.data.model.LabelSettings +import com.pinakes.app.data.model.MergeRequest +import com.pinakes.app.data.model.MergeResult +import com.pinakes.app.data.model.MissingCover +import com.pinakes.app.data.model.ParentWorkRequest +import com.pinakes.app.data.model.ParentWorkResult +import com.pinakes.app.data.model.SeriesDeleteResult +import com.pinakes.app.data.model.SeriesMergeResult +import com.pinakes.app.data.model.SeriesRenameRequest +import com.pinakes.app.data.model.SeriesWrite +import com.pinakes.app.data.model.StaffCopy +import com.pinakes.app.data.model.StaffSeries +import com.pinakes.app.data.model.StaffSeriesDetail +import com.pinakes.app.data.model.SyncResult +import com.pinakes.app.data.model.TargetRequest +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.store.SessionStore +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody + +/** The import kinds of `POST /staff/imports`. */ +enum class ImportKind(val wire: String) { CSV("csv"), LIBRARYTHING("librarything") } + +/** + * Staff content (Mobile API 1.7.0). Every call goes through [staffCall]. Long synchronous + * server jobs (covers sync, an enrichment batch, an import chunk with scraping) use the + * long-timeout client; everything else the normal one. + */ +class StaffContentRepository( + private val network: NetworkModule, + private val session: SessionStore, +) { + private val api get() = network.staffContentApi() + + // ---------- copies ---------- + + suspend fun copy(id: Int): ApiResult = session.staffCall { api.copy(id) } + + suspend fun updateCopy(id: Int, status: String?, notes: String?): ApiResult = + session.staffCall { api.updateCopy(id, CopyUpdate(status = status.blankToNull(), notes = notes?.trim())) } + + suspend fun deleteCopy(id: Int): ApiResult = session.staffCall { api.deleteCopy(id) } + + // ---------- series ---------- + + suspend fun series(query: String? = null, flat: Boolean = false): ApiResult> = + session.staffCall { api.series(q = query.blankToNull(), flat = if (flat) 1 else null) } + + suspend fun seriesDetail(id: Int): ApiResult = session.staffCall { api.seriesDetail(id) } + + suspend fun seriesSuggest(query: String, field: String = "name"): ApiResult> = + session.staffCall { api.seriesSuggest(query.trim(), field) } + + suspend fun saveSeries(id: Int?, body: SeriesWrite): ApiResult = + session.staffCall { if (id == null || id <= 0) api.createSeries(body) else api.updateSeries(id, body) } + + suspend fun renameSeries(id: Int, name: String, mergeIfExists: Boolean): ApiResult = + session.staffCall { api.renameSeries(id, SeriesRenameRequest(name.trim(), mergeIfExists)) } + + suspend fun mergeSeries(id: Int, targetId: Int): ApiResult = session.staffCall { api.mergeSeries(id, TargetRequest(targetId)) } + + suspend fun deleteSeries(id: Int): ApiResult = session.staffCall { api.deleteSeries(id) } + + suspend fun assignBooks(id: Int, bookIds: List): ApiResult = + session.staffCall { api.assignBooks(id, BookIdsRequest(bookIds.distinct().take(200))) } + + /** A blank [number] clears it (sent as JSON null, not dropped). */ + suspend fun setSeriesNumber(id: Int, bookId: Int, number: String?): ApiResult { + val value = number.blankToNull()?.take(50) + val body = buildJsonObject { put("series_number", value?.let { JsonPrimitive(it) } ?: JsonNull) } + return session.staffCall { api.setSeriesNumber(id, bookId, body) } + } + + suspend fun removeFromSeries(id: Int, bookId: Int): ApiResult = session.staffCall { api.removeFromSeries(id, bookId) } + + suspend fun parentWork(id: Int, title: String): ApiResult = + session.staffCall { api.parentWork(id, ParentWorkRequest(title.trim().take(255))) } + + /** `meta.existing` of a 409 `duplicate` on a series create/rename. */ + fun existingSeries(failure: ApiResult.Failure): IdName? = + failure.meta?.existing?.let { runCatching { network.json.decodeFromJsonElement(it) }.getOrNull() }?.takeIf { it.id > 0 } + + // ---------- merges ---------- + + suspend fun authorDuplicates(): ApiResult>> = session.staffCall { api.authorDuplicates() } + + suspend fun mergeAuthors(ids: List, primaryId: Int?, newName: String?): ApiResult = + session.staffCall { api.mergeAuthors(MergeRequest(ids.distinct(), primaryId, newName.blankToNull())) } + + suspend fun mergePublishers(ids: List, primaryId: Int?, newName: String?): ApiResult = + session.staffCall { api.mergePublishers(MergeRequest(ids.distinct(), primaryId, newName.blankToNull())) } + + suspend fun mergeGenre(sourceId: Int, targetId: Int): ApiResult = + session.staffCall { api.mergeGenre(sourceId, TargetRequest(targetId)) } + + // ---------- labels ---------- + + suspend fun labelSettings(): ApiResult = session.staffCall { api.labelSettings() } + + suspend fun saveLabelSettings(body: LabelSettings): ApiResult = session.staffCall { api.saveLabelSettings(body) } + + // ---------- imports ---------- + + /** + * Upload [bytes] as a new import job. [updateFields] is a csv-only comma list (null = all + * families, the server default). + */ + suspend fun createImport( + kind: ImportKind, + fileName: String, + bytes: ByteArray, + mime: String?, + enableScraping: Boolean, + updateFields: List?, + ): ApiResult { + val type = (mime ?: "text/csv").toMediaTypeOrNull() ?: "text/csv".toMediaType() + val part = MultipartBody.Part.createFormData("file", fileName, bytes.toRequestBody(type)) + val text = "text/plain".toMediaType() + val fields = updateFields?.takeIf { kind == ImportKind.CSV }?.joinToString(",")?.toRequestBody(text) + return session.staffCall { + api.createImport(part, kind.wire.toRequestBody(text), (if (enableScraping) "1" else "0").toRequestBody(text), fields) + } + } + + /** One chunk from [start]; with scraping the server may need minutes, so the long client is used. */ + suspend fun importChunk(id: String, start: Int, scraping: Boolean): ApiResult { + val target = if (scraping) network.longStaffContentApi() else api + return session.staffCall { target.importChunk(id, ChunkRequest(start = start)) } + } + + suspend fun importJob(id: String): ApiResult = session.staffCall { api.importJob(id) } + + suspend fun cancelImport(id: String): ApiResult = session.staffCall { api.cancelImport(id) }.map { } + + suspend fun imports(): ApiResult = session.staffCall { api.imports(limit = 30) } + + // ---------- covers / enrichment ---------- + + suspend fun missingCovers(cursor: String? = null, limit: Int = 50): ApiResult> = + session.staffCall { api.missingCovers(cursor, limit) }.toPage() + + suspend fun fetchCover(bookId: Int): ApiResult = session.staffCall { api.fetchCover(bookId) } + + suspend fun syncCovers(): ApiResult = session.staffCall { network.longStaffContentApi().syncCovers() } + + suspend fun enrich(): ApiResult = session.staffCall { api.enrich() } + + suspend fun enrichBatch(): ApiResult = session.staffCall { network.longStaffContentApi().enrichBatch() } + + suspend fun setEnrichCron(enabled: Boolean): ApiResult = session.staffCall { api.enrichCron(CronRequest(enabled)) } +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffFilesRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffFilesRepository.kt new file mode 100644 index 0000000..06d4b54 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffFilesRepository.kt @@ -0,0 +1,82 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.store.SessionStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.File +import java.io.IOException +import javax.net.ssl.SSLException + +/** + * Staff exports (loan receipts, label PDFs, import error CSVs) downloaded with the bearer token + * into `cache/exports/`, which the FileProvider shares. Files older than a day are pruned on + * every download, so the directory never grows. A 403 `forbidden` drops the role like every + * other staff call. + */ +class StaffFilesRepository( + private val network: NetworkModule, + private val session: SessionStore, + cacheDir: File, +) { + val dir: File = File(cacheDir, EXPORTS_DIR) + + /** Download [path] (relative to `/api/v1/`) to a file named after the server's filename or [fallbackName]. */ + suspend fun download(path: String, fallbackName: String): ApiResult = withContext(Dispatchers.IO) { + prune(System.currentTimeMillis()) + try { + val response = network.staffContentApi().download(path) + if (!response.isSuccessful) { + val failure = parseErrorBody(response.errorBody()?.string(), response.code()) + if (failure.isForbidden()) session.saveRole(null) + return@withContext failure + } + val body = response.body() ?: return@withContext ApiResult.Failure(ErrorCodes.UNKNOWN, "Empty body", response.code()) + dir.mkdirs() + val name = safeName(filenameFrom(response.headers()["Content-Disposition"]) ?: fallbackName) + val target = File(dir, name) + val partial = File(dir, "$name.part") + body.use { b -> partial.outputStream().use { out -> b.byteStream().copyTo(out) } } + if (!partial.renameTo(target)) { + partial.copyTo(target, overwrite = true) + partial.delete() + } + ApiResult.Success(target) + } catch (e: SSLException) { + ApiResult.Failure(ErrorCodes.TLS, e.message ?: "TLS error") + } catch (e: IOException) { + ApiResult.Failure(ErrorCodes.NETWORK, e.message ?: "Network error") + } catch (e: Throwable) { + ApiResult.Failure(ErrorCodes.UNKNOWN, e.message ?: "Unexpected error") + } + } + + /** Remove exports older than [MAX_AGE_MS]. */ + fun prune(nowMs: Long) { + dir.listFiles()?.forEach { if (nowMs - it.lastModified() > MAX_AGE_MS) it.delete() } + } + + companion object { + const val EXPORTS_DIR = "exports" + const val MAX_AGE_MS = 24L * 60 * 60 * 1000 + + /** `attachment; filename="prestito_7_20261014.pdf"` → `prestito_7_20261014.pdf`. */ + fun filenameFrom(disposition: String?): String? { + if (disposition.isNullOrBlank()) return null + Regex("""filename\*=UTF-8''([^;]+)""", RegexOption.IGNORE_CASE).find(disposition)?.let { + return java.net.URLDecoder.decode(it.groupValues[1].trim('"'), "UTF-8") + } + return Regex("""filename="?([^";]+)"?""", RegexOption.IGNORE_CASE).find(disposition)?.groupValues?.get(1)?.trim() + } + + /** Only the last path segment, with characters a file system or a share target dislikes replaced. */ + fun safeName(raw: String): String { + val base = raw.substringAfterLast('/').substringAfterLast('\\') + val cleaned = base.replace(Regex("""[^A-Za-z0-9._-]"""), "_").trim('.', '_') + return cleaned.ifBlank { "export" }.take(120) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffPluginsRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffPluginsRepository.kt new file mode 100644 index 0000000..1e41076 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffPluginsRepository.kt @@ -0,0 +1,95 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.ArchiveLookups +import com.pinakes.app.data.model.AttachAuthorityRequest +import com.pinakes.app.data.model.AuthorityDetail +import com.pinakes.app.data.model.AuthorityRow +import com.pinakes.app.data.model.DeleteResult +import com.pinakes.app.data.model.ReceiveOfferRequest +import com.pinakes.app.data.model.StaffArchiveDetail +import com.pinakes.app.data.model.StaffArchiveRow +import com.pinakes.app.data.model.StaffOffer +import com.pinakes.app.data.model.StaffWantedBook +import com.pinakes.app.data.model.WantedReceiveResult +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.store.SessionStore +import kotlinx.serialization.json.JsonObject +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody + +/** Staff management of plugin content (Mobile API 1.7.0): Desiderata offers and Archives. */ +class StaffPluginsRepository( + private val network: NetworkModule, + private val session: SessionStore, +) { + private val api get() = network.staffPluginsApi() + + // ---------- desiderata ---------- + + suspend fun offers(status: String, query: String? = null, cursor: String? = null): ApiResult> = + session.staffCall { api.offers(status, query.blankToNull(), cursor, 30) }.toPage() + + suspend fun acceptOffer(id: Int): ApiResult = session.staffCall { api.acceptOffer(id) } + + suspend fun rejectOffer(id: Int): ApiResult = session.staffCall { api.rejectOffer(id) } + + suspend fun receiveOffer(id: Int, bookId: Int?): ApiResult = + session.staffCall { api.receiveOffer(id, ReceiveOfferRequest(bookId?.takeIf { it > 0 })) } + + suspend fun deleteOffer(id: Int): ApiResult = session.staffCall { api.deleteOffer(id) } + + suspend fun wanted(query: String? = null, cursor: String? = null): ApiResult> = + session.staffCall { api.wanted(query.blankToNull(), cursor, 30) }.toPage() + + suspend fun receiveWanted(bookId: Int): ApiResult = session.staffCall { api.receiveWanted(bookId) } + + // ---------- archives ---------- + + suspend fun archiveLookups(): ApiResult = session.staffCall { api.archiveLookups() } + + /** [published] is `all`, `1` or `0`. */ + suspend fun archives(query: String?, published: String, cursor: String?): ApiResult> = + session.staffCall { api.archives(q = query.blankToNull(), published = published, cursor = cursor, limit = 30) }.toPage() + + suspend fun archive(id: Int): ApiResult = session.staffCall { api.archive(id) } + + suspend fun saveArchive(id: Int?, body: JsonObject): ApiResult = + session.staffCall { if (id == null || id <= 0) api.createArchive(body) else api.updateArchive(id, body) } + + suspend fun deleteArchive(id: Int): ApiResult = session.staffCall { api.deleteArchive(id) } + + suspend fun uploadArchiveCover(id: Int, fileName: String, bytes: ByteArray, mime: String): ApiResult { + val part = MultipartBody.Part.createFormData("cover", fileName, bytes.toRequestBody(mime.toMediaTypeOrNull() ?: "image/jpeg".toMediaType())) + return session.staffCall { api.uploadArchiveCover(id, part) } + } + + suspend fun deleteArchiveCover(id: Int): ApiResult = session.staffCall { api.deleteArchiveCover(id) } + + suspend fun uploadArchiveDocument(id: Int, fileName: String, bytes: ByteArray, mime: String?): ApiResult { + val type = mime?.toMediaTypeOrNull() ?: "application/octet-stream".toMediaType() + val part = MultipartBody.Part.createFormData("document", fileName, bytes.toRequestBody(type)) + return session.staffCall { api.uploadArchiveDocument(id, part) } + } + + suspend fun deleteArchiveDocument(id: Int, fileId: Int): ApiResult = + session.staffCall { api.deleteArchiveDocument(id, fileId) } + + suspend fun attachAuthority(id: Int, authorityId: Int, role: String): ApiResult = + session.staffCall { api.attachAuthority(id, AttachAuthorityRequest(authorityId, role)) } + + suspend fun detachAuthority(id: Int, authorityId: Int): ApiResult = + session.staffCall { api.detachAuthority(id, authorityId) } + + suspend fun authorities(query: String?, type: String? = null, cursor: String? = null): ApiResult> = + session.staffCall { api.authorities(query.blankToNull(), type.blankToNull(), cursor, 30) }.toPage() + + suspend fun authority(id: Int): ApiResult = session.staffCall { api.authority(id) } + + suspend fun saveAuthority(id: Int?, body: JsonObject): ApiResult = + session.staffCall { if (id == null || id <= 0) api.createAuthority(body) else api.updateAuthority(id, body) } + + suspend fun deleteAuthority(id: Int): ApiResult = session.staffCall { api.deleteAuthority(id) } +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt index b7b5873..5e784fc 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffRepository.kt @@ -26,7 +26,6 @@ import com.pinakes.app.data.model.StaffPublisherWrite import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.network.NetworkModule -import com.pinakes.app.data.network.apiCall import com.pinakes.app.data.store.SessionStore import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject @@ -51,11 +50,8 @@ class StaffRepository( ) { private val api get() = network.staffApi() - private suspend fun staffCall(block: suspend () -> com.pinakes.app.data.model.Envelope): ApiResult { - val res = apiCall(block) - if (res is ApiResult.Failure && res.isForbidden()) session.saveRole(null) - return res - } + private suspend fun staffCall(block: suspend () -> com.pinakes.app.data.model.Envelope): ApiResult = + session.staffCall(block) suspend fun lookups(): ApiResult = staffCall { api.lookups() } @@ -90,6 +86,15 @@ class StaffRepository( suspend fun addCopies(id: Int, quantity: Int): ApiResult = staffCall { api.addCopies(id, AddCopiesRequest(quantity = quantity.coerceIn(1, 100))) } + /** + * One copy with an explicit inventory code (Mobile API 1.7.0), or the next generated one + * when [code] is blank. A taken code answers 409 `duplicate` with `meta.existing`. + */ + suspend fun addCopy(id: Int, code: String?, status: String?, notes: String?): ApiResult = + staffCall { + api.addCopies(id, AddCopiesRequest(quantity = 1, status = status.blankToNull(), notes = notes.blankToNull(), inventoryNumber = code.blankToNull())) + } + /** Multipart cover upload (`cover` field, JPEG or PNG, at most 2 MB server-side). */ suspend fun uploadCover(id: Int, bytes: ByteArray, mime: String): ApiResult { val type = if (mime == "image/png") "image/png" else "image/jpeg" diff --git a/app/src/main/java/com/pinakes/app/data/staff/CopyCodeCodec.kt b/app/src/main/java/com/pinakes/app/data/staff/CopyCodeCodec.kt new file mode 100644 index 0000000..edba9a6 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/CopyCodeCodec.kt @@ -0,0 +1,27 @@ +package com.pinakes.app.data.staff + +/** + * Copy inventory codes (the CODE 128 / CODE 39 labels the web prints, or a typed code). + * The server accepts at most 100 characters and strips control characters; the app does the + * same before sending, so what the operator sees is what the server stores. Pure. + */ +object CopyCodeCodec { + const val MAX_LENGTH = 100 + + /** Trimmed, control characters removed, at most [MAX_LENGTH] characters. */ + fun normalize(raw: String): String = + raw.filterNot { it.isISOControl() || it == '
' || it == '
' }.trim().take(MAX_LENGTH) + + /** A usable code: something left after [normalize]. */ + fun isValid(raw: String): Boolean = normalize(raw).isNotEmpty() + + /** + * Whether a scanned value looks like a book's own ISBN/EAN rather than a copy label: the + * copy scanner still accepts it (some libraries use the EAN as inventory code), but the + * sheet says so when the server finds no copy. + */ + fun looksLikeBookIdentifier(raw: String): Boolean { + val digits = normalize(raw) + return digits.length == 13 && digits.all { it.isDigit() } && (digits.startsWith("978") || digits.startsWith("979")) + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/LoanActions.kt b/app/src/main/java/com/pinakes/app/data/staff/LoanActions.kt new file mode 100644 index 0000000..169846a --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/LoanActions.kt @@ -0,0 +1,83 @@ +package com.pinakes.app.data.staff + +import com.pinakes.app.data.model.StaffLoan + +/** The desk actions a loan can offer (`actions` of `StaffLoan`, Mobile API 1.7.0). */ +enum class LoanAction(val wire: String) { + APPROVE("approve"), + REJECT("reject"), + CONFIRM_PICKUP("confirm_pickup"), + CANCEL_PICKUP("cancel_pickup"), + RETURN("return"), + RENEW("renew"), + EDIT("edit"), + RECALL("recall"), + RECEIPT("receipt"), + EMAIL_RECEIPT("email_receipt"); + + companion object { + fun fromWire(value: String): LoanAction? = entries.firstOrNull { it.wire == value.trim().lowercase() } + } +} + +/** What the screen opens for an action. */ +enum class LoanActionUi { IMMEDIATE, CONFIRM, REASON, RETURN_SHEET, EDIT_SHEET, DOWNLOAD } + +/** The return outcomes of `POST /staff/loans/{id}/return` (the copy's next state). */ +enum class ReturnOutcome(val wire: String, val allowsPenalty: Boolean) { + RETURNED("restituito", false), + MAINTENANCE("manutenzione", false), + RESTORATION("in_restauro", false), + LOST("perso", true), + DAMAGED("danneggiato", true); + + companion object { + fun fromWire(value: String?): ReturnOutcome = entries.firstOrNull { it.wire == value } ?: RETURNED + } +} + +/** Penalty input → value to send, or an error. */ +sealed interface PenaltyInput { + data object None : PenaltyInput + data class Amount(val value: Double) : PenaltyInput + data object Invalid : PenaltyInput +} + +/** + * The pure rules behind the loan detail actions. The server stays the authority (it re-checks + * every action); this only decides what to show and how to open it. + */ +object LoanActions { + /** Largest penalty the server stores (DECIMAL(10,2)). */ + const val MAX_PENALTY = 99_999_999.99 + + /** The known actions of [loan], in the server's order; unknown values are ignored. */ + fun of(loan: StaffLoan): List = loan.actions.mapNotNull(LoanAction::fromWire).distinct() + + /** How each action is presented. */ + fun ui(action: LoanAction): LoanActionUi = when (action) { + LoanAction.APPROVE, LoanAction.CONFIRM_PICKUP, LoanAction.RENEW -> LoanActionUi.CONFIRM + LoanAction.REJECT, LoanAction.CANCEL_PICKUP -> LoanActionUi.REASON + LoanAction.RETURN -> LoanActionUi.RETURN_SHEET + LoanAction.EDIT -> LoanActionUi.EDIT_SHEET + LoanAction.RECEIPT -> LoanActionUi.DOWNLOAD + LoanAction.RECALL, LoanAction.EMAIL_RECEIPT -> LoanActionUi.IMMEDIATE + } + + /** Only loans that are out (on time or overdue) can be extended or recalled together. */ + fun isBulkSelectable(loan: StaffLoan): Boolean = loan.status == "in_corso" || loan.status == "in_ritardo" + + /** + * Parse the penalty field: blank → none; a number with `.` or `,` as decimal separator, at + * most two decimals, between 0 and [MAX_PENALTY]. Only lost/damaged outcomes carry one. + */ + fun parsePenalty(raw: String, outcome: ReturnOutcome): PenaltyInput { + val text = raw.trim() + if (text.isEmpty()) return PenaltyInput.None + if (!outcome.allowsPenalty) return PenaltyInput.Invalid + if (!Regex("""^\d{1,8}([.,]\d{1,2})?$""").matches(text)) return PenaltyInput.Invalid + val value = text.replace(',', '.').toDoubleOrNull() ?: return PenaltyInput.Invalid + if (value < 0 || value > MAX_PENALTY) return PenaltyInput.Invalid + return PenaltyInput.Amount(value) + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt new file mode 100644 index 0000000..b36fdac --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt @@ -0,0 +1,59 @@ +package com.pinakes.app.data.staff + +import com.pinakes.app.data.store.InstanceFeatures +import com.pinakes.app.ui.common.OfflinePolicy + +/** + * Which staff areas the app shows. Each one needs a staff role (`admin`/`staff`), a server + * with the staff area (`staff_catalog`) and its own `/health` flag. Circulation also needs a + * connection: offline its entries are hidden, never shown disabled. Pure. + */ +data class StaffAreas( + val staff: Boolean = false, + val circulation: Boolean = false, + val copies: Boolean = false, + val series: Boolean = false, + val merges: Boolean = false, + val labels: Boolean = false, + val imports: Boolean = false, + val covers: Boolean = false, + val desiderata: Boolean = false, + val archives: Boolean = false, + val periodicals: Boolean = false, + val bookClub: Boolean = false, + val works: Boolean = false, + val digital: Boolean = false, + val libraryThing: Boolean = false, +) { + /** At least one entry of the "Books" section beyond the Phase 1 ones. */ + val anyBooksTool: Boolean get() = series || merges || imports || covers || labels + + /** At least one plugin managed natively. */ + val anyPlugin: Boolean get() = desiderata || archives + + companion object { + val None = StaffAreas() + + fun of(role: String?, features: InstanceFeatures, online: Boolean): StaffAreas { + val staff = StaffAccess.canManageCatalog(role, features.staffCatalogAvailable) + if (!staff) return None + return StaffAreas( + staff = true, + circulation = OfflinePolicy.showStaffCirculation(features.staffCirculationAvailable, online), + copies = features.staffCopiesAvailable, + series = features.staffSeriesAvailable, + merges = features.staffMergesAvailable, + labels = features.staffLabelsAvailable, + imports = features.staffImportsAvailable, + covers = features.staffCoversAvailable, + desiderata = features.staffDesiderataAvailable, + archives = features.staffArchivesAvailable, + periodicals = features.staffPeriodicalsAvailable, + bookClub = features.staffBookClubAvailable, + works = features.staffFrbrAvailable, + digital = features.staffDigitalLibraryAvailable, + libraryThing = features.staffLibraryThingAvailable && features.staffImportsAvailable, + ) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt new file mode 100644 index 0000000..fae8e7d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt @@ -0,0 +1,47 @@ +package com.pinakes.app.data.staff + +import androidx.annotation.StringRes +import com.pinakes.app.R +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes + +/** + * The stable error codes of the staff 1.7.0 routes mapped to the app's own wording. Codes the + * table does not know (notably the polymorphic 409 `conflict`, whose meaning only the server + * message carries) return null: the caller shows the server message, else its fallback. Pure. + */ +object StaffErrors { + + @StringRes + fun messageRes(code: String): Int? = when (code) { + "not_eligible" -> R.string.staff_err_not_eligible + "not_available" -> R.string.staff_err_not_available + "max_loans_reached" -> R.string.staff_err_max_loans + "loan_closed" -> R.string.staff_err_loan_closed + "concurrent_retry" -> R.string.staff_err_concurrent_retry + "not_returnable" -> R.string.staff_err_not_returnable + "max_renewals" -> R.string.staff_err_max_renewals + "loan_overdue" -> R.string.staff_err_loan_overdue + "loan_not_active" -> R.string.staff_err_loan_not_active + "past_date" -> R.string.staff_err_past_date + "ambiguous" -> R.string.staff_err_ambiguous + "copy_on_loan" -> R.string.staff_err_copy_on_loan + "copy_held" -> R.string.staff_err_copy_held + "copy_in_circulation" -> R.string.staff_err_copy_in_circulation + "copy_has_history" -> R.string.staff_err_copy_has_history + "no_copies" -> R.string.staff_err_no_copies + "offer_closed" -> R.string.staff_err_offer_closed + "out_of_order" -> R.string.staff_err_out_of_order + "job_busy" -> R.string.staff_err_job_busy + "gone" -> R.string.staff_err_gone + "upload_rejected" -> R.string.staff_err_upload_rejected + "feature_disabled" -> R.string.staff_err_feature_disabled + ErrorCodes.RATE_LIMITED -> R.string.staff_err_rate_limited + ErrorCodes.NETWORK -> R.string.offline_generic + else -> null + } + + /** The resource for [failure], when its code is one of ours. */ + @StringRes + fun messageRes(failure: ApiResult.Failure): Int? = messageRes(failure.code) +} diff --git a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt index c9f462f..97bbc8d 100644 --- a/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt +++ b/app/src/main/java/com/pinakes/app/data/store/FeatureStore.kt @@ -54,6 +54,22 @@ data class InstanceFeatures( val catalogSyncAvailable: Boolean = false, /** Personal loans ICS feed (`calendar_feed`, false in catalogue mode). */ val calendarFeedAvailable: Boolean = false, + // Mobile API 1.7.0 staff areas (`staff_*` in `/health`); hidden until advertised. + // Circulation is also off in catalogue mode, whatever the server sends. + val staffCirculationAvailable: Boolean = false, + val staffCopiesAvailable: Boolean = false, + val staffSeriesAvailable: Boolean = false, + val staffMergesAvailable: Boolean = false, + val staffLabelsAvailable: Boolean = false, + val staffImportsAvailable: Boolean = false, + val staffCoversAvailable: Boolean = false, + val staffDesiderataAvailable: Boolean = false, + val staffArchivesAvailable: Boolean = false, + val staffPeriodicalsAvailable: Boolean = false, + val staffBookClubAvailable: Boolean = false, + val staffFrbrAvailable: Boolean = false, + val staffDigitalLibraryAvailable: Boolean = false, + val staffLibraryThingAvailable: Boolean = false, ) { /** Library tab (loans + reservations) is shown only when at least one of them is enabled. */ val showLibrary: Boolean get() = loans || reservations @@ -115,6 +131,20 @@ class FeatureStore(context: Context) { staffCatalogAvailable = f.staffCatalog && health.appAccessEnabled, catalogSyncAvailable = f.catalogSync && health.appAccessEnabled, calendarFeedAvailable = f.calendarFeed && health.appAccessEnabled && !health.catalogueMode, + staffCirculationAvailable = f.staffCirculation && health.appAccessEnabled && !health.catalogueMode, + staffCopiesAvailable = f.staffCopies && health.appAccessEnabled, + staffSeriesAvailable = f.staffSeries && health.appAccessEnabled, + staffMergesAvailable = f.staffMerges && health.appAccessEnabled, + staffLabelsAvailable = f.staffLabels && health.appAccessEnabled, + staffImportsAvailable = f.staffImports && health.appAccessEnabled, + staffCoversAvailable = f.staffCovers && health.appAccessEnabled, + staffDesiderataAvailable = f.staffDesiderata && health.appAccessEnabled, + staffArchivesAvailable = f.staffArchives && health.appAccessEnabled, + staffPeriodicalsAvailable = f.staffPeriodicals && health.appAccessEnabled, + staffBookClubAvailable = f.staffBookClub && health.appAccessEnabled, + staffFrbrAvailable = f.staffFrbr && health.appAccessEnabled, + staffDigitalLibraryAvailable = f.staffDigitalLibrary && health.appAccessEnabled, + staffLibraryThingAvailable = f.staffLibraryThing && health.appAccessEnabled, ) prefs.edit() .putBoolean(KEY_KNOWN, true) @@ -133,6 +163,20 @@ class FeatureStore(context: Context) { .putBoolean(KEY_STAFF_CATALOG, value.staffCatalogAvailable) .putBoolean(KEY_CATALOG_SYNC, value.catalogSyncAvailable) .putBoolean(KEY_CALENDAR_FEED, value.calendarFeedAvailable) + .putBoolean(KEY_STAFF_CIRCULATION, value.staffCirculationAvailable) + .putBoolean(KEY_STAFF_COPIES, value.staffCopiesAvailable) + .putBoolean(KEY_STAFF_SERIES, value.staffSeriesAvailable) + .putBoolean(KEY_STAFF_MERGES, value.staffMergesAvailable) + .putBoolean(KEY_STAFF_LABELS, value.staffLabelsAvailable) + .putBoolean(KEY_STAFF_IMPORTS, value.staffImportsAvailable) + .putBoolean(KEY_STAFF_COVERS, value.staffCoversAvailable) + .putBoolean(KEY_STAFF_DESIDERATA, value.staffDesiderataAvailable) + .putBoolean(KEY_STAFF_ARCHIVES, value.staffArchivesAvailable) + .putBoolean(KEY_STAFF_PERIODICALS, value.staffPeriodicalsAvailable) + .putBoolean(KEY_STAFF_BOOKCLUB, value.staffBookClubAvailable) + .putBoolean(KEY_STAFF_FRBR, value.staffFrbrAvailable) + .putBoolean(KEY_STAFF_DIGITAL, value.staffDigitalLibraryAvailable) + .putBoolean(KEY_STAFF_LIBRARYTHING, value.staffLibraryThingAvailable) .apply() _features.value = value } @@ -178,6 +222,20 @@ class FeatureStore(context: Context) { staffCatalogAvailable = prefs.getBoolean(KEY_STAFF_CATALOG, false), catalogSyncAvailable = prefs.getBoolean(KEY_CATALOG_SYNC, false), calendarFeedAvailable = prefs.getBoolean(KEY_CALENDAR_FEED, false), + staffCirculationAvailable = prefs.getBoolean(KEY_STAFF_CIRCULATION, false), + staffCopiesAvailable = prefs.getBoolean(KEY_STAFF_COPIES, false), + staffSeriesAvailable = prefs.getBoolean(KEY_STAFF_SERIES, false), + staffMergesAvailable = prefs.getBoolean(KEY_STAFF_MERGES, false), + staffLabelsAvailable = prefs.getBoolean(KEY_STAFF_LABELS, false), + staffImportsAvailable = prefs.getBoolean(KEY_STAFF_IMPORTS, false), + staffCoversAvailable = prefs.getBoolean(KEY_STAFF_COVERS, false), + staffDesiderataAvailable = prefs.getBoolean(KEY_STAFF_DESIDERATA, false), + staffArchivesAvailable = prefs.getBoolean(KEY_STAFF_ARCHIVES, false), + staffPeriodicalsAvailable = prefs.getBoolean(KEY_STAFF_PERIODICALS, false), + staffBookClubAvailable = prefs.getBoolean(KEY_STAFF_BOOKCLUB, false), + staffFrbrAvailable = prefs.getBoolean(KEY_STAFF_FRBR, false), + staffDigitalLibraryAvailable = prefs.getBoolean(KEY_STAFF_DIGITAL, false), + staffLibraryThingAvailable = prefs.getBoolean(KEY_STAFF_LIBRARYTHING, false), ) } @@ -199,5 +257,19 @@ class FeatureStore(context: Context) { private const val KEY_STAFF_CATALOG = "f_staff_catalog" private const val KEY_CATALOG_SYNC = "f_catalog_sync" private const val KEY_CALENDAR_FEED = "f_calendar_feed" + private const val KEY_STAFF_CIRCULATION = "f_staff_circulation" + private const val KEY_STAFF_COPIES = "f_staff_copies" + private const val KEY_STAFF_SERIES = "f_staff_series" + private const val KEY_STAFF_MERGES = "f_staff_merges" + private const val KEY_STAFF_LABELS = "f_staff_labels" + private const val KEY_STAFF_IMPORTS = "f_staff_imports" + private const val KEY_STAFF_COVERS = "f_staff_covers" + private const val KEY_STAFF_DESIDERATA = "f_staff_desiderata" + private const val KEY_STAFF_ARCHIVES = "f_staff_archives" + private const val KEY_STAFF_PERIODICALS = "f_staff_periodicals" + private const val KEY_STAFF_BOOKCLUB = "f_staff_bookclub" + private const val KEY_STAFF_FRBR = "f_staff_frbr" + private const val KEY_STAFF_DIGITAL = "f_staff_digital_library" + private const val KEY_STAFF_LIBRARYTHING = "f_staff_librarything" } } diff --git a/app/src/main/java/com/pinakes/app/di/AppModule.kt b/app/src/main/java/com/pinakes/app/di/AppModule.kt index be24f62..a375f43 100644 --- a/app/src/main/java/com/pinakes/app/di/AppModule.kt +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -19,6 +19,10 @@ import com.pinakes.app.data.repository.NotificationsRepository import com.pinakes.app.data.repository.PeriodicalsRepository import com.pinakes.app.data.repository.ProfileRepository import com.pinakes.app.data.repository.ReviewsRepository +import com.pinakes.app.data.repository.StaffCirculationRepository +import com.pinakes.app.data.repository.StaffContentRepository +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.StaffPluginsRepository import com.pinakes.app.data.repository.StaffRepository import com.pinakes.app.data.repository.WishlistRepository import com.pinakes.app.data.store.FeatureStore @@ -109,6 +113,23 @@ object AppModule { fun staffRepository(network: NetworkModule, session: SessionStore): StaffRepository = StaffRepository(network, session) + @Provides @Singleton + fun staffCirculationRepository(network: NetworkModule, session: SessionStore): StaffCirculationRepository = + StaffCirculationRepository(network, session) + + @Provides @Singleton + fun staffContentRepository(network: NetworkModule, session: SessionStore): StaffContentRepository = + StaffContentRepository(network, session) + + @Provides @Singleton + fun staffPluginsRepository(network: NetworkModule, session: SessionStore): StaffPluginsRepository = + StaffPluginsRepository(network, session) + + /** Staff PDFs/CSVs land in `cache/exports/`, the directory the FileProvider shares. */ + @Provides @Singleton + fun staffFilesRepository(@ApplicationContext context: Context, network: NetworkModule, session: SessionStore): StaffFilesRepository = + StaffFilesRepository(network, session, context.cacheDir) + @Provides @Singleton fun calendarRepository(network: NetworkModule): CalendarRepository = CalendarRepository(network) diff --git a/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt index 00582b0..53f2401 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/AppViewModel.kt @@ -2,7 +2,9 @@ package com.pinakes.app.ui.common import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.pinakes.app.data.connectivity.ConnectivityObserver import com.pinakes.app.data.staff.StaffAccess +import com.pinakes.app.data.staff.StaffAreas import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore import com.pinakes.app.data.store.ThemeStore @@ -28,6 +30,7 @@ class AppViewModel @Inject constructor( session: SessionStore, featureStore: FeatureStore, private val themeStore: ThemeStore, + connectivity: ConnectivityObserver, ) : ViewModel() { val authState = session.authState @@ -50,5 +53,17 @@ class AppViewModel @Inject constructor( StaffAccess.canManageCatalog(session.role.value, featureStore.features.value.staffCatalogAvailable), ) + /** + * The staff areas to show (Mobile API 1.7.0): role × `/health` flags × connectivity. The + * circulation desk disappears the moment the device goes offline and comes back with it. + */ + val staffAreas: StateFlow = combine(session.role, featureStore.features, connectivity.isOnline) { role, f, online -> + StaffAreas.of(role, f, online) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + StaffAreas.of(session.role.value, featureStore.features.value, connectivity.isOnline.value), + ) + fun setThemeMode(mode: ThemeMode) = themeStore.setMode(mode) } diff --git a/app/src/main/java/com/pinakes/app/ui/common/OfflinePolicy.kt b/app/src/main/java/com/pinakes/app/ui/common/OfflinePolicy.kt index 1c06060..a6ef0ad 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/OfflinePolicy.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/OfflinePolicy.kt @@ -14,6 +14,13 @@ object OfflinePolicy { */ fun showLoanActions(canBorrow: Boolean, offline: Boolean): Boolean = canBorrow && !offline + /** + * Whether the staff circulation desk (loans, returns, reservations, copy scanner) is shown: + * only when the server offers it AND the device is online. Offline the entries are hidden, + * not disabled: a desk action taken on stale data could hand over a copy that is out. + */ + fun showStaffCirculation(available: Boolean, online: Boolean): Boolean = available && online + /** Whether an availability pill may claim the current state (never for cached rows). */ fun showCurrentAvailability(offline: Boolean): Boolean = !offline } diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt index 1d67672..ee09d9e 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt @@ -76,6 +76,43 @@ object Routes { const val STAFF_GENRES = "staff/genres" const val STAFF_SHELVES = "staff/shelves" + // Staff area, phase 2 (Mobile API 1.7.0): circulation desk, book tools, plugin content + const val STAFF_CIRCULATION = "staff/circulation" + const val ARG_BUCKET = "bucket" + const val STAFF_LOANS = "staff/loans?bucket={bucket}" + fun staffLoans(bucket: String = "all"): String = "staff/loans?bucket=${Uri.encode(bucket)}" + const val ARG_LOAN_ID = "loanId" + const val STAFF_LOAN = "staff/loans/{loanId}" + fun staffLoan(id: Int): String = "staff/loans/$id" + const val ARG_USER_ID = "userId" + const val ARG_COPY_CODE = "copyCode" + const val ARG_RESERVATION_MODE = "reservation" + const val STAFF_LOAN_NEW = "staff/new-loan?userId={userId}&bookId={bookId}©Code={copyCode}&reservation={reservation}" + fun staffLoanNew(userId: Int = 0, bookId: Int = 0, copyCode: String = "", reservation: Boolean = false): String = + "staff/new-loan?userId=$userId&bookId=$bookId©Code=${Uri.encode(copyCode)}&reservation=$reservation" + const val STAFF_RESERVATIONS = "staff/reservations" + const val STAFF_USER = "staff/users/{userId}" + fun staffUser(id: Int): String = "staff/users/$id" + const val STAFF_COPY_SCAN = "staff/scan-copy" + const val ARG_SERIES_ID = "seriesId" + const val STAFF_SERIES = "staff/series" + const val STAFF_SERIES_DETAIL = "staff/series/{seriesId}" + fun staffSeriesDetail(id: Int): String = "staff/series/$id" + const val STAFF_MERGES = "staff/merges" + const val ARG_JOB_ID = "jobId" + const val STAFF_IMPORTS = "staff/imports" + const val STAFF_IMPORT = "staff/imports/{jobId}" + fun staffImport(id: String): String = "staff/imports/${Uri.encode(id)}" + const val STAFF_COVERS = "staff/covers" + const val STAFF_DESIDERATA = "staff/desiderata" + const val STAFF_ARCHIVES = "staff/archives" + const val STAFF_ARCHIVE_EDIT = "staff/archives/{archiveId}/edit" + fun staffArchiveEdit(id: Int = 0): String = "staff/archives/$id/edit" + const val ARG_AUTHORITY_ID = "authorityId" + const val STAFF_AUTHORITIES = "staff/authorities" + const val STAFF_AUTHORITY_EDIT = "staff/authorities/{authorityId}/edit" + fun staffAuthorityEdit(id: Int = 0): String = "staff/authorities/$id/edit" + /** Graph hosting the bottom-nav + nested authed screens. */ const val MAIN_GRAPH = "main" const val DESIDERATA = "desiderata" diff --git a/app/src/test/java/com/pinakes/app/staff/CopyCodeCodecTest.kt b/app/src/test/java/com/pinakes/app/staff/CopyCodeCodecTest.kt new file mode 100644 index 0000000..fab5e08 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/CopyCodeCodecTest.kt @@ -0,0 +1,42 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.staff.CopyCodeCodec +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Copy codes are sent the way the server stores them; export file names are safe to share. */ +class CopyCodeCodecTest { + + @Test fun trimsAndStripsControlCharacters() { + assertEquals("INV-0011", CopyCodeCodec.normalize(" INV-0011\n")) + assertEquals("AB12", CopyCodeCodec.normalize("A\u0000B\u001D1\t2")) + assertEquals("", CopyCodeCodec.normalize(" \r\n ")) + assertFalse(CopyCodeCodec.isValid("\u0007")) + assertTrue(CopyCodeCodec.isValid("x")) + } + + @Test fun capsAtOneHundredCharacters() { + assertEquals(100, CopyCodeCodec.normalize("x".repeat(150)).length) + } + + @Test fun recognisesBookIdentifiers() { + assertTrue(CopyCodeCodec.looksLikeBookIdentifier("9788804668237")) + assertTrue(CopyCodeCodec.looksLikeBookIdentifier("9791234567896")) + assertFalse(CopyCodeCodec.looksLikeBookIdentifier("INV-0011")) + assertFalse(CopyCodeCodec.looksLikeBookIdentifier("4006381333931")) + } + + @Test fun exportFileNames() { + assertEquals("prestito_7_20261014.pdf", StaffFilesRepository.filenameFrom("""attachment; filename="prestito_7_20261014.pdf"""")) + assertEquals("labels.pdf", StaffFilesRepository.filenameFrom("inline; filename=labels.pdf")) + assertEquals("errori è.csv", StaffFilesRepository.filenameFrom("attachment; filename*=UTF-8''errori%20%C3%A8.csv")) + assertNull(StaffFilesRepository.filenameFrom(null)) + assertEquals("passwd", StaffFilesRepository.safeName("../../etc/passwd")) + assertEquals("errori_.csv", StaffFilesRepository.safeName("errori è.csv").replace("__", "_")) + assertEquals("export", StaffFilesRepository.safeName("...")) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/LoanActionsTest.kt b/app/src/test/java/com/pinakes/app/staff/LoanActionsTest.kt new file mode 100644 index 0000000..32ffc67 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/LoanActionsTest.kt @@ -0,0 +1,53 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.StaffLoan +import com.pinakes.app.data.staff.LoanAction +import com.pinakes.app.data.staff.LoanActionUi +import com.pinakes.app.data.staff.LoanActions +import com.pinakes.app.data.staff.ReturnOutcome +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Which loan actions show, and how each opens. The server stays the authority. */ +class LoanActionsTest { + + @Test fun everyWireValueRoundTrips() { + LoanAction.entries.forEach { assertEquals(it, LoanAction.fromWire(it.wire)) } + assertEquals(LoanAction.CONFIRM_PICKUP, LoanAction.fromWire(" Confirm_Pickup ")) + assertNull(LoanAction.fromWire("teleport")) + } + + @Test fun unknownAndDuplicateActionsAreDropped() { + val loan = StaffLoan(actions = listOf("approve", "approve", "unknown", "reject")) + assertEquals(listOf(LoanAction.APPROVE, LoanAction.REJECT), LoanActions.of(loan)) + } + + @Test fun presentation() { + assertEquals(LoanActionUi.CONFIRM, LoanActions.ui(LoanAction.APPROVE)) + assertEquals(LoanActionUi.REASON, LoanActions.ui(LoanAction.REJECT)) + assertEquals(LoanActionUi.REASON, LoanActions.ui(LoanAction.CANCEL_PICKUP)) + assertEquals(LoanActionUi.RETURN_SHEET, LoanActions.ui(LoanAction.RETURN)) + assertEquals(LoanActionUi.EDIT_SHEET, LoanActions.ui(LoanAction.EDIT)) + assertEquals(LoanActionUi.DOWNLOAD, LoanActions.ui(LoanAction.RECEIPT)) + assertEquals(LoanActionUi.IMMEDIATE, LoanActions.ui(LoanAction.RECALL)) + assertEquals(LoanActionUi.IMMEDIATE, LoanActions.ui(LoanAction.EMAIL_RECEIPT)) + } + + @Test fun onlyOutstandingLoansAreBulkSelectable() { + assertTrue(LoanActions.isBulkSelectable(StaffLoan(status = "in_corso"))) + assertTrue(LoanActions.isBulkSelectable(StaffLoan(status = "in_ritardo"))) + listOf("pendente", "da_ritirare", "prenotato", "restituito", "perso").forEach { + assertFalse(it, LoanActions.isBulkSelectable(StaffLoan(status = it))) + } + } + + @Test fun returnOutcomes() { + assertEquals(ReturnOutcome.RETURNED, ReturnOutcome.fromWire(null)) + assertEquals(ReturnOutcome.DAMAGED, ReturnOutcome.fromWire("danneggiato")) + assertEquals(setOf(ReturnOutcome.LOST, ReturnOutcome.DAMAGED), ReturnOutcome.entries.filter { it.allowsPenalty }.toSet()) + assertEquals(listOf("restituito", "manutenzione", "in_restauro", "perso", "danneggiato"), ReturnOutcome.entries.map { it.wire }) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/PenaltyValidationTest.kt b/app/src/test/java/com/pinakes/app/staff/PenaltyValidationTest.kt new file mode 100644 index 0000000..e4852cd --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/PenaltyValidationTest.kt @@ -0,0 +1,37 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.staff.LoanActions +import com.pinakes.app.data.staff.PenaltyInput +import com.pinakes.app.data.staff.ReturnOutcome +import org.junit.Assert.assertEquals +import org.junit.Test + +/** The return sheet's penalty field: decimal comma or point, two decimals, 0..99999999.99, lost/damaged only. */ +class PenaltyValidationTest { + + private fun parse(raw: String, outcome: ReturnOutcome = ReturnOutcome.DAMAGED) = LoanActions.parsePenalty(raw, outcome) + + @Test fun blankIsNoPenalty() { + assertEquals(PenaltyInput.None, parse("")) + assertEquals(PenaltyInput.None, parse(" ", ReturnOutcome.RETURNED)) + } + + @Test fun acceptsPointAndComma() { + assertEquals(PenaltyInput.Amount(12.5), parse("12.5")) + assertEquals(PenaltyInput.Amount(12.5), parse("12,50")) + assertEquals(PenaltyInput.Amount(0.0), parse("0")) + assertEquals(PenaltyInput.Amount(99_999_999.99), parse("99999999.99", ReturnOutcome.LOST)) + } + + @Test fun rejectsMalformedOrOutOfRange() { + listOf("-1", "1.234", "12a", "1e3", "100000000", "1.2.3", ",5", "5,").forEach { + assertEquals(it, PenaltyInput.Invalid, parse(it)) + } + } + + @Test fun onlyLostOrDamagedCarryAPenalty() { + listOf(ReturnOutcome.RETURNED, ReturnOutcome.MAINTENANCE, ReturnOutcome.RESTORATION).forEach { + assertEquals(PenaltyInput.Invalid, parse("5", it)) + } + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/StaffAreasTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffAreasTest.kt new file mode 100644 index 0000000..6d59933 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffAreasTest.kt @@ -0,0 +1,55 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.staff.StaffAreas +import com.pinakes.app.data.store.InstanceFeatures +import com.pinakes.app.ui.common.OfflinePolicy +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Staff areas = role × `/health` flags × connectivity; circulation disappears offline. */ +class StaffAreasTest { + + private val all = InstanceFeatures( + staffCatalogAvailable = true, staffCirculationAvailable = true, staffCopiesAvailable = true, staffSeriesAvailable = true, + staffMergesAvailable = true, staffLabelsAvailable = true, staffImportsAvailable = true, staffCoversAvailable = true, + staffDesiderataAvailable = true, staffArchivesAvailable = true, staffLibraryThingAvailable = true, + ) + + @Test fun staffOnlineSeesEverythingAdvertised() { + val a = StaffAreas.of("staff", all, online = true) + assertTrue(a.staff && a.circulation && a.copies && a.series && a.merges && a.labels && a.imports && a.covers) + assertTrue(a.desiderata && a.archives && a.libraryThing && a.anyBooksTool && a.anyPlugin) + } + + @Test fun offlineHidesOnlyCirculation() { + val a = StaffAreas.of("admin", all, online = false) + assertFalse(a.circulation) + assertTrue(a.staff && a.series && a.imports && a.archives) + } + + @Test fun borrowersAndUnknownRolesSeeNothing() { + listOf(null, "", "standard", "premium").forEach { role -> + assertEquals(role.toString(), StaffAreas.None, StaffAreas.of(role, all, online = true)) + } + } + + @Test fun withoutTheStaffAreaNothingShows() { + assertEquals(StaffAreas.None, StaffAreas.of("admin", all.copy(staffCatalogAvailable = false), online = true)) + } + + @Test fun eachFlagGatesItsArea() { + val none = StaffAreas.of("admin", InstanceFeatures(staffCatalogAvailable = true), online = true) + assertTrue(none.staff) + assertFalse(none.circulation || none.series || none.imports || none.desiderata || none.archives || none.anyBooksTool || none.anyPlugin) + // LibraryThing is an import kind: no imports area, no LibraryThing either. + assertFalse(StaffAreas.of("admin", all.copy(staffImportsAvailable = false), true).libraryThing) + } + + @Test fun offlinePolicyForCirculation() { + assertTrue(OfflinePolicy.showStaffCirculation(available = true, online = true)) + assertFalse(OfflinePolicy.showStaffCirculation(available = true, online = false)) + assertFalse(OfflinePolicy.showStaffCirculation(available = false, online = true)) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/StaffCirculationModelsParsingTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffCirculationModelsParsingTest.kt new file mode 100644 index 0000000..a1f4f14 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffCirculationModelsParsingTest.kt @@ -0,0 +1,147 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.CirculationSummary +import com.pinakes.app.data.model.CopyLookup +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.HealthPayload +import com.pinakes.app.data.model.ImportJob +import com.pinakes.app.data.model.LoanCreateRequest +import com.pinakes.app.data.model.StaffArchiveDetail +import com.pinakes.app.data.model.StaffLoan +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.model.StaffSeries +import com.pinakes.app.data.model.StaffUserDetail +import com.pinakes.app.data.network.parseErrorBody +import com.pinakes.app.data.staff.LoanAction +import com.pinakes.app.data.staff.LoanActions +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The Mobile API 1.7.0 payloads decode tolerantly: defaults for absent keys, unknown values ignored. */ +class StaffCirculationModelsParsingTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun healthStaffFlagsDefaultOffAndParseWhenSent() { + val old = json.decodeFromString("""{"features":{"staff_catalog":true}}""") + assertFalse(old.features.staffCirculation || old.features.staffCopies || old.features.staffArchives || old.features.staffLibraryThing) + val new = json.decodeFromString( + """{"features":{"staff_circulation":true,"staff_copies":true,"staff_series":true,"staff_merges":true,"staff_labels":true, + "staff_imports":true,"staff_covers":true,"staff_desiderata":true,"staff_archives":true,"staff_periodicals":true, + "staff_bookclub":true,"staff_frbr":true,"staff_digital_library":true,"staff_librarything":true}}""" + ) + with(new.features) { + assertTrue(staffCirculation && staffCopies && staffSeries && staffMerges && staffLabels && staffImports && staffCovers) + assertTrue(staffDesiderata && staffArchives && staffPeriodicals && staffBookClub && staffFrbr && staffDigitalLibrary && staffLibraryThing) + } + } + + @Test fun staffLoanFullShape() { + val loan = json.decodeFromString( + """{"id":7,"status":"in_ritardo","status_label":"In ritardo","origin":"diretto", + "book":{"id":5,"title":"Orlando","cover_url":"/c.jpg","isbn13":"9780156701600"}, + "copy":{"id":11,"inventory_number":"INV-0011","status":"prestato"}, + "user":{"id":3,"name":"Ada Rossi","email":"ada@example.org","card_number":"C-12","status":"attivo"}, + "processed_by":{"id":1,"name":"Admin"},"loaned_at":"2026-09-01","due_at":"2026-10-01", + "days_overdue":13,"renewals":1,"max_renewals":2,"penalty":"4.50","recall_count":2, + "actions":["return","edit","recall","receipt","teleport"]}""" + ) + assertEquals(7, loan.id) + assertEquals("INV-0011", loan.copy?.inventoryNumber) + assertEquals("C-12", loan.user.cardNumber) + assertEquals(4.5, loan.penalty!!, 0.0001) + assertEquals(13, loan.daysOverdue) + assertEquals(listOf(LoanAction.RETURN, LoanAction.EDIT, LoanAction.RECALL, LoanAction.RECEIPT), LoanActions.of(loan)) + } + + @Test fun staffLoanMinimalShapeUsesDefaults() { + val loan = json.decodeFromString("""{"id":1,"status":"pendente"}""") + assertNull(loan.copy) + assertEquals("", loan.book.title) + assertEquals(0, loan.renewals) + assertTrue(loan.actions.isEmpty()) + assertFalse(loan.returnedLate) + } + + @Test fun summaryAndUserDetail() { + val s = json.decodeFromString>( + """{"data":{"pending":2,"pickup_ready":1,"scheduled":0,"active":5,"overdue":3,"reservations":4,"due_today":1, + "returned_today":2,"today":"2026-10-14","settings":{"loan_duration_days":30,"max_renewals":2}}}""" + ).data!! + assertEquals(3, s.overdue) + assertEquals(30, s.settings?.loanDurationDays) + val u = json.decodeFromString( + """{"id":3,"name":"Ada","status":"sospeso","eligible":false,"eligibility_reason":"user_suspended", + "loans":[{"id":7,"status":"in_corso"}],"reservations":[{"id":2,"status":"attiva","queue_position":1}]}""" + ) + assertFalse(u.eligible) + assertEquals(1, u.loans.size) + assertEquals(1, u.reservations.first().queuePosition) + } + + @Test fun copyLookupFoundAndNotFound() { + val found = json.decodeFromString( + """{"found":true,"copy":{"id":11,"inventory_number":"INV-0011","status":"prestato","held":false,"deletable":false, + "book":{"id":5,"title":"Orlando"},"available":false,"loan":{"id":7,"status":"in_corso","user":{"id":3,"name":"Ada"}}}}""" + ) + assertTrue(found.found) + assertEquals(7, found.copy?.loan?.id) + assertEquals(false, found.copy?.available) + val missing = json.decodeFromString("""{"found":false,"copy":null}""") + assertFalse(missing.found) + assertNull(missing.copy) + } + + @Test fun createRequestAlwaysCarriesImmediate() { + val body = json.encodeToString(LoanCreateRequest.serializer(), LoanCreateRequest(userId = 3, bookId = 5, immediate = false)) + assertTrue(body, body.contains("\"immediate\":false")) + assertFalse(body, body.contains("copy_code")) + } + + @Test fun errorMetaCarriesFieldProcessedAndRetry() { + val f = parseErrorBody("""{"data":null,"meta":{"field":"due_at","processed":20,"retry_after":30},"error":{"code":"out_of_order","message":"x"}}""", 409) + assertEquals("out_of_order", f.code) + assertEquals("due_at", f.meta?.field) + assertEquals(20, f.meta?.processed) + assertEquals(30, f.meta?.retryAfter) + } + + @Test fun lookupsEditableCopyStates() { + val withFlag = json.decodeFromString( + """{"copy_states":[{"value":"disponibile","label":"Disponibile","editable":true},{"value":"prestato","label":"In prestito","editable":false}, + {"value":"perso","label":"Perso","editable":true}],"return_outcomes":[{"value":"restituito","label":"Restituito"}]}""" + ) + assertEquals(listOf("disponibile", "perso"), withFlag.editableCopyStateOptions.map { it.value }) + assertEquals("restituito", withFlag.returnOutcomeOptions.single().value) + val older = json.decodeFromString("""{"copy_states":["disponibile","prestato","prenotato","manutenzione"]}""") + assertEquals(listOf("disponibile", "manutenzione"), older.editableCopyStateOptions.map { it.value }) + } + + @Test fun contentAndPluginShapes() { + val series = json.decodeFromString( + """{"id":4,"name":"Discworld","type":"serie","book_count":41,"min_number":1,"max_number":"41","complete":true, + "children":[{"id":5,"name":"Rincewind","parent_id":4}]}""" + ) + assertEquals("1", series.minNumber) + assertEquals(5, series.children.single().id) + val job = json.decodeFromString( + """{"id":"6f1c","kind":"csv","file_name":"books.csv","total_rows":30,"processed":10,"errors":2, + "error_details":["Row 3: missing title",{"message":"Row 9: bad ISBN"}]}""" + ) + assertEquals(listOf("Row 3: missing title", "Row 9: bad ISBN"), job.errorLines) + assertFalse(job.complete) + val archive = json.decodeFromString( + """{"id":3,"title":"Fondo X","published":false,"fields":{"reference_code":"IT-1","date_start":1920,"extent":null}, + "files":[{"id":2,"url":"/a.pdf","mime":"application/pdf"}],"authorities":[{"id":9,"name":"Rossi","role":"creator"}]}""" + ) + assertEquals("IT-1", archive.field("reference_code")) + assertEquals("1920", archive.field("date_start")) + assertEquals("", archive.field("extent")) + assertEquals("", archive.field("missing")) + assertFalse(archive.published) + } +} diff --git a/i18n/de.json b/i18n/de.json index fcb418d..8ac593c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -828,5 +828,200 @@ "staff_record_life": "Lebensdaten", "staff_record_created": "Erstellt", "staff_record_updated": "Aktualisiert", - "staff_record_no_books": "Noch keine Bücher im Katalog" + "staff_record_no_books": "Noch keine Bücher im Katalog", + "staff_section_circulation": "Ausleihe", + "staff_section_books": "Werkzeuge für Bücher", + "staff_section_plugins": "Plugin-Inhalte", + "staff_circulation_desk": "Ausleihtheke", + "staff_circulation_desk_subtitle": "Anfragen, Abholungen, Ausleihen und Überfälliges", + "staff_scan_copy": "Exemplar scannen", + "staff_scan_copy_subtitle": "Ein Exemplar über sein Etikett zurücknehmen, ausleihen oder finden", + "staff_loan_new": "Neue Ausleihe", + "staff_reservations": "Vormerkungen", + "staff_offline_circulation_hidden": "Die Ausleihtheke braucht eine Verbindung: Sie erscheint wieder, sobald du online bist.", + "staff_series": "Reihen", + "staff_merges": "Dubletten zusammenführen", + "staff_merges_subtitle": "Autoren, Verlage und Genres", + "staff_imports": "Datei importieren", + "staff_imports_subtitle": "CSV oder LibraryThing, mit fortsetzbarem Fortschritt", + "staff_covers": "Cover und Anreicherung", + "staff_covers_subtitle": "Fehlende Cover finden, fehlende Daten ergänzen", + "staff_labels_settings": "Etiketten-Einstellungen", + "staff_labels_settings_subtitle": "Größe und Inhalt der gedruckten Etiketten", + "staff_desiderata": "Spendenangebote", + "staff_desiderata_subtitle": "Angebotene Bücher annehmen, ablehnen und erfassen", + "staff_archives": "Archive", + "staff_archives_subtitle": "Archiveinheiten, Anhänge und Veröffentlichung", + "staff_authorities": "Normdatensätze", + "staff_circulation_actions": "Schnellaktionen", + "staff_circulation_today": "Heute fällig: %1$d · Heute zurückgegeben: %2$d", + "staff_loans_title": "Ausleihen", + "staff_loans_pending": "Anfragen", + "staff_loans_pickup": "Abholbereit", + "staff_loans_scheduled": "Geplant", + "staff_loans_active": "Ausgeliehen", + "staff_loans_overdue": "Überfällig", + "staff_loans_history": "Verlauf", + "staff_loans_all": "Alle Ausleihen", + "staff_loans_search": "Titel, Nutzer, Ausweis oder Exemplarcode", + "staff_loans_empty": "Keine Ausleihen hier", + "staff_select_many": "Mehrere auswählen", + "staff_select_done": "Auswahl beenden", + "staff_selected": "Ausgewählt", + "staff_not_selected": "Nicht ausgewählt", + "staff_selected_count": "Ausgewählt: %1$d", + "staff_bulk_extend": "Verlängern", + "staff_bulk_extend_body": "Das Fälligkeitsdatum von %1$d Ausleihen verschieben um:", + "staff_bulk_extend_days": "Tage", + "staff_bulk_extend_days_invalid": "Zwischen 1 und 365 Tagen", + "staff_bulk_extended": "%1$d Ausleihen um %2$d Tage verlängert", + "staff_bulk_recalled": "Mahnungen gesendet: %1$d, übersprungen: %2$d", + "staff_loan_title": "Ausleihe", + "staff_loan_borrower": "Nutzer", + "staff_loan_details": "Details der Ausleihe", + "staff_user_card": "Ausweis", + "staff_user_open": "Nutzer öffnen", + "staff_open_book": "Buch öffnen", + "staff_copy_inventory": "Exemplar", + "staff_loan_requested": "Angefragt am", + "staff_loan_loaned": "Ausleihbeginn", + "staff_loan_due": "Fällig am", + "staff_loan_pickup_deadline": "Abholen bis", + "staff_loan_returned_at": "Zurückgegeben am", + "staff_loan_renewals": "Verlängerungen", + "staff_loan_renewals_value": "%1$d von %2$d", + "staff_loan_recalls": "Mahnungen", + "staff_loan_processed_by": "Bearbeitet von", + "staff_loan_days_overdue": "Seit %1$d Tagen überfällig", + "staff_loan_pickup_by": "Abholen bis %1$s", + "staff_loan_starts_on": "Beginnt am %1$s", + "staff_penalty": "Gebühr", + "staff_penalty_invalid": "Ein Betrag wie 12,50 (nur bei verlorenen oder beschädigten Exemplaren)", + "staff_loan_action_approve": "Genehmigen", + "staff_loan_action_reject": "Ablehnen", + "staff_loan_action_confirm_pickup": "Abholung bestätigen", + "staff_loan_action_cancel_pickup": "Abholung stornieren", + "staff_loan_action_return": "Rücknahme", + "staff_loan_action_renew": "Verlängern", + "staff_loan_action_edit": "Ausleihe bearbeiten", + "staff_loan_action_recall": "Mahnung senden", + "staff_loan_action_receipt": "Beleg als PDF", + "staff_loan_action_email_receipt": "Beleg per E-Mail senden", + "staff_loan_approve_body": "Diese Anfrage genehmigen? Der Nutzer wird benachrichtigt.", + "staff_loan_pickup_body": "Der Nutzer ist an der Theke und nimmt das Exemplar jetzt mit?", + "staff_loan_renew_body": "Diese Ausleihe um eine Leihfrist verlängern?", + "staff_loan_reject_body": "Der Nutzer wird benachrichtigt, mit dem Grund, falls du einen angibst.", + "staff_loan_cancel_pickup_body": "Das Exemplar wird für den nächsten Nutzer wieder verfügbar.", + "staff_reason_optional": "Grund (optional)", + "staff_loan_approved": "Anfrage genehmigt", + "staff_loan_rejected": "Anfrage abgelehnt", + "staff_loan_picked_up": "Abholung bestätigt: die Ausleihe läuft", + "staff_loan_pickup_cancelled": "Abholung storniert", + "staff_loan_renewed": "Ausleihe verlängert", + "staff_loan_returned": "Rücknahme erfasst", + "staff_loan_updated": "Ausleihe aktualisiert", + "staff_recall_sent": "Mahnung gesendet", + "staff_receipt_emailed": "Beleg per E-Mail gesendet", + "staff_mail_not_sent": "Die E-Mail konnte nicht gesendet werden. Prüfe die Mail-Einstellungen der Bibliothek.", + "staff_receipt_ready": "Ausleihbeleg", + "staff_download_failed": "Die Datei konnte nicht heruntergeladen werden.", + "staff_open_file": "Öffnen", + "staff_share_file": "Teilen", + "staff_return_outcome": "Das Exemplar geht zurück", + "staff_return_outcome_returned": "Ins Regal", + "staff_return_outcome_maintenance": "In die Wartung", + "staff_return_outcome_restoration": "In die Restaurierung", + "staff_return_outcome_lost": "Verloren", + "staff_return_outcome_damaged": "Beschädigt", + "staff_queue_position": "Warteschlange Nr. %1$d", + "staff_reservation_expires": "Läuft ab am %1$s", + "staff_pick_user": "Nutzer", + "staff_search_users": "Name, E-Mail, Ausweis oder Telefon", + "staff_search_min_chars": "Mindestens 2 Zeichen eingeben.", + "staff_picker_confirm": "Hinzufügen (%1$d)", + "staff_date_pick": "Datum wählen", + "staff_err_not_eligible": "Dieser Nutzer kann gerade nicht ausleihen (gesperrt, Ausweis abgelaufen oder nicht berechtigt).", + "staff_err_not_available": "Für diese Daten ist kein Exemplar verfügbar.", + "staff_err_max_loans": "Dieser Nutzer hat bereits die Höchstzahl an Ausleihen.", + "staff_err_loan_closed": "Diese Ausleihe ist abgeschlossen und kann nicht mehr geändert werden.", + "staff_err_concurrent_retry": "Jemand anderes hat diese Ausleihe gleichzeitig geändert. Bitte erneut versuchen.", + "staff_err_not_returnable": "Diese Ausleihe kann in ihrem aktuellen Zustand nicht zurückgenommen werden.", + "staff_err_max_renewals": "Die Ausleihe wurde bereits so oft wie möglich verlängert.", + "staff_err_loan_overdue": "Eine überfällige Ausleihe kann nicht verlängert werden: zuerst die Rücknahme erfassen.", + "staff_err_loan_not_active": "Nur eine laufende Ausleihe kann verlängert werden.", + "staff_err_past_date": "Die Daten liegen in der Vergangenheit.", + "staff_err_ambiguous": "Mehrere offene Ausleihen passen zu diesem Code: die Ausleihe stattdessen aus der Liste öffnen.", + "staff_err_copy_on_loan": "Dieses Exemplar ist ausgeliehen: zuerst die Rücknahme erfassen.", + "staff_err_copy_held": "Dieses Exemplar ist für einen Nutzer reserviert.", + "staff_err_copy_in_circulation": "Dieses Exemplar ist im Umlauf und kann nicht gelöscht werden.", + "staff_err_copy_has_history": "Dieses Exemplar hat einen Ausleihverlauf: als verloren oder ausgesondert markieren statt löschen.", + "staff_err_no_copies": "Dieses Buch hat keine Exemplare, für die Etiketten gedruckt werden können.", + "staff_err_offer_closed": "Dieses Angebot wurde bereits bearbeitet.", + "staff_err_out_of_order": "Der Import wurde anderswo fortgesetzt: es geht dort weiter, wo der Server steht.", + "staff_err_job_busy": "Dieser Import läuft auf einem anderen Gerät. Gleich noch einmal versuchen.", + "staff_err_gone": "Dieser Import ist nicht mehr verfügbar (fertig, abgebrochen oder abgelaufen).", + "staff_err_upload_rejected": "Der Server hat die Datei abgelehnt (Typ oder Größe).", + "staff_err_feature_disabled": "Dieser Bereich ist auf dem Bibliotheksserver ausgeschaltet.", + "staff_err_rate_limited": "Zu viele Anfragen: eine Minute warten und erneut versuchen.", + "staff_labels_hint": "Maße in Millimetern. Sie gelten für alle Etiketten der Bibliothek, auch im Web.", + "staff_labels_width": "Breite (mm)", + "staff_labels_height": "Höhe (mm)", + "staff_labels_padding": "Innenrand (mm)", + "staff_labels_range": "Zwischen 10 und 100 mm (Rand: 0 bis 20)", + "staff_labels_show_app_name": "Name der Bibliothek", + "staff_labels_show_title": "Titel", + "staff_labels_show_subtitle": "Untertitel", + "staff_labels_show_author_publisher": "Autor und Verlag", + "staff_labels_show_dewey": "Dewey-Klassifikation", + "staff_copy_code": "Exemplarcode", + "staff_copy_code_hint": "Optional: genau dieses Exemplar ausleihen. Leer: der Server wählt ein freies.", + "staff_copy_code_required": "Einen Exemplarcode eingeben", + "staff_copy_code_value": "Exemplar %1$s", + "staff_copy_not_found": "Kein Exemplar mit diesem Code", + "staff_copy_not_found_body": "Kein Exemplar im Katalog trägt den Code %1$s.", + "staff_copy_not_found_isbn": "%1$s sieht wie die ISBN eines Buches aus, nicht wie ein Exemplaretikett. Das Inventaretikett des Exemplars scannen.", + "staff_copy_returned": "Rücknahme erfasst: %1$s", + "staff_copy_returned_count": "Zurück: %1$d", + "staff_scan_copy_hint": "Das Inventaretikett des Exemplars in den Rahmen halten oder den Code unten eingeben.", + "staff_scan_copy_permission": "Die Kamera liest die Exemplaretiketten. Du kannst den Code auch unten eingeben.", + "staff_scan_next": "Nächstes Exemplar scannen", + "staff_loan_new_with_copy": "Neue Ausleihe mit diesem Exemplar", + "staff_loan_open": "Ausleihe öffnen", + "staff_hand_over_now": "Jetzt aushändigen", + "staff_hand_over_now_hint": "Der Nutzer nimmt das Exemplar an der Theke mit: die Ausleihe läuft sofort.", + "staff_hand_over_future": "Eine später beginnende Ausleihe wird geplant und an diesem Tag ausgehändigt.", + "staff_loan_create": "Ausleihe anlegen", + "staff_loan_dates": "Daten", + "staff_loan_dates_hint": "Leerer Beginn: heute. Leeres Fälligkeitsdatum: die übliche Leihfrist der Bibliothek.", + "staff_loan_dates_order": "Das Enddatum liegt vor dem Anfangsdatum.", + "staff_loan_summary_start": "Beginn: %1$s", + "staff_pick_book": "Buch", + "staff_pick_book_placeholder": "Im Katalog suchen", + "staff_pick_user_placeholder": "Nutzer suchen", + "staff_user_not_eligible": "Kann gerade nicht ausleihen: %1$s", + "staff_user_open_loans": "Offene Ausleihen", + "staff_user_status": "Status", + "staff_user_status_active": "Aktiv", + "staff_user_status_suspended": "Gesperrt", + "staff_user_status_expired": "Ausweis abgelaufen", + "staff_user_card_expires": "Ausweis gültig bis", + "staff_reservation_new": "Neue Vormerkung", + "staff_reservation_create": "Vormerkung anlegen", + "staff_reservation_edit": "Daten bearbeiten", + "staff_reservation_cancel": "Vormerkung stornieren", + "staff_reservation_cancel_body": "Die Vormerkung von %1$s stornieren? Der Nächste in der Warteschlange rückt auf.", + "staff_reservation_reactivate": "Reaktivieren", + "staff_reservation_cancelled": "Vormerkung storniert", + "staff_reservation_reactivated": "Vormerkung reaktiviert", + "staff_reservation_updated": "Vormerkung aktualisiert", + "staff_reservation_from": "Gewünscht ab", + "staff_reservation_to": "Gewünscht bis", + "staff_reservation_expiry": "Vormerkung läuft ab", + "staff_reservation_dates_hint": "Daten leer lassen, um sich für das nächste freie Exemplar anzustellen.", + "staff_reservations_active": "Aktiv", + "staff_reservations_completed": "Erledigt", + "staff_reservations_cancelled": "Storniert", + "staff_reservations_all": "Alle", + "staff_reservations_empty": "Keine Vormerkungen", + "staff_reservations_search": "Titel oder Nutzer" } diff --git a/i18n/en.json b/i18n/en.json index 486a90e..704af59 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -828,5 +828,200 @@ "staff_record_life": "Life", "staff_record_created": "Created", "staff_record_updated": "Updated", - "staff_record_no_books": "No books in the catalogue yet" + "staff_record_no_books": "No books in the catalogue yet", + "staff_section_circulation": "Circulation", + "staff_section_books": "Book tools", + "staff_section_plugins": "Plugin content", + "staff_circulation_desk": "Circulation desk", + "staff_circulation_desk_subtitle": "Requests, pickups, loans and overdue items", + "staff_scan_copy": "Scan a copy", + "staff_scan_copy_subtitle": "Return, lend or look up a copy by its label", + "staff_loan_new": "New loan", + "staff_reservations": "Reservations", + "staff_offline_circulation_hidden": "The circulation desk needs a connection: it comes back as soon as you are online.", + "staff_series": "Series", + "staff_merges": "Merge duplicates", + "staff_merges_subtitle": "Authors, publishers and genres", + "staff_imports": "Import a file", + "staff_imports_subtitle": "CSV or LibraryThing, with resumable progress", + "staff_covers": "Covers and enrichment", + "staff_covers_subtitle": "Find missing covers, fill in missing data", + "staff_labels_settings": "Label settings", + "staff_labels_settings_subtitle": "Size and content of printed labels", + "staff_desiderata": "Donation offers", + "staff_desiderata_subtitle": "Accept, reject and receive offered books", + "staff_archives": "Archives", + "staff_archives_subtitle": "Archival units, attachments and publishing", + "staff_authorities": "Authority records", + "staff_circulation_actions": "Quick actions", + "staff_circulation_today": "Due today: %1$d · Returned today: %2$d", + "staff_loans_title": "Loans", + "staff_loans_pending": "Requests", + "staff_loans_pickup": "To pick up", + "staff_loans_scheduled": "Scheduled", + "staff_loans_active": "On loan", + "staff_loans_overdue": "Overdue", + "staff_loans_history": "History", + "staff_loans_all": "All loans", + "staff_loans_search": "Title, borrower, card or copy code", + "staff_loans_empty": "No loans here", + "staff_select_many": "Select several", + "staff_select_done": "Stop selecting", + "staff_selected": "Selected", + "staff_not_selected": "Not selected", + "staff_selected_count": "Selected: %1$d", + "staff_bulk_extend": "Extend", + "staff_bulk_extend_body": "Move the due date of %1$d loans forward by:", + "staff_bulk_extend_days": "Days", + "staff_bulk_extend_days_invalid": "Between 1 and 365 days", + "staff_bulk_extended": "%1$d loans extended by %2$d days", + "staff_bulk_recalled": "Reminders sent: %1$d, skipped: %2$d", + "staff_loan_title": "Loan", + "staff_loan_borrower": "Borrower", + "staff_loan_details": "Loan details", + "staff_user_card": "Card", + "staff_user_open": "Open the user", + "staff_open_book": "Open the book", + "staff_copy_inventory": "Copy", + "staff_loan_requested": "Requested", + "staff_loan_loaned": "Loan start", + "staff_loan_due": "Due", + "staff_loan_pickup_deadline": "Pick up by", + "staff_loan_returned_at": "Returned", + "staff_loan_renewals": "Renewals", + "staff_loan_renewals_value": "%1$d of %2$d", + "staff_loan_recalls": "Reminders", + "staff_loan_processed_by": "Handled by", + "staff_loan_days_overdue": "Overdue by %1$d days", + "staff_loan_pickup_by": "Pick up by %1$s", + "staff_loan_starts_on": "Starts on %1$s", + "staff_penalty": "Penalty", + "staff_penalty_invalid": "An amount such as 12.50 (lost or damaged copies only)", + "staff_loan_action_approve": "Approve", + "staff_loan_action_reject": "Reject", + "staff_loan_action_confirm_pickup": "Confirm pickup", + "staff_loan_action_cancel_pickup": "Cancel pickup", + "staff_loan_action_return": "Return", + "staff_loan_action_renew": "Renew", + "staff_loan_action_edit": "Edit loan", + "staff_loan_action_recall": "Send a reminder", + "staff_loan_action_receipt": "Receipt PDF", + "staff_loan_action_email_receipt": "Email the receipt", + "staff_loan_approve_body": "Approve this request? The borrower is notified.", + "staff_loan_pickup_body": "The borrower is at the desk and takes the copy now?", + "staff_loan_renew_body": "Extend this loan by one loan period?", + "staff_loan_reject_body": "The borrower is notified, with the reason if you give one.", + "staff_loan_cancel_pickup_body": "The copy becomes available again for the next borrower.", + "staff_reason_optional": "Reason (optional)", + "staff_loan_approved": "Request approved", + "staff_loan_rejected": "Request rejected", + "staff_loan_picked_up": "Pickup confirmed: the loan is running", + "staff_loan_pickup_cancelled": "Pickup cancelled", + "staff_loan_renewed": "Loan renewed", + "staff_loan_returned": "Return recorded", + "staff_loan_updated": "Loan updated", + "staff_recall_sent": "Reminder sent", + "staff_receipt_emailed": "Receipt sent by email", + "staff_mail_not_sent": "The email could not be sent. Check the library’s mail settings.", + "staff_receipt_ready": "Loan receipt", + "staff_download_failed": "The file could not be downloaded.", + "staff_open_file": "Open", + "staff_share_file": "Share", + "staff_return_outcome": "The copy goes back", + "staff_return_outcome_returned": "On the shelf", + "staff_return_outcome_maintenance": "To maintenance", + "staff_return_outcome_restoration": "To restoration", + "staff_return_outcome_lost": "Lost", + "staff_return_outcome_damaged": "Damaged", + "staff_queue_position": "Queue #%1$d", + "staff_reservation_expires": "Expires %1$s", + "staff_pick_user": "Borrower", + "staff_search_users": "Name, email, card or phone", + "staff_search_min_chars": "Type at least 2 characters.", + "staff_picker_confirm": "Add (%1$d)", + "staff_date_pick": "Pick a date", + "staff_err_not_eligible": "This user cannot borrow right now (suspended, card expired or not allowed).", + "staff_err_not_available": "No copy is available for those dates.", + "staff_err_max_loans": "This user already has the maximum number of loans.", + "staff_err_loan_closed": "This loan is closed and can no longer be changed.", + "staff_err_concurrent_retry": "Someone else changed this loan at the same time. Try again.", + "staff_err_not_returnable": "This loan cannot be returned in its current state.", + "staff_err_max_renewals": "The loan has already been renewed the maximum number of times.", + "staff_err_loan_overdue": "An overdue loan cannot be renewed: record the return first.", + "staff_err_loan_not_active": "Only a running loan can be renewed.", + "staff_err_past_date": "The dates are in the past.", + "staff_err_ambiguous": "Several open loans match this code: open the loan from the list instead.", + "staff_err_copy_on_loan": "This copy is on loan: record the return first.", + "staff_err_copy_held": "This copy is held for a borrower.", + "staff_err_copy_in_circulation": "This copy is in circulation and cannot be deleted.", + "staff_err_copy_has_history": "This copy has loan history: set it to lost or withdrawn instead of deleting it.", + "staff_err_no_copies": "This book has no copies to print labels for.", + "staff_err_offer_closed": "This offer has already been handled.", + "staff_err_out_of_order": "The import moved on elsewhere: continuing from where the server is.", + "staff_err_job_busy": "This import is running on another device. Try again in a moment.", + "staff_err_gone": "This import is no longer available (finished, cancelled or expired).", + "staff_err_upload_rejected": "The server refused the file (type or size).", + "staff_err_feature_disabled": "This area is turned off on the library server.", + "staff_err_rate_limited": "Too many requests: wait a minute and try again.", + "staff_labels_hint": "Sizes in millimetres. They apply to every label the library prints, on the web too.", + "staff_labels_width": "Width (mm)", + "staff_labels_height": "Height (mm)", + "staff_labels_padding": "Inner margin (mm)", + "staff_labels_range": "Between 10 and 100 mm (margin: 0 to 20)", + "staff_labels_show_app_name": "Library name", + "staff_labels_show_title": "Title", + "staff_labels_show_subtitle": "Subtitle", + "staff_labels_show_author_publisher": "Author and publisher", + "staff_labels_show_dewey": "Dewey classification", + "staff_copy_code": "Copy code", + "staff_copy_code_hint": "Optional: lend this exact copy. Empty: the server picks a free one.", + "staff_copy_code_required": "Type a copy code", + "staff_copy_code_value": "Copy %1$s", + "staff_copy_not_found": "No copy with this code", + "staff_copy_not_found_body": "No copy in the catalogue carries the code %1$s.", + "staff_copy_not_found_isbn": "%1$s looks like a book ISBN, not a copy label. Scan the inventory label of the copy.", + "staff_copy_returned": "Return recorded: %1$s", + "staff_copy_returned_count": "Returned: %1$d", + "staff_scan_copy_hint": "Hold the copy’s inventory label inside the frame, or type its code below.", + "staff_scan_copy_permission": "The camera reads the copy labels. You can also type the code below.", + "staff_scan_next": "Scan the next copy", + "staff_loan_new_with_copy": "New loan with this copy", + "staff_loan_open": "Open the loan", + "staff_hand_over_now": "Hand over now", + "staff_hand_over_now_hint": "The borrower takes the copy at the desk: the loan starts running at once.", + "staff_hand_over_future": "A loan starting later is scheduled and handed over on that day.", + "staff_loan_create": "Create the loan", + "staff_loan_dates": "Dates", + "staff_loan_dates_hint": "Empty start: today. Empty due date: the library’s standard loan period.", + "staff_loan_dates_order": "The end date is before the start date.", + "staff_loan_summary_start": "Starts on %1$s", + "staff_pick_book": "Book", + "staff_pick_book_placeholder": "Search the catalogue", + "staff_pick_user_placeholder": "Search a borrower", + "staff_user_not_eligible": "Cannot borrow now: %1$s", + "staff_user_open_loans": "Open loans", + "staff_user_status": "Status", + "staff_user_status_active": "Active", + "staff_user_status_suspended": "Suspended", + "staff_user_status_expired": "Card expired", + "staff_user_card_expires": "Card valid until", + "staff_reservation_new": "New reservation", + "staff_reservation_create": "Create the reservation", + "staff_reservation_edit": "Edit dates", + "staff_reservation_cancel": "Cancel reservation", + "staff_reservation_cancel_body": "Cancel the reservation of %1$s? The next one in the queue moves up.", + "staff_reservation_reactivate": "Reactivate", + "staff_reservation_cancelled": "Reservation cancelled", + "staff_reservation_reactivated": "Reservation reactivated", + "staff_reservation_updated": "Reservation updated", + "staff_reservation_from": "Wanted from", + "staff_reservation_to": "Wanted until", + "staff_reservation_expiry": "Reservation expires", + "staff_reservation_dates_hint": "Leave the dates empty to queue for the next free copy.", + "staff_reservations_active": "Active", + "staff_reservations_completed": "Completed", + "staff_reservations_cancelled": "Cancelled", + "staff_reservations_all": "All", + "staff_reservations_empty": "No reservations", + "staff_reservations_search": "Title or borrower" } diff --git a/i18n/fr.json b/i18n/fr.json index 1998103..cd806b0 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -828,5 +828,200 @@ "staff_record_life": "Vie", "staff_record_created": "Créé", "staff_record_updated": "Mis à jour", - "staff_record_no_books": "Aucun livre au catalogue pour l’instant" + "staff_record_no_books": "Aucun livre au catalogue pour l’instant", + "staff_section_circulation": "Circulation", + "staff_section_books": "Outils pour les livres", + "staff_section_plugins": "Contenus des extensions", + "staff_circulation_desk": "Banque de prêt", + "staff_circulation_desk_subtitle": "Demandes, retraits, prêts et retards", + "staff_scan_copy": "Scanner un exemplaire", + "staff_scan_copy_subtitle": "Rendre, prêter ou retrouver un exemplaire par son étiquette", + "staff_loan_new": "Nouveau prêt", + "staff_reservations": "Réservations", + "staff_offline_circulation_hidden": "La banque de prêt a besoin d’une connexion : elle revient dès que vous êtes en ligne.", + "staff_series": "Collections", + "staff_merges": "Fusionner les doublons", + "staff_merges_subtitle": "Auteurs, éditeurs et genres", + "staff_imports": "Importer un fichier", + "staff_imports_subtitle": "CSV ou LibraryThing, avec une progression reprenable", + "staff_covers": "Couvertures et enrichissement", + "staff_covers_subtitle": "Trouver les couvertures manquantes, compléter les données", + "staff_labels_settings": "Réglages des étiquettes", + "staff_labels_settings_subtitle": "Taille et contenu des étiquettes imprimées", + "staff_desiderata": "Propositions de don", + "staff_desiderata_subtitle": "Accepter, refuser et recevoir les livres proposés", + "staff_archives": "Archives", + "staff_archives_subtitle": "Unités d’archives, pièces jointes et publication", + "staff_authorities": "Notices d’autorité", + "staff_circulation_actions": "Actions rapides", + "staff_circulation_today": "À rendre aujourd’hui : %1$d · Rendus aujourd’hui : %2$d", + "staff_loans_title": "Prêts", + "staff_loans_pending": "Demandes", + "staff_loans_pickup": "À retirer", + "staff_loans_scheduled": "Programmés", + "staff_loans_active": "En cours", + "staff_loans_overdue": "En retard", + "staff_loans_history": "Historique", + "staff_loans_all": "Tous les prêts", + "staff_loans_search": "Titre, lecteur, carte ou code d’exemplaire", + "staff_loans_empty": "Aucun prêt ici", + "staff_select_many": "Sélectionner plusieurs", + "staff_select_done": "Terminer la sélection", + "staff_selected": "Sélectionné", + "staff_not_selected": "Non sélectionné", + "staff_selected_count": "Sélectionnés : %1$d", + "staff_bulk_extend": "Prolonger", + "staff_bulk_extend_body": "Repousser l’échéance de %1$d prêts de :", + "staff_bulk_extend_days": "Jours", + "staff_bulk_extend_days_invalid": "Entre 1 et 365 jours", + "staff_bulk_extended": "%1$d prêts prolongés de %2$d jours", + "staff_bulk_recalled": "Relances envoyées : %1$d, ignorées : %2$d", + "staff_loan_title": "Prêt", + "staff_loan_borrower": "Lecteur", + "staff_loan_details": "Détails du prêt", + "staff_user_card": "Carte", + "staff_user_open": "Ouvrir le lecteur", + "staff_open_book": "Ouvrir le livre", + "staff_copy_inventory": "Exemplaire", + "staff_loan_requested": "Demandé le", + "staff_loan_loaned": "Début du prêt", + "staff_loan_due": "Échéance", + "staff_loan_pickup_deadline": "À retirer avant", + "staff_loan_returned_at": "Rendu le", + "staff_loan_renewals": "Renouvellements", + "staff_loan_renewals_value": "%1$d sur %2$d", + "staff_loan_recalls": "Relances", + "staff_loan_processed_by": "Traité par", + "staff_loan_days_overdue": "En retard de %1$d jours", + "staff_loan_pickup_by": "À retirer avant le %1$s", + "staff_loan_starts_on": "Commence le %1$s", + "staff_penalty": "Pénalité", + "staff_penalty_invalid": "Un montant comme 12,50 (exemplaires perdus ou abîmés seulement)", + "staff_loan_action_approve": "Approuver", + "staff_loan_action_reject": "Refuser", + "staff_loan_action_confirm_pickup": "Confirmer le retrait", + "staff_loan_action_cancel_pickup": "Annuler le retrait", + "staff_loan_action_return": "Retour", + "staff_loan_action_renew": "Renouveler", + "staff_loan_action_edit": "Modifier le prêt", + "staff_loan_action_recall": "Envoyer une relance", + "staff_loan_action_receipt": "Reçu PDF", + "staff_loan_action_email_receipt": "Envoyer le reçu par email", + "staff_loan_approve_body": "Approuver cette demande ? Le lecteur est prévenu.", + "staff_loan_pickup_body": "Le lecteur est au guichet et emporte l’exemplaire maintenant ?", + "staff_loan_renew_body": "Prolonger ce prêt d’une période ?", + "staff_loan_reject_body": "Le lecteur est prévenu, avec le motif si vous en donnez un.", + "staff_loan_cancel_pickup_body": "L’exemplaire redevient disponible pour le lecteur suivant.", + "staff_reason_optional": "Motif (facultatif)", + "staff_loan_approved": "Demande approuvée", + "staff_loan_rejected": "Demande refusée", + "staff_loan_picked_up": "Retrait confirmé : le prêt est en cours", + "staff_loan_pickup_cancelled": "Retrait annulé", + "staff_loan_renewed": "Prêt renouvelé", + "staff_loan_returned": "Retour enregistré", + "staff_loan_updated": "Prêt mis à jour", + "staff_recall_sent": "Relance envoyée", + "staff_receipt_emailed": "Reçu envoyé par email", + "staff_mail_not_sent": "L’email n’a pas pu être envoyé. Vérifiez les réglages de messagerie de la bibliothèque.", + "staff_receipt_ready": "Reçu du prêt", + "staff_download_failed": "Le fichier n’a pas pu être téléchargé.", + "staff_open_file": "Ouvrir", + "staff_share_file": "Partager", + "staff_return_outcome": "L’exemplaire revient", + "staff_return_outcome_returned": "En rayon", + "staff_return_outcome_maintenance": "En maintenance", + "staff_return_outcome_restoration": "En restauration", + "staff_return_outcome_lost": "Perdu", + "staff_return_outcome_damaged": "Abîmé", + "staff_queue_position": "File : n° %1$d", + "staff_reservation_expires": "Expire le %1$s", + "staff_pick_user": "Lecteur", + "staff_search_users": "Nom, email, carte ou téléphone", + "staff_search_min_chars": "Tapez au moins 2 caractères.", + "staff_picker_confirm": "Ajouter (%1$d)", + "staff_date_pick": "Choisir une date", + "staff_err_not_eligible": "Ce lecteur ne peut pas emprunter pour l’instant (suspendu, carte expirée ou non autorisé).", + "staff_err_not_available": "Aucun exemplaire n’est disponible à ces dates.", + "staff_err_max_loans": "Ce lecteur a déjà le nombre maximal de prêts.", + "staff_err_loan_closed": "Ce prêt est clos et ne peut plus être modifié.", + "staff_err_concurrent_retry": "Quelqu’un d’autre a modifié ce prêt au même moment. Réessayez.", + "staff_err_not_returnable": "Ce prêt ne peut pas être rendu dans son état actuel.", + "staff_err_max_renewals": "Le prêt a déjà été renouvelé le nombre maximal de fois.", + "staff_err_loan_overdue": "Un prêt en retard ne peut pas être renouvelé : enregistrez d’abord le retour.", + "staff_err_loan_not_active": "Seul un prêt en cours peut être renouvelé.", + "staff_err_past_date": "Les dates sont dans le passé.", + "staff_err_ambiguous": "Plusieurs prêts ouverts correspondent à ce code : ouvrez le prêt depuis la liste.", + "staff_err_copy_on_loan": "Cet exemplaire est prêté : enregistrez d’abord le retour.", + "staff_err_copy_held": "Cet exemplaire est réservé pour un lecteur.", + "staff_err_copy_in_circulation": "Cet exemplaire est en circulation et ne peut pas être supprimé.", + "staff_err_copy_has_history": "Cet exemplaire a un historique de prêts : marquez-le perdu ou retiré plutôt que de le supprimer.", + "staff_err_no_copies": "Ce livre n’a aucun exemplaire pour lequel imprimer des étiquettes.", + "staff_err_offer_closed": "Cette proposition a déjà été traitée.", + "staff_err_out_of_order": "L’import a avancé ailleurs : reprise là où en est le serveur.", + "staff_err_job_busy": "Cet import est en cours sur un autre appareil. Réessayez dans un instant.", + "staff_err_gone": "Cet import n’est plus disponible (terminé, annulé ou expiré).", + "staff_err_upload_rejected": "Le serveur a refusé le fichier (type ou taille).", + "staff_err_feature_disabled": "Cette section est désactivée sur le serveur de la bibliothèque.", + "staff_err_rate_limited": "Trop de requêtes : attendez une minute et réessayez.", + "staff_labels_hint": "Dimensions en millimètres. Elles valent pour toutes les étiquettes imprimées par la bibliothèque, sur le web aussi.", + "staff_labels_width": "Largeur (mm)", + "staff_labels_height": "Hauteur (mm)", + "staff_labels_padding": "Marge intérieure (mm)", + "staff_labels_range": "Entre 10 et 100 mm (marge : de 0 à 20)", + "staff_labels_show_app_name": "Nom de la bibliothèque", + "staff_labels_show_title": "Titre", + "staff_labels_show_subtitle": "Sous-titre", + "staff_labels_show_author_publisher": "Auteur et éditeur", + "staff_labels_show_dewey": "Classification Dewey", + "staff_copy_code": "Code d’exemplaire", + "staff_copy_code_hint": "Facultatif : prêter cet exemplaire précis. Vide : le serveur en choisit un libre.", + "staff_copy_code_required": "Saisissez un code d’exemplaire", + "staff_copy_code_value": "Exemplaire %1$s", + "staff_copy_not_found": "Aucun exemplaire avec ce code", + "staff_copy_not_found_body": "Aucun exemplaire du catalogue ne porte le code %1$s.", + "staff_copy_not_found_isbn": "%1$s ressemble à l’ISBN d’un livre, pas à l’étiquette d’un exemplaire. Scannez l’étiquette d’inventaire de l’exemplaire.", + "staff_copy_returned": "Retour enregistré : %1$s", + "staff_copy_returned_count": "Rendus : %1$d", + "staff_scan_copy_hint": "Placez l’étiquette d’inventaire de l’exemplaire dans le cadre, ou saisissez son code ci-dessous.", + "staff_scan_copy_permission": "L’appareil photo lit les étiquettes des exemplaires. Vous pouvez aussi saisir le code ci-dessous.", + "staff_scan_next": "Scanner l’exemplaire suivant", + "staff_loan_new_with_copy": "Nouveau prêt avec cet exemplaire", + "staff_loan_open": "Ouvrir le prêt", + "staff_hand_over_now": "Remettre maintenant", + "staff_hand_over_now_hint": "Le lecteur emporte l’exemplaire au guichet : le prêt démarre aussitôt.", + "staff_hand_over_future": "Un prêt qui commence plus tard est programmé et remis ce jour-là.", + "staff_loan_create": "Créer le prêt", + "staff_loan_dates": "Dates", + "staff_loan_dates_hint": "Début vide : aujourd’hui. Échéance vide : la durée de prêt standard de la bibliothèque.", + "staff_loan_dates_order": "La date de fin est antérieure à la date de début.", + "staff_loan_summary_start": "Début : %1$s", + "staff_pick_book": "Livre", + "staff_pick_book_placeholder": "Rechercher dans le catalogue", + "staff_pick_user_placeholder": "Rechercher un lecteur", + "staff_user_not_eligible": "Ne peut pas emprunter maintenant : %1$s", + "staff_user_open_loans": "Prêts ouverts", + "staff_user_status": "Statut", + "staff_user_status_active": "Actif", + "staff_user_status_suspended": "Suspendu", + "staff_user_status_expired": "Carte expirée", + "staff_user_card_expires": "Carte valable jusqu’au", + "staff_reservation_new": "Nouvelle réservation", + "staff_reservation_create": "Créer la réservation", + "staff_reservation_edit": "Modifier les dates", + "staff_reservation_cancel": "Annuler la réservation", + "staff_reservation_cancel_body": "Annuler la réservation de %1$s ? Le suivant dans la file avance.", + "staff_reservation_reactivate": "Réactiver", + "staff_reservation_cancelled": "Réservation annulée", + "staff_reservation_reactivated": "Réservation réactivée", + "staff_reservation_updated": "Réservation mise à jour", + "staff_reservation_from": "Souhaité à partir du", + "staff_reservation_to": "Souhaité jusqu’au", + "staff_reservation_expiry": "Expiration de la réservation", + "staff_reservation_dates_hint": "Laissez les dates vides pour attendre le prochain exemplaire libre.", + "staff_reservations_active": "Actives", + "staff_reservations_completed": "Terminées", + "staff_reservations_cancelled": "Annulées", + "staff_reservations_all": "Toutes", + "staff_reservations_empty": "Aucune réservation", + "staff_reservations_search": "Titre ou lecteur" } diff --git a/i18n/it.json b/i18n/it.json index 5bf2150..0ccb4b6 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -828,5 +828,200 @@ "staff_record_life": "Vita", "staff_record_created": "Creato", "staff_record_updated": "Aggiornato", - "staff_record_no_books": "Ancora nessun libro in catalogo" + "staff_record_no_books": "Ancora nessun libro in catalogo", + "staff_section_circulation": "Circolazione", + "staff_section_books": "Strumenti per i libri", + "staff_section_plugins": "Contenuti dei plugin", + "staff_circulation_desk": "Banco prestiti", + "staff_circulation_desk_subtitle": "Richieste, ritiri, prestiti e ritardi", + "staff_scan_copy": "Scansiona una copia", + "staff_scan_copy_subtitle": "Restituisci, presta o cerca una copia dalla sua etichetta", + "staff_loan_new": "Nuovo prestito", + "staff_reservations": "Prenotazioni", + "staff_offline_circulation_hidden": "Il banco prestiti richiede una connessione: torna appena sei online.", + "staff_series": "Collane", + "staff_merges": "Unisci duplicati", + "staff_merges_subtitle": "Autori, editori e generi", + "staff_imports": "Importa un file", + "staff_imports_subtitle": "CSV o LibraryThing, con avanzamento ripristinabile", + "staff_covers": "Copertine e arricchimento", + "staff_covers_subtitle": "Trova le copertine mancanti, completa i dati", + "staff_labels_settings": "Impostazioni etichette", + "staff_labels_settings_subtitle": "Dimensioni e contenuto delle etichette stampate", + "staff_desiderata": "Proposte di donazione", + "staff_desiderata_subtitle": "Accetta, rifiuta e ricevi i libri proposti", + "staff_archives": "Archivi", + "staff_archives_subtitle": "Unità archivistiche, allegati e pubblicazione", + "staff_authorities": "Schede di autorità", + "staff_circulation_actions": "Azioni rapide", + "staff_circulation_today": "In scadenza oggi: %1$d · Restituiti oggi: %2$d", + "staff_loans_title": "Prestiti", + "staff_loans_pending": "Richieste", + "staff_loans_pickup": "Da ritirare", + "staff_loans_scheduled": "Programmati", + "staff_loans_active": "In corso", + "staff_loans_overdue": "In ritardo", + "staff_loans_history": "Storico", + "staff_loans_all": "Tutti i prestiti", + "staff_loans_search": "Titolo, utente, tessera o codice copia", + "staff_loans_empty": "Nessun prestito qui", + "staff_select_many": "Seleziona più prestiti", + "staff_select_done": "Termina la selezione", + "staff_selected": "Selezionato", + "staff_not_selected": "Non selezionato", + "staff_selected_count": "Selezionati: %1$d", + "staff_bulk_extend": "Proroga", + "staff_bulk_extend_body": "Sposta in avanti la scadenza di %1$d prestiti di:", + "staff_bulk_extend_days": "Giorni", + "staff_bulk_extend_days_invalid": "Tra 1 e 365 giorni", + "staff_bulk_extended": "%1$d prestiti prorogati di %2$d giorni", + "staff_bulk_recalled": "Solleciti inviati: %1$d, saltati: %2$d", + "staff_loan_title": "Prestito", + "staff_loan_borrower": "Utente", + "staff_loan_details": "Dettagli del prestito", + "staff_user_card": "Tessera", + "staff_user_open": "Apri l’utente", + "staff_open_book": "Apri il libro", + "staff_copy_inventory": "Copia", + "staff_loan_requested": "Richiesto il", + "staff_loan_loaned": "Inizio prestito", + "staff_loan_due": "Scadenza", + "staff_loan_pickup_deadline": "Ritiro entro", + "staff_loan_returned_at": "Restituito il", + "staff_loan_renewals": "Rinnovi", + "staff_loan_renewals_value": "%1$d di %2$d", + "staff_loan_recalls": "Solleciti", + "staff_loan_processed_by": "Gestito da", + "staff_loan_days_overdue": "In ritardo di %1$d giorni", + "staff_loan_pickup_by": "Ritiro entro il %1$s", + "staff_loan_starts_on": "Inizia il %1$s", + "staff_penalty": "Sanzione", + "staff_penalty_invalid": "Un importo come 12,50 (solo copie perse o danneggiate)", + "staff_loan_action_approve": "Approva", + "staff_loan_action_reject": "Rifiuta", + "staff_loan_action_confirm_pickup": "Conferma ritiro", + "staff_loan_action_cancel_pickup": "Annulla ritiro", + "staff_loan_action_return": "Restituisci", + "staff_loan_action_renew": "Rinnova", + "staff_loan_action_edit": "Modifica prestito", + "staff_loan_action_recall": "Invia sollecito", + "staff_loan_action_receipt": "Ricevuta PDF", + "staff_loan_action_email_receipt": "Invia la ricevuta via email", + "staff_loan_approve_body": "Approvare la richiesta? L’utente riceve una notifica.", + "staff_loan_pickup_body": "L’utente è al banco e ritira ora la copia?", + "staff_loan_renew_body": "Prolungare il prestito di un periodo?", + "staff_loan_reject_body": "L’utente riceve una notifica, con il motivo se lo indichi.", + "staff_loan_cancel_pickup_body": "La copia torna disponibile per il prossimo utente.", + "staff_reason_optional": "Motivo (facoltativo)", + "staff_loan_approved": "Richiesta approvata", + "staff_loan_rejected": "Richiesta rifiutata", + "staff_loan_picked_up": "Ritiro confermato: il prestito è in corso", + "staff_loan_pickup_cancelled": "Ritiro annullato", + "staff_loan_renewed": "Prestito rinnovato", + "staff_loan_returned": "Restituzione registrata", + "staff_loan_updated": "Prestito aggiornato", + "staff_recall_sent": "Sollecito inviato", + "staff_receipt_emailed": "Ricevuta inviata via email", + "staff_mail_not_sent": "Non è stato possibile inviare l’email. Controlla le impostazioni di posta della biblioteca.", + "staff_receipt_ready": "Ricevuta del prestito", + "staff_download_failed": "Non è stato possibile scaricare il file.", + "staff_open_file": "Apri", + "staff_share_file": "Condividi", + "staff_return_outcome": "La copia torna", + "staff_return_outcome_returned": "A scaffale", + "staff_return_outcome_maintenance": "In manutenzione", + "staff_return_outcome_restoration": "In restauro", + "staff_return_outcome_lost": "Perso", + "staff_return_outcome_damaged": "Danneggiato", + "staff_queue_position": "In coda: n. %1$d", + "staff_reservation_expires": "Scade il %1$s", + "staff_pick_user": "Utente", + "staff_search_users": "Nome, email, tessera o telefono", + "staff_search_min_chars": "Scrivi almeno 2 caratteri.", + "staff_picker_confirm": "Aggiungi (%1$d)", + "staff_date_pick": "Scegli una data", + "staff_err_not_eligible": "Questo utente ora non può prendere in prestito (sospeso, tessera scaduta o non abilitato).", + "staff_err_not_available": "Nessuna copia disponibile per quelle date.", + "staff_err_max_loans": "Questo utente ha già il numero massimo di prestiti.", + "staff_err_loan_closed": "Questo prestito è chiuso e non si può più modificare.", + "staff_err_concurrent_retry": "Qualcun altro ha modificato il prestito nello stesso momento. Riprova.", + "staff_err_not_returnable": "Questo prestito non può essere restituito nello stato attuale.", + "staff_err_max_renewals": "Il prestito è già stato rinnovato il numero massimo di volte.", + "staff_err_loan_overdue": "Un prestito in ritardo non si può rinnovare: registra prima la restituzione.", + "staff_err_loan_not_active": "Si può rinnovare solo un prestito in corso.", + "staff_err_past_date": "Le date sono nel passato.", + "staff_err_ambiguous": "Più prestiti aperti corrispondono a questo codice: apri il prestito dall’elenco.", + "staff_err_copy_on_loan": "Questa copia è in prestito: registra prima la restituzione.", + "staff_err_copy_held": "Questa copia è riservata a un utente.", + "staff_err_copy_in_circulation": "Questa copia è in circolazione e non si può eliminare.", + "staff_err_copy_has_history": "Questa copia ha uno storico di prestiti: impostala come persa o ritirata invece di eliminarla.", + "staff_err_no_copies": "Questo libro non ha copie di cui stampare le etichette.", + "staff_err_offer_closed": "Questa proposta è già stata gestita.", + "staff_err_out_of_order": "L’importazione è proseguita altrove: riprendo da dove è il server.", + "staff_err_job_busy": "Questa importazione è in corso su un altro dispositivo. Riprova tra poco.", + "staff_err_gone": "Questa importazione non è più disponibile (finita, annullata o scaduta).", + "staff_err_upload_rejected": "Il server ha rifiutato il file (tipo o dimensione).", + "staff_err_feature_disabled": "Quest’area è disattivata sul server della biblioteca.", + "staff_err_rate_limited": "Troppe richieste: attendi un minuto e riprova.", + "staff_labels_hint": "Misure in millimetri. Valgono per tutte le etichette stampate dalla biblioteca, anche dal web.", + "staff_labels_width": "Larghezza (mm)", + "staff_labels_height": "Altezza (mm)", + "staff_labels_padding": "Margine interno (mm)", + "staff_labels_range": "Tra 10 e 100 mm (margine: da 0 a 20)", + "staff_labels_show_app_name": "Nome della biblioteca", + "staff_labels_show_title": "Titolo", + "staff_labels_show_subtitle": "Sottotitolo", + "staff_labels_show_author_publisher": "Autore ed editore", + "staff_labels_show_dewey": "Classificazione Dewey", + "staff_copy_code": "Codice copia", + "staff_copy_code_hint": "Facoltativo: presta proprio questa copia. Vuoto: il server ne sceglie una libera.", + "staff_copy_code_required": "Scrivi un codice copia", + "staff_copy_code_value": "Copia %1$s", + "staff_copy_not_found": "Nessuna copia con questo codice", + "staff_copy_not_found_body": "Nessuna copia del catalogo ha il codice %1$s.", + "staff_copy_not_found_isbn": "%1$s sembra l’ISBN di un libro, non l’etichetta di una copia. Scansiona l’etichetta inventariale della copia.", + "staff_copy_returned": "Restituzione registrata: %1$s", + "staff_copy_returned_count": "Restituiti: %1$d", + "staff_scan_copy_hint": "Inquadra l’etichetta inventariale della copia, oppure scrivi il codice qui sotto.", + "staff_scan_copy_permission": "La fotocamera legge le etichette delle copie. Puoi anche scrivere il codice qui sotto.", + "staff_scan_next": "Scansiona la copia successiva", + "staff_loan_new_with_copy": "Nuovo prestito con questa copia", + "staff_loan_open": "Apri il prestito", + "staff_hand_over_now": "Consegna subito", + "staff_hand_over_now_hint": "L’utente prende la copia al banco: il prestito parte subito.", + "staff_hand_over_future": "Un prestito che inizia più avanti viene programmato e consegnato quel giorno.", + "staff_loan_create": "Crea il prestito", + "staff_loan_dates": "Date", + "staff_loan_dates_hint": "Inizio vuoto: oggi. Scadenza vuota: la durata standard della biblioteca.", + "staff_loan_dates_order": "La data di fine è precedente a quella di inizio.", + "staff_loan_summary_start": "Inizio: %1$s", + "staff_pick_book": "Libro", + "staff_pick_book_placeholder": "Cerca nel catalogo", + "staff_pick_user_placeholder": "Cerca un utente", + "staff_user_not_eligible": "Ora non può prendere in prestito: %1$s", + "staff_user_open_loans": "Prestiti aperti", + "staff_user_status": "Stato", + "staff_user_status_active": "Attivo", + "staff_user_status_suspended": "Sospeso", + "staff_user_status_expired": "Tessera scaduta", + "staff_user_card_expires": "Tessera valida fino al", + "staff_reservation_new": "Nuova prenotazione", + "staff_reservation_create": "Crea la prenotazione", + "staff_reservation_edit": "Modifica le date", + "staff_reservation_cancel": "Annulla prenotazione", + "staff_reservation_cancel_body": "Annullare la prenotazione di %1$s? Il successivo in coda avanza.", + "staff_reservation_reactivate": "Riattiva", + "staff_reservation_cancelled": "Prenotazione annullata", + "staff_reservation_reactivated": "Prenotazione riattivata", + "staff_reservation_updated": "Prenotazione aggiornata", + "staff_reservation_from": "Desiderato dal", + "staff_reservation_to": "Desiderato fino al", + "staff_reservation_expiry": "Scadenza prenotazione", + "staff_reservation_dates_hint": "Lascia le date vuote per mettersi in coda per la prossima copia libera.", + "staff_reservations_active": "Attive", + "staff_reservations_completed": "Completate", + "staff_reservations_cancelled": "Annullate", + "staff_reservations_all": "Tutte", + "staff_reservations_empty": "Nessuna prenotazione", + "staff_reservations_search": "Titolo o utente" } From f1ad1a33441604354d17ce748680b60267233748 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 01:05:31 +0200 Subject: [PATCH 34/62] Run the circulation desk from the staff area The staff hub gains a Circulation section (desk counters, copy scanner, new loan, reservations) that is hidden while offline, a Book tools section and a Plugin content section, each row shown only when its health flag is on. Loans are listed by desk bucket with search and multi-select bulk extend and recall; a loan opens with the actions the server offers (approve, reject, pickup, return with outcome and penalty, renew, edit, recall, receipt PDF read in the app or shared through the FileProvider, receipt by email). A new loan picks the borrower with eligibility, the book or a scanned copy code, and the dates on the per-user availability calendar. Reservations are grouped by title with edit, cancel and reactivate. The copy scanner reads CODE 128 and CODE 39 inventory labels as well as EAN-13 and returns or lends the copy it finds. The label settings sheet edits the label size and content. --- .../com/pinakes/app/ui/common/ShareFile.kt | 44 ++ .../app/ui/navigation/PinakesNavHost.kt | 93 ++++ .../app/ui/screens/detail/PdfReaderDialog.kt | 40 ++ .../app/ui/screens/staff/StaffCommon.kt | 446 +++++++++++++++++ .../app/ui/screens/staff/StaffHubScreen.kt | 84 +++- .../staff/circulation/CirculationUi.kt | 189 ++++++++ .../circulation/StaffCirculationHomeScreen.kt | 189 ++++++++ .../circulation/StaffLoanCreateScreen.kt | 332 +++++++++++++ .../circulation/StaffLoanDetailScreen.kt | 449 ++++++++++++++++++ .../staff/circulation/StaffLoansScreen.kt | 352 ++++++++++++++ .../circulation/StaffReservationsScreen.kt | 302 ++++++++++++ .../staff/circulation/StaffUserScreen.kt | 160 +++++++ .../staff/content/StaffLabelsSettingsSheet.kt | 190 ++++++++ .../ui/screens/staff/scan/BarcodeAnalyzer.kt | 19 +- .../ui/screens/staff/scan/CameraPreview.kt | 3 +- .../screens/staff/scan/StaffCopyScanScreen.kt | 410 ++++++++++++++++ app/src/main/res/xml/file_paths.xml | 4 +- 17 files changed, 3300 insertions(+), 6 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/ui/common/ShareFile.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffCirculationHomeScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoansScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffReservationsScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffLabelsSettingsSheet.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt diff --git a/app/src/main/java/com/pinakes/app/ui/common/ShareFile.kt b/app/src/main/java/com/pinakes/app/ui/common/ShareFile.kt new file mode 100644 index 0000000..138c0ad --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/common/ShareFile.kt @@ -0,0 +1,44 @@ +package com.pinakes.app.ui.common + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import androidx.core.app.ShareCompat +import androidx.core.content.FileProvider +import java.io.File + +/** + * Hands a staff export (receipt, labels, error CSV) from `cache/exports/` to other apps through + * the FileProvider, with a read grant on that one URI. Nothing else in the cache is exposed. + */ +object ShareFile { + + private fun uri(context: Context, file: File) = + FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file) + + /** The system share sheet (print, mail, drive, ...). */ + fun share(context: Context, file: File, mime: String, chooserTitle: String) { + val intent = ShareCompat.IntentBuilder(context) + .setType(mime) + .setStream(uri(context, file)) + .setChooserTitle(chooserTitle) + .createChooserIntent() + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(intent) } + } + + /** Open in a viewer app; false when none is installed (the caller then shares instead). */ + fun open(context: Context, file: File, mime: String): Boolean { + val intent = Intent(Intent.ACTION_VIEW) + .setDataAndType(uri(context, file), mime) + .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + return try { + context.startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index 39d96f5..414e09a 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -47,6 +47,14 @@ import com.pinakes.app.ui.screens.staff.StaffRecordDetailScreen import com.pinakes.app.ui.screens.staff.RecordKind import com.pinakes.app.ui.screens.staff.StaffShelvesScreen import com.pinakes.app.ui.screens.staff.scan.StaffScanScreen +import com.pinakes.app.ui.screens.staff.StaffHubPhase2 +import com.pinakes.app.ui.screens.staff.circulation.StaffCirculationHomeScreen +import com.pinakes.app.ui.screens.staff.circulation.StaffLoanCreateScreen +import com.pinakes.app.ui.screens.staff.circulation.StaffLoanDetailScreen +import com.pinakes.app.ui.screens.staff.circulation.StaffLoansScreen +import com.pinakes.app.ui.screens.staff.circulation.StaffReservationsScreen +import com.pinakes.app.ui.screens.staff.circulation.StaffUserScreen +import com.pinakes.app.ui.screens.staff.scan.StaffCopyScanScreen /** * Root navigation. The high-level [AuthState] decides the start destination; within the @@ -157,6 +165,91 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onPublishers = { navController.navigate(Routes.STAFF_PUBLISHERS) }, onGenres = { navController.navigate(Routes.STAFF_GENRES) }, onShelves = { navController.navigate(Routes.STAFF_SHELVES) }, + phase2 = StaffHubPhase2( + onCirculation = { navController.navigate(Routes.STAFF_CIRCULATION) }, + onCopyScan = { navController.navigate(Routes.STAFF_COPY_SCAN) }, + onNewLoan = { navController.navigate(Routes.staffLoanNew()) }, + onReservations = { navController.navigate(Routes.STAFF_RESERVATIONS) }, + onSeries = { navController.navigate(Routes.STAFF_SERIES) }, + onMerges = { navController.navigate(Routes.STAFF_MERGES) }, + onImports = { navController.navigate(Routes.STAFF_IMPORTS) }, + onCovers = { navController.navigate(Routes.STAFF_COVERS) }, + onDesiderata = { navController.navigate(Routes.STAFF_DESIDERATA) }, + onArchives = { navController.navigate(Routes.STAFF_ARCHIVES) }, + onAuthorities = { navController.navigate(Routes.STAFF_AUTHORITIES) }, + ), + ) + } + + // ---- Staff circulation desk (Mobile API 1.7.0) ---- + composable(Routes.STAFF_CIRCULATION, enterTransition = slideIn, popExitTransition = slideOut) { + StaffCirculationHomeScreen( + onNavigateUp = { navController.popBackStack() }, + onOpenLoans = { navController.navigate(Routes.staffLoans(it)) }, + onScanCopy = { navController.navigate(Routes.STAFF_COPY_SCAN) }, + onNewLoan = { navController.navigate(Routes.staffLoanNew()) }, + onReservations = { navController.navigate(Routes.STAFF_RESERVATIONS) }, + ) + } + composable( + Routes.STAFF_LOANS, + arguments = listOf(navArgument(Routes.ARG_BUCKET) { type = NavType.StringType; defaultValue = "all" }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StaffLoansScreen(onNavigateUp = { navController.popBackStack() }, onOpenLoan = { navController.navigate(Routes.staffLoan(it)) }) + } + composable( + Routes.STAFF_LOAN, + arguments = listOf(navArgument(Routes.ARG_LOAN_ID) { type = NavType.IntType }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StaffLoanDetailScreen( + onNavigateUp = { navController.popBackStack() }, + onOpenUser = { navController.navigate(Routes.staffUser(it)) }, + onOpenBook = { navController.navigate(Routes.staffBookEdit(it)) }, + ) + } + composable( + Routes.STAFF_LOAN_NEW, + arguments = listOf( + navArgument(Routes.ARG_USER_ID) { type = NavType.IntType; defaultValue = 0 }, + navArgument(Routes.ARG_BOOK_ID) { type = NavType.IntType; defaultValue = 0 }, + navArgument(Routes.ARG_COPY_CODE) { type = NavType.StringType; defaultValue = "" }, + navArgument(Routes.ARG_RESERVATION_MODE) { type = NavType.BoolType; defaultValue = false }, + ), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StaffLoanCreateScreen( + onNavigateUp = { navController.popBackStack() }, + onCreated = { id -> navController.navigate(Routes.staffLoan(id)) { popUpTo(Routes.STAFF_LOAN_NEW) { inclusive = true } } }, + onReservationCreated = { navController.popBackStack() }, + ) + } + composable(Routes.STAFF_RESERVATIONS, enterTransition = slideIn, popExitTransition = slideOut) { + StaffReservationsScreen( + onNavigateUp = { navController.popBackStack() }, + onNewReservation = { navController.navigate(Routes.staffLoanNew(reservation = true)) }, + onOpenUser = { navController.navigate(Routes.staffUser(it)) }, + onNewLoan = { user, book -> navController.navigate(Routes.staffLoanNew(userId = user, bookId = book)) }, + ) + } + composable( + Routes.STAFF_USER, + arguments = listOf(navArgument(Routes.ARG_USER_ID) { type = NavType.IntType }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StaffUserScreen( + onNavigateUp = { navController.popBackStack() }, + onOpenLoan = { navController.navigate(Routes.staffLoan(it)) }, + onNewLoan = { navController.navigate(Routes.staffLoanNew(userId = it)) }, + ) + } + composable(Routes.STAFF_COPY_SCAN, enterTransition = slideIn, popExitTransition = slideOut) { + StaffCopyScanScreen( + onNavigateUp = { navController.popBackStack() }, + onNewLoan = { bookId, code -> navController.navigate(Routes.staffLoanNew(bookId = bookId, copyCode = code)) }, + onOpenBook = { navController.navigate(Routes.staffBookEdit(it)) }, + onOpenLoan = { navController.navigate(Routes.staffLoan(it)) }, ) } composable(Routes.STAFF_SCAN, enterTransition = slideIn, popExitTransition = slideOut) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt index e7cd1a2..b605587 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/PdfReaderDialog.kt @@ -88,6 +88,46 @@ fun PdfReaderDialog( state = PdfState.Error } } + PdfReaderContent(state, onDismiss) +} + +/** + * The same reader for a PDF already on disk (a staff export downloaded with the bearer token: + * receipts and labels never go through the unauthenticated media client). The reader works on + * a private copy, so [file] stays available for sharing after the dialog closes. + */ +@Composable +fun PdfReaderDialog( + file: File, + onDismiss: () -> Unit, +) { + val context = LocalContext.current + var state by remember { mutableStateOf(PdfState.Loading) } + + LaunchedEffect(file) { + state = PdfState.Loading + var document: CachedPdfDocument? = null + try { + withContext(Dispatchers.IO) { + val copy = File.createTempFile("pinakes-export-", ".pdf", context.cacheDir) + file.copyTo(copy, overwrite = true) + document = CachedPdfDocument.open(copy) + } + state = PdfState.Ready(checkNotNull(document)) + awaitCancellation() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + state = PdfState.Error + } finally { + withContext(NonCancellable + Dispatchers.IO) { document?.close() } + } + } + PdfReaderContent(state, onDismiss) +} + +@Composable +private fun PdfReaderContent(state: PdfState, onDismiss: () -> Unit) { Dialog( onDismissRequest = onDismiss, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt new file mode 100644 index 0000000..73d4684 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt @@ -0,0 +1,446 @@ +package com.pinakes.app.ui.screens.staff + +import androidx.annotation.StringRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckBox +import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank +import androidx.compose.material.icons.outlined.Share +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.pinakes.app.R +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.staff.StaffErrors +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.common.isOffline +import com.pinakes.app.ui.common.serverMessage +import com.pinakes.app.ui.components.EmptyState +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SearchField +import com.pinakes.app.ui.theme.Spacing +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneOffset + +// Shared building blocks of the Phase 2 staff screens. They follow the Phase 1 staff screens +// (StaffUi.kt): the same rows, sheets, fields and states, so a new screen reads like the old. + +/** A user-facing message: a string resource (with arguments) or a server text. */ +data class StaffMessage(@param:StringRes val res: Int? = null, val text: String? = null, val args: List = emptyList()) + +@Composable +fun StaffMessage.resolve(): String = when { + !text.isNullOrBlank() -> text + res != null -> stringResource(res, *args.toTypedArray()) + else -> "" +} + +/** + * The message for a failed staff call: our wording for a known code, else the server text + * (409 `conflict` carries the only precise explanation), else offline wording or [fallback]. + */ +fun ApiResult.Failure.toStaffMessage(@StringRes fallback: Int): StaffMessage { + StaffErrors.messageRes(this)?.let { return StaffMessage(it) } + if (isOffline) return StaffMessage(R.string.offline_generic) + return serverMessage()?.let { StaffMessage(text = it) } ?: StaffMessage(fallback) +} + +/** Show [message] once in [host], then report it consumed. */ +@Composable +internal fun StaffSnackbarEffect(message: StaffMessage?, host: SnackbarHostState, onConsumed: () -> Unit) { + val text = message?.resolve() + LaunchedEffect(message) { + if (message != null && !text.isNullOrBlank()) { + host.showSnackbar(text) + onConsumed() + } + } +} + +/** + * Leaves the screen when its area is no longer offered: the circulation desk while the device + * goes offline (owner rule: hidden, never disabled), or a flag the server turned off. + */ +@Composable +internal fun LeaveWhen(condition: Boolean, onNavigateUp: () -> Unit) { + LaunchedEffect(condition) { if (condition) onNavigateUp() } +} + +/** Loading → error → empty → content, the order every staff list uses. */ +@Composable +internal fun StaffLoadBody( + loading: Boolean, + error: StaffMessage?, + isEmpty: Boolean, + emptyTitle: String, + emptyIcon: ImageVector, + onRetry: () -> Unit, + emptySubtitle: String = "", + content: @Composable () -> Unit, +) { + when { + loading -> LoadingState() + error != null -> ErrorState(message = error.resolve().ifBlank { stringResource(R.string.staff_error_load) }, onRetry = onRetry) + isEmpty -> EmptyState(title = emptyTitle, subtitle = emptySubtitle, icon = emptyIcon) + else -> content() + } +} + +/** A labelled switch row (forms and settings sheets). */ +@Composable +internal fun SwitchRow(title: String, checked: Boolean, onChange: (Boolean) -> Unit, subtitle: String? = null, enabled: Boolean = true) { + Row(Modifier.fillMaxWidth().padding(vertical = Spacing.xs), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) + if (!subtitle.isNullOrBlank()) { + Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + Switch(checked = checked, onCheckedChange = onChange, enabled = enabled) + } +} + +/** A key/value line inside a staff info card. */ +@Composable +internal fun InfoLine(label: String, value: String?) { + if (value.isNullOrBlank()) return + Row(Modifier.fillMaxWidth().padding(vertical = Spacing.xs), horizontalArrangement = Arrangement.spacedBy(Spacing.md)) { + Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(0.42f)) + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(0.58f)) + } +} + +/** The white rounded box grouping a record's facts (DESIGN.md "Other screens"). */ +@Composable +internal fun InfoCard(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Spacing.lg)) { content() } + } +} + +/** A dialog asking for an optional free-text reason (reject, cancel pickup, cancel reservation). */ +@Composable +internal fun ReasonDialog( + title: String, + body: String, + confirmLabel: String, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, + label: String = stringResource(R.string.staff_reason_optional), + required: Boolean = false, +) { + var value by rememberSaveable { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + shape = MaterialTheme.shapes.large, + title = { Text(title, style = MaterialTheme.typography.titleMedium) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + if (body.isNotBlank()) Text(body, style = MaterialTheme.typography.bodyMedium) + PinakesTextField( + value = value, onValueChange = { value = it }, label = label, singleLine = false, maxLength = 500, + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).testTag("reason-field"), + ) + } + }, + confirmButton = { PrimaryButton(confirmLabel, onClick = { onConfirm(value) }, enabled = !required || value.isNotBlank()) }, + dismissButton = { PinakesTextButton(stringResource(R.string.action_cancel), onClick = onDismiss) }, + ) +} + +/** + * A read-only field showing a date (`yyyy-MM-dd`, rendered in the app language) that opens the + * Material date picker. [onChange] receives `yyyy-MM-dd`, or null when cleared. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DatePickerField( + label: String, + value: String?, + onChange: (String?) -> Unit, + allowClear: Boolean = false, + enabled: Boolean = true, +) { + var open by remember { mutableStateOf(false) } + PickerRow( + label = label, + value = value?.takeIf { it.isNotBlank() }?.let { DateFormat.date(it) }, + placeholder = stringResource(R.string.staff_date_pick), + onClick = { if (enabled) open = true }, + onClear = if (allowClear) ({ onChange(null) }) else null, + ) + if (open) { + val initial = value?.let { runCatching { LocalDate.parse(it) }.getOrNull() } + val pickerState = rememberDatePickerState(initialSelectedDateMillis = initial?.atStartOfDay(ZoneOffset.UTC)?.toInstant()?.toEpochMilli()) + DatePickerDialog( + onDismissRequest = { open = false }, + confirmButton = { + PinakesTextButton(stringResource(R.string.action_confirm), onClick = { + pickerState.selectedDateMillis?.let { millis -> + onChange(Instant.ofEpochMilli(millis).atZone(ZoneOffset.UTC).toLocalDate().toString()) + } + open = false + }) + }, + dismissButton = { PinakesTextButton(stringResource(R.string.action_cancel), onClick = { open = false }) }, + ) { DatePicker(state = pickerState) } + } +} + +/** A thin progress line with "n of m" under it (imports, cover loops). */ +@Composable +internal fun ProgressLine(done: Int, total: Int, label: String) { + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + LinearProgressIndicator( + progress = { if (total <= 0) 0f else (done.toFloat() / total).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().testTag("progress"), + ) + Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } +} + +// --------------------------------------------------------------------------------------------- +// Search pickers (users, books, series, authorities) +// --------------------------------------------------------------------------------------------- + +/** One row a picker can show. */ +data class PickItem(val id: Int, val title: String, val subtitle: String? = null, val enabled: Boolean = true, val note: String? = null) + +data class SearchState( + val query: String = "", + val results: List = emptyList(), + val loading: Boolean = false, + val error: StaffMessage? = null, + val searched: Boolean = false, +) + +/** + * Debounced search for a picker, owned by a ViewModel. Late answers for an older query are + * dropped; a query shorter than [minLength] clears the results without calling the server. + */ +class SearchController( + private val scope: CoroutineScope, + private val minLength: Int = 1, + private val search: suspend (String) -> ApiResult>, +) { + private val _state = MutableStateFlow(SearchState()) + val state: StateFlow = _state.asStateFlow() + private var job: Job? = null + private var generation = 0 + /** Called on a refused (forbidden) search so the screen can leave. */ + var onFailure: (ApiResult.Failure) -> Unit = {} + + fun onQuery(value: String) { + _state.update { it.copy(query = value) } + job?.cancel() + val gen = ++generation + if (value.trim().length < minLength) { + _state.update { it.copy(results = emptyList(), loading = false, error = null, searched = false) } + return + } + job = scope.launch { + delay(300) + _state.update { it.copy(loading = true, error = null) } + val res = search(value.trim()) + if (gen != generation) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy(results = res.data, loading = false, searched = true) + is ApiResult.Failure -> { + onFailure(res) + it.copy(loading = false, error = res.toStaffMessage(R.string.staff_error_load), searched = true) + } + } + } + } + } + + /** Run the current query again (or start with [initial]). */ + fun start(initial: String = _state.value.query) = onQuery(initial) + + fun clear() { + job?.cancel() + generation++ + _state.value = SearchState() + } +} + +/** + * A bottom sheet with a search field and the matching rows. Single pick calls [onPick]; + * with [selected] non-null the rows toggle and [onConfirm] closes with the selection. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun SearchPickerSheet( + title: String, + placeholder: String, + state: SearchState, + onQuery: (String) -> Unit, + onPick: (PickItem) -> Unit, + onDismiss: () -> Unit, + selected: Set? = null, + onConfirm: (() -> Unit)? = null, + hint: String? = null, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(title, style = MaterialTheme.typography.headlineSmall) + SearchField(query = state.query, onQueryChange = onQuery, placeholder = placeholder, modifier = Modifier.fillMaxWidth().testTag("picker-search")) + if (!hint.isNullOrBlank() && state.results.isEmpty() && !state.loading) { + Text(hint, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + when { + state.loading -> LoadingState(modifier = Modifier.heightIn(max = 120.dp)) + state.error != null -> Text(state.error.resolve(), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyMedium) + state.searched && state.results.isEmpty() -> Text( + stringResource(R.string.staff_picker_empty), style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LazyColumn(Modifier.fillMaxWidth().heightIn(max = 420.dp), contentPadding = PaddingValues(bottom = Spacing.sm)) { + items(state.results, key = { it.id }) { item -> + val isSelected = selected?.contains(item.id) == true + Surface( + onClick = { if (item.enabled) onPick(item) }, + enabled = item.enabled, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs), + ) { + Row(Modifier.padding(Spacing.md), verticalAlignment = Alignment.CenterVertically) { + if (selected != null) { + Icon( + if (isSelected) Icons.Outlined.CheckBox else Icons.Outlined.CheckBoxOutlineBlank, + contentDescription = null, tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.padding(start = Spacing.sm)) + } + Column(Modifier.weight(1f)) { + Text(item.title, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis, + color = if (item.enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant) + item.subtitle?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + item.note?.takeIf { it.isNotBlank() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + } + } + } + } + if (selected != null && onConfirm != null) { + PrimaryButton( + stringResource(R.string.staff_picker_confirm, selected.size), onClick = onConfirm, + enabled = selected.isNotEmpty(), modifier = Modifier.fillMaxWidth(), + ) + } + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +/** A tappable field that opens a picker and shows the pick (user, book). */ +@Composable +internal fun PickedField(label: String, value: String?, placeholder: String, onClick: () -> Unit, onClear: (() -> Unit)? = null) = + PickerRow(label = label, value = value, placeholder = placeholder, onClick = onClick, onClear = onClear) + +/** A trailing icon button with a content description (row actions). */ +@Composable +internal fun RowIconButton(icon: ImageVector, description: String, onClick: () -> Unit, enabled: Boolean = true) { + IconButton(onClick = onClick, enabled = enabled) { Icon(icon, contentDescription = description) } +} + +/** + * A downloaded staff export (receipt, labels, error CSV): a PDF opens in the in-app reader, + * anything else in a viewer app; both can be shared through the FileProvider (print, mail). + */ +@Composable +internal fun ExportFileDialog(file: java.io.File, mime: String, title: String, onDismiss: () -> Unit) { + val context = androidx.compose.ui.platform.LocalContext.current + var reading by rememberSaveable { mutableStateOf(false) } + if (reading) { + com.pinakes.app.ui.screens.detail.PdfReaderDialog(file = file, onDismiss = { reading = false }) + return + } + val shareTitle = stringResource(R.string.staff_share_file) + AlertDialog( + onDismissRequest = onDismiss, + shape = MaterialTheme.shapes.large, + title = { Text(title, style = MaterialTheme.typography.titleMedium) }, + text = { Text(file.name, style = MaterialTheme.typography.bodyMedium) }, + confirmButton = { + PrimaryButton(stringResource(R.string.staff_open_file), onClick = { + if (mime == "application/pdf") reading = true + else if (!com.pinakes.app.ui.common.ShareFile.open(context, file, mime)) com.pinakes.app.ui.common.ShareFile.share(context, file, mime, shareTitle) + }) + }, + dismissButton = { + com.pinakes.app.ui.components.SecondaryButton(shareTitle, leadingIcon = androidx.compose.material.icons.Icons.Outlined.Share, onClick = { + com.pinakes.app.ui.common.ShareFile.share(context, file, mime, shareTitle) + onDismiss() + }) + }, + ) +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt index acf771d..d0283d5 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt @@ -7,16 +7,31 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.LibraryBooks +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.automirrored.outlined.AssignmentReturn +import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Business import androidx.compose.material.icons.outlined.Category +import androidx.compose.material.icons.outlined.CollectionsBookmark +import androidx.compose.material.icons.outlined.Event import androidx.compose.material.icons.outlined.Inventory2 +import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material.icons.automirrored.outlined.MergeType import androidx.compose.material.icons.outlined.Person import androidx.compose.material.icons.outlined.PostAdd import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.material.icons.outlined.UploadFile +import androidx.compose.material.icons.outlined.VolunteerActivism import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel @@ -24,9 +39,29 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.pinakes.app.R import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.screens.staff.content.StaffLabelsSettingsSheet import com.pinakes.app.ui.theme.Spacing -/** Entry point of the staff area (Profile → Staff area). */ +/** Navigation targets of the staff hub's Phase 2 sections (Mobile API 1.7.0). */ +data class StaffHubPhase2( + val onCirculation: () -> Unit = {}, + val onCopyScan: () -> Unit = {}, + val onNewLoan: () -> Unit = {}, + val onReservations: () -> Unit = {}, + val onSeries: () -> Unit = {}, + val onMerges: () -> Unit = {}, + val onImports: () -> Unit = {}, + val onCovers: () -> Unit = {}, + val onDesiderata: () -> Unit = {}, + val onArchives: () -> Unit = {}, + val onAuthorities: () -> Unit = {}, +) + +/** + * Entry point of the staff area (Profile → Staff area). Each Phase 2 row shows only when the + * server advertises its area; the circulation section also needs a connection and disappears + * offline (hidden, not disabled). + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun StaffHubScreen( @@ -38,9 +73,13 @@ fun StaffHubScreen( onPublishers: () -> Unit, onGenres: () -> Unit, onShelves: () -> Unit, + phase2: StaffHubPhase2 = StaffHubPhase2(), ) { val app: AppViewModel = hiltViewModel() val isStaff by app.isStaff.collectAsStateWithLifecycle() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val features by app.features.collectAsStateWithLifecycle() + var labelsOpen by rememberSaveable { mutableStateOf(false) } // Role revoked (or the server dropped staff support) while the hub was open. ForbiddenExit(forbidden = !isStaff, onNavigateUp = onNavigateUp) @@ -52,6 +91,24 @@ fun StaffHubScreen( .verticalScroll(rememberScrollState()) .padding(horizontal = Spacing.lg, vertical = Spacing.sm), ) { + if (areas.circulation) { + FormSectionTitle(stringResource(R.string.staff_section_circulation)) + StaffActionRow(Icons.Outlined.SwapHoriz, stringResource(R.string.staff_circulation_desk), + stringResource(R.string.staff_circulation_desk_subtitle), phase2.onCirculation) + StaffActionRow(Icons.AutoMirrored.Outlined.AssignmentReturn, stringResource(R.string.staff_scan_copy), + stringResource(R.string.staff_scan_copy_subtitle), phase2.onCopyScan) + StaffActionRow(Icons.Outlined.PostAdd, stringResource(R.string.staff_loan_new), onClick = phase2.onNewLoan) + StaffActionRow(Icons.Outlined.Event, stringResource(R.string.staff_reservations), onClick = phase2.onReservations) + } else if (features.staffCirculationAvailable) { + // The desk exists but the device is offline: say why it is not here. + Text( + stringResource(R.string.staff_offline_circulation_hidden), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = Spacing.md), + ) + } + FormSectionTitle(stringResource(R.string.staff_section_add)) StaffActionRow(Icons.Outlined.QrCodeScanner, stringResource(R.string.staff_scan), stringResource(R.string.staff_scan_subtitle), onScan) StaffActionRow(Icons.Outlined.PostAdd, stringResource(R.string.staff_add_manual), stringResource(R.string.staff_add_manual_subtitle), onAddManual) @@ -62,6 +119,31 @@ fun StaffHubScreen( StaffActionRow(Icons.Outlined.Business, stringResource(R.string.staff_publishers), onClick = onPublishers) StaffActionRow(Icons.Outlined.Category, stringResource(R.string.staff_genres), onClick = onGenres) StaffActionRow(Icons.Outlined.Inventory2, stringResource(R.string.staff_shelves), onClick = onShelves) + + if (areas.anyBooksTool) { + FormSectionTitle(stringResource(R.string.staff_section_books)) + if (areas.series) StaffActionRow(Icons.Outlined.CollectionsBookmark, stringResource(R.string.staff_series), onClick = phase2.onSeries) + if (areas.merges) StaffActionRow(Icons.AutoMirrored.Outlined.MergeType, stringResource(R.string.staff_merges), + stringResource(R.string.staff_merges_subtitle), phase2.onMerges) + if (areas.imports) StaffActionRow(Icons.Outlined.UploadFile, stringResource(R.string.staff_imports), + stringResource(R.string.staff_imports_subtitle), phase2.onImports) + if (areas.covers) StaffActionRow(Icons.Outlined.AutoAwesome, stringResource(R.string.staff_covers), + stringResource(R.string.staff_covers_subtitle), phase2.onCovers) + if (areas.labels) StaffActionRow(Icons.AutoMirrored.Outlined.Label, stringResource(R.string.staff_labels_settings), + stringResource(R.string.staff_labels_settings_subtitle)) { labelsOpen = true } + } + + if (areas.anyPlugin) { + FormSectionTitle(stringResource(R.string.staff_section_plugins)) + if (areas.desiderata) StaffActionRow(Icons.Outlined.VolunteerActivism, stringResource(R.string.staff_desiderata), + stringResource(R.string.staff_desiderata_subtitle), phase2.onDesiderata) + if (areas.archives) { + StaffActionRow(Icons.Outlined.Archive, stringResource(R.string.staff_archives), stringResource(R.string.staff_archives_subtitle), phase2.onArchives) + StaffActionRow(Icons.Outlined.Person, stringResource(R.string.staff_authorities), onClick = phase2.onAuthorities) + } + } } } + + if (labelsOpen && areas.labels) StaffLabelsSettingsSheet(onDismiss = { labelsOpen = false }) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt new file mode 100644 index 0000000..e53c2ce --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt @@ -0,0 +1,189 @@ +package com.pinakes.app.ui.screens.staff.circulation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.CheckBox +import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import com.pinakes.app.R +import com.pinakes.app.data.model.StaffLoan +import com.pinakes.app.data.model.StaffReservation +import com.pinakes.app.data.staff.LoanActions +import com.pinakes.app.data.staff.PenaltyInput +import com.pinakes.app.data.staff.ReturnOutcome +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.common.StatusMapping +import com.pinakes.app.ui.components.AvailabilityStatus +import com.pinakes.app.ui.components.FilterChipRow +import com.pinakes.app.ui.components.FilterOption +import com.pinakes.app.ui.components.MediaRow +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.theme.Spacing + +/** Chip status + localized label for a loan state (`status_label` for states the app does not know). */ +@Composable +internal fun loanChip(loan: StaffLoan): Pair { + val (status, label) = StatusMapping.loan(loan.status, loan.statusLabel) + return status to (label.resId?.let { stringResource(it) } ?: label.fallback) +} + +@Composable +internal fun reservationChip(reservation: StaffReservation): Pair { + val (status, label) = StatusMapping.reservation(reservation.status) + return status to (label.resId?.let { stringResource(it) } ?: label.fallback ?: reservation.statusLabel) +} + +/** The date that matters for a desk row: overdue days, due date, pickup deadline, request date. */ +@Composable +internal fun loanDateLine(loan: StaffLoan): String? = when { + loan.status == "in_ritardo" && loan.daysOverdue > 0 -> stringResource(R.string.staff_loan_days_overdue, loan.daysOverdue) + loan.status == "da_ritirare" && loan.pickupDeadline != null -> stringResource(R.string.staff_loan_pickup_by, DateFormat.date(loan.pickupDeadline)) + loan.returnedAt != null -> stringResource(R.string.library_returned_on, DateFormat.date(loan.returnedAt)) + loan.status == "pendente" && loan.requestedAt != null -> stringResource(R.string.library_requested_on, DateFormat.date(loan.requestedAt)) + loan.status == "prenotato" && loan.loanedAt != null -> stringResource(R.string.staff_loan_starts_on, DateFormat.date(loan.loanedAt)) + loan.dueAt != null -> stringResource(R.string.library_due_label, DateFormat.date(loan.dueAt)) + else -> null +} + +/** One loan at the desk: cover, title, borrower and the date that matters. */ +@Composable +internal fun StaffLoanRow( + loan: StaffLoan, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean? = null, + showUser: Boolean = true, +) { + val (status, label) = loanChip(loan) + val user = listOfNotNull(loan.user.name.takeIf { it.isNotBlank() }, loan.user.cardNumber?.takeIf { it.isNotBlank() }).joinToString(" · ") + MediaRow( + modifier = modifier.padding(vertical = Spacing.xs).testTag("loan-${loan.id}"), + title = loan.book.title, + coverUrl = loan.book.coverUrl, + line1 = if (showUser) user.ifBlank { null } else loan.copy?.inventoryNumber, + line2 = loanDateLine(loan), + status = status, + statusLabel = label, + onClick = onClick, + trailing = selected?.let { isSelected -> + { + Icon( + if (isSelected) Icons.Outlined.CheckBox else Icons.Outlined.CheckBoxOutlineBlank, + contentDescription = stringResource(if (isSelected) R.string.staff_selected else R.string.staff_not_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) +} + +/** One reservation: position in the queue, borrower, requested window. */ +@Composable +internal fun StaffReservationRow( + reservation: StaffReservation, + onClick: () -> Unit, + modifier: Modifier = Modifier, + showBook: Boolean = true, + trailing: @Composable (() -> Unit)? = null, +) { + val (status, label) = reservationChip(reservation) + val window = listOfNotNull(reservation.requestedFrom, reservation.requestedTo).joinToString(" → ") { DateFormat.date(it) } + val position = reservation.queuePosition?.let { stringResource(R.string.staff_queue_position, it) } + MediaRow( + modifier = modifier.padding(vertical = Spacing.xs).testTag("reservation-${reservation.id}"), + title = if (showBook) reservation.book.title else reservation.user.name, + coverUrl = if (showBook) reservation.book.coverUrl else null, + line1 = listOfNotNull(position, if (showBook) reservation.user.name.takeIf { it.isNotBlank() } else reservation.user.cardNumber).joinToString(" · ").ifBlank { null }, + line2 = window.ifBlank { reservation.expiresAt?.let { stringResource(R.string.staff_reservation_expires, DateFormat.date(it)) } }, + status = status, + statusLabel = label, + onClick = onClick, + trailing = trailing, + ) +} + +/** + * The return form: the copy's outcome, notes, and a penalty for lost or damaged copies. The + * penalty is validated before sending ([LoanActions.parsePenalty]). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ReturnSheet( + title: String, + saving: Boolean, + onConfirm: (outcome: ReturnOutcome, notes: String, penalty: Double?) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var outcome by rememberSaveable { mutableStateOf(ReturnOutcome.RETURNED) } + var notes by rememberSaveable { mutableStateOf("") } + var penalty by rememberSaveable { mutableStateOf("") } + val parsed = LoanActions.parsePenalty(penalty, outcome) + ModalBottomSheet(onDismissRequest = { if (!saving) onDismiss() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(title, style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.staff_return_outcome), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + FilterChipRow( + options = ReturnOutcome.entries.map { FilterOption(it, stringResource(returnOutcomeLabel(it))) }, + selectedIds = setOf(outcome), + onToggle = { outcome = it; if (!it.allowsPenalty) penalty = "" }, + ) + PinakesTextField( + value = notes, onValueChange = { notes = it }, label = stringResource(R.string.staff_field_notes), singleLine = false, + maxLength = 1000, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp), + ) + if (outcome.allowsPenalty) { + PinakesTextField( + value = penalty, onValueChange = { penalty = it }, label = stringResource(R.string.staff_penalty), + isError = parsed is PenaltyInput.Invalid, errorText = stringResource(R.string.staff_penalty_invalid), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), maxLength = 12, + modifier = Modifier.fillMaxWidth().testTag("penalty-field"), + ) + } + PrimaryButton( + stringResource(R.string.staff_loan_action_return), loading = saving, enabled = parsed !is PenaltyInput.Invalid, + onClick = { onConfirm(outcome, notes, (parsed as? PenaltyInput.Amount)?.value) }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +internal fun returnOutcomeLabel(outcome: ReturnOutcome): Int = when (outcome) { + ReturnOutcome.RETURNED -> R.string.staff_return_outcome_returned + ReturnOutcome.MAINTENANCE -> R.string.staff_return_outcome_maintenance + ReturnOutcome.RESTORATION -> R.string.staff_return_outcome_restoration + ReturnOutcome.LOST -> R.string.staff_return_outcome_lost + ReturnOutcome.DAMAGED -> R.string.staff_return_outcome_damaged +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffCirculationHomeScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffCirculationHomeScreen.kt new file mode 100644 index 0000000..803e405 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffCirculationHomeScreen.kt @@ -0,0 +1,189 @@ +package com.pinakes.app.ui.screens.staff.circulation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.AssignmentReturn +import androidx.compose.material.icons.automirrored.outlined.List +import androidx.compose.material.icons.outlined.Event +import androidx.compose.material.icons.outlined.PostAdd +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.CirculationSummary +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.LoanBucket +import com.pinakes.app.data.repository.StaffCirculationRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.StaffActionRow +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Fraunces +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class CirculationHomeUiState( + val summary: CirculationSummary? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffCirculationHomeViewModel @Inject constructor( + private val circulation: StaffCirculationRepository, +) : ViewModel() { + private val _state = MutableStateFlow(CirculationHomeUiState()) + val state: StateFlow = _state.asStateFlow() + + /** The counters change with every desk action: reload them each time the screen shows. */ + fun reload() { + _state.update { it.copy(loading = it.summary == null && !it.refreshing, error = null) } + viewModelScope.launch { + val res = circulation.summary() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(summary = res.data, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy( + loading = false, refreshing = false, forbidden = res.isForbidden(), + error = if (it.summary == null) res.toStaffMessage(R.string.staff_error_load) else null, + ) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + reload() + } +} + +/** The desk: today's counters (each opens its list) and the quick actions. */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun StaffCirculationHomeScreen( + onNavigateUp: () -> Unit, + onOpenLoans: (String) -> Unit, + onScanCopy: () -> Unit, + onNewLoan: () -> Unit, + onReservations: () -> Unit, +) { + val vm: StaffCirculationHomeViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.circulation, onNavigateUp) + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) vm.reload() } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + + Scaffold(topBar = { PinakesTopBar(title = stringResource(R.string.staff_circulation_desk), onNavigateUp = onNavigateUp) }) { padding -> + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize().padding(padding)) { + val summary = state.summary + when { + state.loading -> LoadingState() + summary == null -> ErrorState(message = state.error?.resolve() ?: stringResource(R.string.staff_error_load), onRetry = vm::refresh) + else -> Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg, vertical = Spacing.sm), + ) { + summary.today?.let { + Text(DateFormat.date(it), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + FlowRow( + Modifier.fillMaxWidth().padding(top = Spacing.sm), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + maxItemsInEachRow = 2, + ) { + val cell = Modifier.weight(1f) + CounterCard(stringResource(R.string.staff_loans_pending), summary.pending, cell, "counter-pending") { onOpenLoans(LoanBucket.PENDING.wire) } + CounterCard(stringResource(R.string.staff_loans_pickup), summary.pickupReady, cell, "counter-pickup") { onOpenLoans(LoanBucket.PICKUP.wire) } + CounterCard(stringResource(R.string.staff_loans_active), summary.active, cell, "counter-active") { onOpenLoans(LoanBucket.ACTIVE.wire) } + CounterCard(stringResource(R.string.staff_loans_overdue), summary.overdue, cell, "counter-overdue", alert = summary.overdue > 0) { + onOpenLoans(LoanBucket.OVERDUE.wire) + } + CounterCard(stringResource(R.string.staff_loans_scheduled), summary.scheduled, cell, "counter-scheduled") { onOpenLoans(LoanBucket.SCHEDULED.wire) } + CounterCard(stringResource(R.string.staff_reservations), summary.reservations, cell, "counter-reservations", onClick = onReservations) + } + Text( + stringResource(R.string.staff_circulation_today, summary.dueToday, summary.returnedToday), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = Spacing.md), + ) + FormSectionTitle(stringResource(R.string.staff_circulation_actions)) + StaffActionRow(Icons.AutoMirrored.Outlined.AssignmentReturn, stringResource(R.string.staff_scan_copy), stringResource(R.string.staff_scan_copy_subtitle), onScanCopy) + StaffActionRow(Icons.Outlined.PostAdd, stringResource(R.string.staff_loan_new), onClick = onNewLoan) + StaffActionRow(Icons.Outlined.Event, stringResource(R.string.staff_reservations), onClick = onReservations) + StaffActionRow(Icons.AutoMirrored.Outlined.List, stringResource(R.string.staff_loans_all), onClick = { onOpenLoans(LoanBucket.ALL.wire) }) + } + } + } + } +} + +/** A counter tile: big Fraunces number over a muted label. */ +@Composable +private fun CounterCard(label: String, value: Int, modifier: Modifier, tag: String, alert: Boolean = false, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + modifier = modifier.testTag(tag), + ) { + Column(Modifier.padding(Spacing.lg)) { + Text( + value.toString(), + style = MaterialTheme.typography.headlineMedium.copy(fontFamily = Fraunces), + color = if (alert) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface, + ) + Text(label, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt new file mode 100644 index 0000000..550886d --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt @@ -0,0 +1,332 @@ +package com.pinakes.app.ui.screens.staff.circulation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.AvailabilityCalendar +import com.pinakes.app.data.model.LoanCreateRequest +import com.pinakes.app.data.model.ReservationWrite +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffCirculationRepository +import com.pinakes.app.data.repository.StaffRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.CopyCodeCodec +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.detail.LoanCalendar +import com.pinakes.app.ui.screens.staff.DatePickerField +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.PickedField +import com.pinakes.app.ui.screens.staff.SearchController +import com.pinakes.app.ui.screens.staff.SearchPickerSheet +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import java.time.LocalDate +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +enum class DeskPicker { USER, BOOK } + +data class LoanCreateUiState( + val reservation: Boolean = false, + val user: PickItem? = null, + val book: PickItem? = null, + val copyCode: String = "", + val start: String? = null, + val end: String? = null, + val handOverNow: Boolean = true, + val notes: String = "", + val calendar: AvailabilityCalendar? = null, + val calendarFailed: Boolean = false, + val picker: DeskPicker? = null, + val saving: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + /** Loan id after a successful create (the screen opens it); -1 for a reservation. */ + val createdLoanId: Int? = null, +) { + val today: String get() = LocalDate.now().toString() + + /** "Hand over now" only makes sense for a loan starting today. */ + val canHandOver: Boolean get() = !reservation && (start == null || start == today) + + val canSubmit: Boolean get() = user != null && book != null && !saving +} + +/** + * New loan (or, with `reservation=true`, a new reservation) for a borrower: pick the user + * (eligibility shown), the book (or arrive with a scanned copy code), the dates on the + * per-user availability calendar, and whether the copy is handed over now. + */ +@HiltViewModel +class StaffLoanCreateViewModel @Inject constructor( + private val circulation: StaffCirculationRepository, + private val staff: StaffRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val _state = MutableStateFlow( + LoanCreateUiState( + reservation = savedState.get(Routes.ARG_RESERVATION_MODE) ?: false, + copyCode = savedState.get(Routes.ARG_COPY_CODE).orEmpty(), + ), + ) + val state: StateFlow = _state.asStateFlow() + + val users = SearchController(viewModelScope, minLength = 2) { q -> + when (val res = circulation.users(q)) { + is ApiResult.Success -> ApiResult.Success(res.data.map { it.toPickItem() }) + is ApiResult.Failure -> res + } + }.also { it.onFailure = ::onSearchFailure } + + val books = SearchController(viewModelScope, minLength = 2) { q -> + when (val res = staff.books(q, cursor = null, limit = 20)) { + is ApiResult.Success -> ApiResult.Success(res.data.items.map { b -> + PickItem(b.id, b.title, listOfNotNull(b.author?.takeIf { it.isNotBlank() }, b.isbn13).joinToString(" · "), + note = null) + }) + is ApiResult.Failure -> res + } + }.also { it.onFailure = ::onSearchFailure } + + init { + val userId = savedState.get(Routes.ARG_USER_ID) ?: 0 + val bookId = savedState.get(Routes.ARG_BOOK_ID) ?: 0 + viewModelScope.launch { + if (userId > 0) (circulation.user(userId) as? ApiResult.Success)?.data?.let { u -> + _state.update { it.copy(user = PickItem(u.id, u.name, u.cardNumber, note = if (!u.eligible) u.eligibilityReason ?: u.status else null)) } + } + if (bookId > 0) (staff.book(bookId) as? ApiResult.Success)?.data?.let { b -> + _state.update { it.copy(book = PickItem(b.id, b.title)) } + } + loadCalendar() + } + } + + private fun onSearchFailure(failure: ApiResult.Failure) { + if (failure.isForbidden()) _state.update { it.copy(forbidden = true) } + } + + fun openPicker(kind: DeskPicker) = _state.update { it.copy(picker = kind) } + + fun closePicker() { + _state.update { it.copy(picker = null) } + users.clear() + books.clear() + } + + fun pick(item: PickItem) { + when (_state.value.picker) { + DeskPicker.USER -> _state.update { it.copy(user = item, error = null) } + DeskPicker.BOOK -> _state.update { it.copy(book = item, error = null, copyCode = "") } + null -> Unit + } + closePicker() + viewModelScope.launch { loadCalendar() } + } + + /** Availability for the chosen book, from this user's point of view (their own holds count as free). */ + private suspend fun loadCalendar() { + val book = _state.value.book ?: return + when (val res = circulation.availability(book.id, _state.value.user?.id)) { + is ApiResult.Success -> _state.update { it.copy(calendar = res.data, calendarFailed = false) } + is ApiResult.Failure -> _state.update { it.copy(calendar = null, calendarFailed = true, forbidden = res.isForbidden()) } + } + } + + fun setCopyCode(value: String) = _state.update { it.copy(copyCode = value.take(CopyCodeCodec.MAX_LENGTH), error = null) } + fun setStart(value: String?) = _state.update { it.copy(start = value, handOverNow = it.handOverNow && (value == null || value == it.today)) } + fun setEnd(value: String?) = _state.update { it.copy(end = value) } + fun setHandOver(value: Boolean) = _state.update { it.copy(handOverNow = value && it.canHandOver) } + fun setNotes(value: String) = _state.update { it.copy(notes = value) } + + fun submit() { + val s = _state.value + val user = s.user ?: return + val book = s.book ?: return + if (s.saving) return + if (s.start != null && s.end != null && s.end < s.start) { + _state.update { it.copy(error = StaffMessage(R.string.staff_loan_dates_order)) } + return + } + _state.update { it.copy(saving = true, error = null) } + viewModelScope.launch { + if (s.reservation) { + val res = circulation.createReservation( + ReservationWrite(userId = user.id, bookId = book.id, requestedFrom = s.start, requestedTo = s.end), + ) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(saving = false, createdLoanId = -1) + is ApiResult.Failure -> it.copy(saving = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_save)) + } + } + return@launch + } + val res = circulation.createLoan( + LoanCreateRequest( + userId = user.id, bookId = book.id, + copyCode = s.copyCode.takeIf { CopyCodeCodec.isValid(it) }, + loanedAt = s.start, dueAt = s.end, notes = s.notes, + immediate = s.handOverNow && s.canHandOver, + ), + ) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(saving = false, createdLoanId = res.data.id) + is ApiResult.Failure -> it.copy(saving = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffLoanCreateScreen(onNavigateUp: () -> Unit, onCreated: (loanId: Int) -> Unit, onReservationCreated: () -> Unit) { + val vm: StaffLoanCreateViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.circulation, onNavigateUp) + LaunchedEffect(state.createdLoanId) { + when (val id = state.createdLoanId) { + null -> Unit + -1 -> onReservationCreated() + else -> onCreated(id) + } + } + + Scaffold( + topBar = { + PinakesTopBar( + title = stringResource(if (state.reservation) R.string.staff_reservation_new else R.string.staff_loan_new), + onNavigateUp = onNavigateUp, + ) + }, + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg, vertical = Spacing.sm), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + state.error?.let { InlineErrorBanner(message = it.resolve()) } + FormSectionTitle(stringResource(R.string.staff_pick_user)) + PickedField( + stringResource(R.string.staff_pick_user), state.user?.let { u -> listOfNotNull(u.title, u.subtitle).joinToString(" · ") }, + stringResource(R.string.staff_pick_user_placeholder), onClick = { vm.openPicker(DeskPicker.USER) }, + ) + state.user?.note?.let { + Text(stringResource(R.string.staff_user_not_eligible, it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + FormSectionTitle(stringResource(R.string.staff_pick_book)) + PickedField(stringResource(R.string.staff_pick_book), state.book?.title, stringResource(R.string.staff_pick_book_placeholder), + onClick = { vm.openPicker(DeskPicker.BOOK) }) + if (!state.reservation) { + PinakesTextField( + value = state.copyCode, onValueChange = vm::setCopyCode, label = stringResource(R.string.staff_copy_code), + supportingText = stringResource(R.string.staff_copy_code_hint), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), modifier = Modifier.fillMaxWidth().testTag("copy-code"), + ) + } + FormSectionTitle(stringResource(R.string.staff_loan_dates)) + DatePickerField(stringResource(if (state.reservation) R.string.staff_reservation_from else R.string.staff_loan_loaned), state.start, vm::setStart, allowClear = true) + DatePickerField(stringResource(if (state.reservation) R.string.staff_reservation_to else R.string.staff_loan_due), state.end, vm::setEnd, allowClear = true) + Text( + stringResource(if (state.reservation) R.string.staff_reservation_dates_hint else R.string.staff_loan_dates_hint), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val calendar = state.calendar + if (state.book != null && (calendar != null || state.calendarFailed)) { + LoanCalendar( + calendar = calendar ?: AvailabilityCalendar(), + selected = state.start, + onSelect = { vm.setStart(it) }, + anyFutureDay = state.calendarFailed, + modifier = Modifier.testTag("loan-calendar"), + ) + } + if (!state.reservation) { + SwitchRow( + stringResource(R.string.staff_hand_over_now), state.handOverNow && state.canHandOver, vm::setHandOver, + subtitle = stringResource(if (state.canHandOver) R.string.staff_hand_over_now_hint else R.string.staff_hand_over_future), + enabled = state.canHandOver, + ) + PinakesTextField( + value = state.notes, onValueChange = vm::setNotes, label = stringResource(R.string.staff_field_notes), singleLine = false, + maxLength = 1000, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp), + ) + } + state.start?.let { + Text(stringResource(R.string.staff_loan_summary_start, DateFormat.date(it)), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + PrimaryButton( + stringResource(if (state.reservation) R.string.staff_reservation_create else R.string.staff_loan_create), + onClick = vm::submit, loading = state.saving, enabled = state.canSubmit, modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(Spacing.xxl)) + } + } + + when (state.picker) { + DeskPicker.USER -> { + val s by vm.users.state.collectAsStateWithLifecycle() + SearchPickerSheet( + title = stringResource(R.string.staff_pick_user), placeholder = stringResource(R.string.staff_search_users), state = s, + onQuery = vm.users::onQuery, onPick = vm::pick, onDismiss = vm::closePicker, hint = stringResource(R.string.staff_search_min_chars), + ) + } + DeskPicker.BOOK -> { + val s by vm.books.state.collectAsStateWithLifecycle() + SearchPickerSheet( + title = stringResource(R.string.staff_pick_book), placeholder = stringResource(R.string.staff_books_search), state = s, + onQuery = vm.books::onQuery, onPick = vm::pick, onDismiss = vm::closePicker, hint = stringResource(R.string.staff_search_min_chars), + ) + } + null -> Unit + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt new file mode 100644 index 0000000..426dd8b --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt @@ -0,0 +1,449 @@ +package com.pinakes.app.ui.screens.staff.circulation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.LoanEditRequest +import com.pinakes.app.data.model.StaffLoan +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffCirculationRepository +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.LoanAction +import com.pinakes.app.data.staff.LoanActionUi +import com.pinakes.app.data.staff.LoanActions +import com.pinakes.app.data.staff.ReturnOutcome +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.AvailabilityChip +import com.pinakes.app.ui.components.BookCover +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.DatePickerField +import com.pinakes.app.ui.screens.staff.ExportFileDialog +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.PickedField +import com.pinakes.app.ui.screens.staff.ReasonDialog +import com.pinakes.app.ui.screens.staff.SearchController +import com.pinakes.app.ui.screens.staff.SearchPickerSheet +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** The edit sheet's draft: only fields that differ from the loan are sent. */ +data class LoanEditDraft( + val userId: Int, + val userName: String, + val loanedAt: String?, + val dueAt: String?, +) + +data class StaffLoanDetailUiState( + val loan: StaffLoan? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + val busy: LoanAction? = null, + val pending: LoanAction? = null, + val edit: LoanEditDraft? = null, + val userPicker: Boolean = false, + val receipt: File? = null, + val viewReceipt: Boolean = false, + val message: StaffMessage? = null, +) + +@HiltViewModel +class StaffLoanDetailViewModel @Inject constructor( + private val circulation: StaffCirculationRepository, + private val files: StaffFilesRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val loanId: Int = savedState.get(Routes.ARG_LOAN_ID) ?: 0 + private val _state = MutableStateFlow(StaffLoanDetailUiState()) + val state: StateFlow = _state.asStateFlow() + + val users = SearchController(viewModelScope, minLength = 2) { q -> + when (val res = circulation.users(q)) { + is ApiResult.Success -> ApiResult.Success(res.data.map { u -> u.toPickItem() }) + is ApiResult.Failure -> res + } + }.also { c -> c.onFailure = { f -> if (f.isForbidden()) _state.update { it.copy(forbidden = true) } } } + + init { load() } + + fun load() { + _state.update { it.copy(loading = it.loan == null && !it.refreshing, error = null) } + viewModelScope.launch { + val res = circulation.loan(loanId) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(loan = res.data, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy( + loading = false, refreshing = false, forbidden = res.isForbidden(), + error = if (it.loan == null) res.toStaffMessage(R.string.staff_error_load) else null, + message = if (it.loan != null) res.toStaffMessage(R.string.staff_error_load) else null, + ) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + load() + } + + /** A tap on an action button: confirm/reason/sheet first, or run it straight away. */ + fun onAction(action: LoanAction) { + val loan = _state.value.loan ?: return + when (LoanActions.ui(action)) { + LoanActionUi.IMMEDIATE -> run(action) { + if (action == LoanAction.RECALL) circulation.recall(loan.id).sent() else circulation.emailReceipt(loan.id).sent() + } + LoanActionUi.CONFIRM, LoanActionUi.REASON, LoanActionUi.RETURN_SHEET -> _state.update { it.copy(pending = action) } + LoanActionUi.EDIT_SHEET -> _state.update { + it.copy(edit = LoanEditDraft(loan.user.id, loan.user.name, loan.loanedAt?.take(10), loan.dueAt?.take(10))) + } + LoanActionUi.DOWNLOAD -> downloadReceipt() + } + } + + fun dismissPending() = _state.update { it.copy(pending = null) } + + fun confirmPending(reason: String? = null) { + val action = _state.value.pending ?: return + val id = _state.value.loan?.id ?: return + _state.update { it.copy(pending = null) } + when (action) { + LoanAction.APPROVE -> mutate(action, R.string.staff_loan_approved) { circulation.approve(id) } + LoanAction.REJECT -> mutate(action, R.string.staff_loan_rejected) { circulation.reject(id, reason) } + LoanAction.CONFIRM_PICKUP -> mutate(action, R.string.staff_loan_picked_up) { circulation.pickup(id) } + LoanAction.CANCEL_PICKUP -> mutate(action, R.string.staff_loan_pickup_cancelled) { circulation.cancelPickup(id, reason) } + LoanAction.RENEW -> mutate(action, R.string.staff_loan_renewed) { circulation.renew(id) } + else -> Unit + } + } + + fun returnLoan(outcome: ReturnOutcome, notes: String, penalty: Double?) { + val id = _state.value.loan?.id ?: return + _state.update { it.copy(pending = null) } + mutate(LoanAction.RETURN, R.string.staff_loan_returned, retryOnConcurrent = true) { + circulation.returnLoan(id, outcome.wire, notes, penalty) + } + } + + fun editUser(item: PickItem) { + _state.update { s -> s.copy(edit = s.edit?.copy(userId = item.id, userName = item.title), userPicker = false) } + users.clear() + } + + fun openUserPicker() = _state.update { it.copy(userPicker = true) } + fun closeUserPicker() { _state.update { it.copy(userPicker = false) }; users.clear() } + fun editDates(loanedAt: String?, dueAt: String?) = _state.update { s -> s.copy(edit = s.edit?.copy(loanedAt = loanedAt, dueAt = dueAt)) } + fun closeEdit() = _state.update { it.copy(edit = null) } + + fun saveEdit() { + val loan = _state.value.loan ?: return + val draft = _state.value.edit ?: return + // The server merges over the row; send only what changed (never blank a date). + val request = LoanEditRequest( + userId = draft.userId.takeIf { it != loan.user.id }, + loanedAt = draft.loanedAt?.takeIf { it != loan.loanedAt?.take(10) }, + dueAt = draft.dueAt?.takeIf { it != loan.dueAt?.take(10) }, + ) + if (request.userId == null && request.loanedAt == null && request.dueAt == null) { + _state.update { it.copy(edit = null) } + return + } + mutate(LoanAction.EDIT, R.string.staff_loan_updated, retryOnConcurrent = true, closeEdit = true) { circulation.updateLoan(loan.id, request) } + } + + private fun mutate( + action: LoanAction, + success: Int, + retryOnConcurrent: Boolean = false, + closeEdit: Boolean = false, + call: suspend () -> ApiResult, + ) { + if (_state.value.busy != null) return + _state.update { it.copy(busy = action) } + viewModelScope.launch { + var res = call() + // The core asks for a retry when two desks touched the copy at once: retry once. + if (retryOnConcurrent && res is ApiResult.Failure && res.code == "concurrent_retry") res = call() + val outcome = res + _state.update { + when (outcome) { + is ApiResult.Success -> it.copy( + busy = null, loan = outcome.data, message = StaffMessage(success), + edit = if (closeEdit) null else it.edit, + ) + is ApiResult.Failure -> it.copy(busy = null, forbidden = outcome.isForbidden(), message = outcome.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + + private fun run(action: LoanAction, call: suspend () -> Pair?) { + if (_state.value.busy != null) return + _state.update { it.copy(busy = action) } + viewModelScope.launch { + val result = call() + _state.update { + it.copy( + busy = null, + message = when { + result == null -> StaffMessage(R.string.staff_error_save) + result.first -> StaffMessage(if (action == LoanAction.RECALL) R.string.staff_recall_sent else R.string.staff_receipt_emailed) + else -> result.second?.takeIf { m -> m.isNotBlank() }?.let { m -> StaffMessage(text = m) } ?: StaffMessage(R.string.staff_mail_not_sent) + }, + ) + } + if (action == LoanAction.RECALL && result?.first == true) load() + } + } + + private fun ApiResult.sent(): Pair? = when (this) { + is ApiResult.Success -> data.sent to (data.message ?: meta?.message) + is ApiResult.Failure -> { + if (isForbidden()) _state.update { it.copy(forbidden = true) } + null + } + } + + private fun downloadReceipt() { + val loan = _state.value.loan ?: return + if (_state.value.busy != null) return + _state.update { it.copy(busy = LoanAction.RECEIPT) } + viewModelScope.launch { + val res = files.download("staff/loans/${loan.id}/receipt.pdf", "prestito_${loan.id}.pdf") + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = null, receipt = res.data, viewReceipt = true) + is ApiResult.Failure -> it.copy(busy = null, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_download_failed)) + } + } + } + } + + fun closeReceipt() = _state.update { it.copy(viewReceipt = false) } + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +internal fun com.pinakes.app.data.model.StaffUser.toPickItem(): PickItem = PickItem( + id = id, + title = name, + subtitle = listOfNotNull(cardNumber?.takeIf { it.isNotBlank() }, email?.takeIf { it.isNotBlank() }).joinToString(" · "), + note = if (!eligible) eligibilityReason?.takeIf { it.isNotBlank() } ?: status else null, +) + +internal fun loanActionLabel(action: LoanAction): Int = when (action) { + LoanAction.APPROVE -> R.string.staff_loan_action_approve + LoanAction.REJECT -> R.string.staff_loan_action_reject + LoanAction.CONFIRM_PICKUP -> R.string.staff_loan_action_confirm_pickup + LoanAction.CANCEL_PICKUP -> R.string.staff_loan_action_cancel_pickup + LoanAction.RETURN -> R.string.staff_loan_action_return + LoanAction.RENEW -> R.string.staff_loan_action_renew + LoanAction.EDIT -> R.string.staff_loan_action_edit + LoanAction.RECALL -> R.string.staff_loan_action_recall + LoanAction.RECEIPT -> R.string.staff_loan_action_receipt + LoanAction.EMAIL_RECEIPT -> R.string.staff_loan_action_email_receipt +} + +/** One loan at the desk: who, which copy, the dates, and the actions the server offers now. */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun StaffLoanDetailScreen(onNavigateUp: () -> Unit, onOpenUser: (Int) -> Unit, onOpenBook: (Int) -> Unit) { + val vm: StaffLoanDetailViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.circulation, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_loan_title), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize().padding(padding)) { + val loan = state.loan + when { + state.loading -> LoadingState() + loan == null -> ErrorState(message = state.error?.resolve() ?: stringResource(R.string.staff_error_load), onRetry = vm::refresh) + else -> Column( + Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg, vertical = Spacing.sm), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.lg)) { + BookCover(loan.book.title, loan.book.coverUrl, Modifier.width(88.dp).aspectRatio(2f / 3f), compact = true) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text(loan.book.title, style = MaterialTheme.typography.titleLarge, maxLines = 3, overflow = TextOverflow.Ellipsis) + val (status, label) = loanChip(loan) + AvailabilityChip(status = status, label = label) + loanDateLine(loan)?.let { Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } + } + } + val actions = LoanActions.of(loan) + if (actions.isNotEmpty()) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + actions.forEachIndexed { index, action -> + val label = stringResource(loanActionLabel(action)) + if (index == 0) { + PrimaryButton(label, onClick = { vm.onAction(action) }, loading = state.busy == action, enabled = state.busy == null) + } else { + SecondaryButton(label, onClick = { vm.onAction(action) }, enabled = state.busy == null) + } + } + } + } + FormSectionTitle(stringResource(R.string.staff_loan_borrower)) + InfoCard(Modifier.testTag("loan-user")) { + Text(loan.user.name, style = MaterialTheme.typography.titleMedium) + InfoLine(stringResource(R.string.staff_user_card), loan.user.cardNumber) + InfoLine(stringResource(R.string.staff_field_email), loan.user.email) + Spacer(Modifier.height(Spacing.xs)) + SecondaryButton(stringResource(R.string.staff_user_open), onClick = { onOpenUser(loan.user.id) }, enabled = loan.user.id > 0) + } + FormSectionTitle(stringResource(R.string.staff_loan_details)) + InfoCard { + InfoLine(stringResource(R.string.staff_copy_inventory), loan.copy?.inventoryNumber) + InfoLine(stringResource(R.string.staff_loan_requested), loan.requestedAt?.let(DateFormat::date)) + InfoLine(stringResource(R.string.staff_loan_loaned), loan.loanedAt?.let(DateFormat::date)) + InfoLine(stringResource(R.string.staff_loan_due), loan.dueAt?.let(DateFormat::date)) + InfoLine(stringResource(R.string.staff_loan_pickup_deadline), loan.pickupDeadline?.let(DateFormat::date)) + InfoLine(stringResource(R.string.staff_loan_returned_at), loan.returnedAt?.let(DateFormat::date)) + InfoLine(stringResource(R.string.staff_loan_renewals), stringResource(R.string.staff_loan_renewals_value, loan.renewals, loan.maxRenewals)) + InfoLine(stringResource(R.string.staff_penalty), loan.penalty?.let { "%.2f".format(java.util.Locale.ROOT, it) }) + InfoLine(stringResource(R.string.staff_field_notes), loan.notes) + InfoLine(stringResource(R.string.staff_loan_recalls), loan.recallCount.takeIf { it > 0 }?.let { n -> + listOfNotNull(n.toString(), loan.lastRecallAt?.let(DateFormat::date)).joinToString(" · ") + }) + InfoLine(stringResource(R.string.staff_loan_processed_by), loan.processedBy?.name) + } + SecondaryButton(stringResource(R.string.staff_open_book), onClick = { onOpenBook(loan.book.id) }, enabled = loan.book.id > 0, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(Spacing.xxl)) + } + } + } + } + + when (val pending = state.pending) { + LoanAction.REJECT, LoanAction.CANCEL_PICKUP -> ReasonDialog( + title = stringResource(loanActionLabel(pending)), + body = stringResource(if (pending == LoanAction.REJECT) R.string.staff_loan_reject_body else R.string.staff_loan_cancel_pickup_body), + confirmLabel = stringResource(loanActionLabel(pending)), + onConfirm = { vm.confirmPending(it) }, + onDismiss = vm::dismissPending, + ) + LoanAction.APPROVE, LoanAction.CONFIRM_PICKUP, LoanAction.RENEW -> ConfirmDialog( + title = stringResource(loanActionLabel(pending)), + body = stringResource( + when (pending) { + LoanAction.APPROVE -> R.string.staff_loan_approve_body + LoanAction.CONFIRM_PICKUP -> R.string.staff_loan_pickup_body + else -> R.string.staff_loan_renew_body + }, + ), + confirmLabel = stringResource(loanActionLabel(pending)), + onConfirm = { vm.confirmPending() }, + onDismiss = vm::dismissPending, + ) + LoanAction.RETURN -> ReturnSheet( + title = stringResource(R.string.staff_loan_action_return), + saving = state.busy == LoanAction.RETURN, + onConfirm = vm::returnLoan, + onDismiss = vm::dismissPending, + ) + else -> Unit + } + state.edit?.let { draft -> EditLoanSheet(draft, state, vm) } + if (state.userPicker) { + val users by vm.users.state.collectAsStateWithLifecycle() + SearchPickerSheet( + title = stringResource(R.string.staff_pick_user), placeholder = stringResource(R.string.staff_search_users), + state = users, onQuery = vm.users::onQuery, onPick = vm::editUser, onDismiss = vm::closeUserPicker, + hint = stringResource(R.string.staff_search_min_chars), + ) + } + val receipt = state.receipt + if (state.viewReceipt && receipt != null) { + ExportFileDialog(receipt, "application/pdf", stringResource(R.string.staff_receipt_ready), onDismiss = vm::closeReceipt) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun EditLoanSheet(draft: LoanEditDraft, state: StaffLoanDetailUiState, vm: StaffLoanDetailViewModel) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { if (state.busy == null) vm.closeEdit() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(R.string.staff_loan_action_edit), style = MaterialTheme.typography.headlineSmall) + PickedField(stringResource(R.string.staff_pick_user), draft.userName, stringResource(R.string.staff_pick_user), onClick = vm::openUserPicker) + DatePickerField(stringResource(R.string.staff_loan_loaned), draft.loanedAt, { vm.editDates(it, draft.dueAt) }) + DatePickerField(stringResource(R.string.staff_loan_due), draft.dueAt, { vm.editDates(draft.loanedAt, it) }) + PrimaryButton(stringResource(R.string.action_save), onClick = vm::saveEdit, loading = state.busy == LoanAction.EDIT, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(Spacing.lg)) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoansScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoansScreen.kt new file mode 100644 index 0000000..dab05ee --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoansScreen.kt @@ -0,0 +1,352 @@ +package com.pinakes.app.ui.screens.staff.circulation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Checklist +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.SwapHoriz +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.StaffLoan +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.LoanBucket +import com.pinakes.app.data.repository.StaffCirculationRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.LoanActions +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SearchField +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class StaffLoansUiState( + val bucket: LoanBucket = LoanBucket.ALL, + val query: String = "", + val items: List = emptyList(), + val nextCursor: String? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val loadingMore: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + val selecting: Boolean = false, + val selected: Set = emptySet(), + val bulkBusy: Boolean = false, + val message: StaffMessage? = null, +) { + /** Bulk extend and recall apply to loans that are out. */ + val canSelect: Boolean get() = bucket == LoanBucket.ACTIVE || bucket == LoanBucket.OVERDUE +} + +@HiltViewModel +class StaffLoansViewModel @Inject constructor( + private val circulation: StaffCirculationRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val _state = MutableStateFlow(StaffLoansUiState(bucket = LoanBucket.fromWire(savedState.get(Routes.ARG_BUCKET)))) + val state: StateFlow = _state.asStateFlow() + private var searchJob: Job? = null + private var generation = 0 + + fun reload() { + val gen = ++generation + _state.update { it.copy(loading = it.items.isEmpty() && !it.refreshing, error = null) } + viewModelScope.launch { + val s = _state.value + val res = circulation.loans(s.bucket, s.query) + if (gen != generation) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + items = res.data.items, nextCursor = res.data.nextCursor, loading = false, refreshing = false, + selected = it.selected.intersect(res.data.items.map(StaffLoan::id).toSet()), + ) + is ApiResult.Failure -> it.copy( + loading = false, refreshing = false, forbidden = res.isForbidden(), + error = res.toStaffMessage(R.string.staff_error_load), + ) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + reload() + } + + fun loadMore() { + val s = _state.value + val cursor = s.nextCursor ?: return + if (s.loadingMore || s.loading) return + val gen = generation + _state.update { it.copy(loadingMore = true) } + viewModelScope.launch { + val res = circulation.loans(s.bucket, s.query, cursor) + if (gen != generation) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy(items = (it.items + res.data.items).distinctBy(StaffLoan::id), nextCursor = res.data.nextCursor, loadingMore = false) + is ApiResult.Failure -> it.copy(loadingMore = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun selectBucket(bucket: LoanBucket) { + if (bucket == _state.value.bucket) return + _state.update { it.copy(bucket = bucket, items = emptyList(), nextCursor = null, selecting = false, selected = emptySet()) } + reload() + } + + fun onQueryChange(value: String) { + _state.update { it.copy(query = value) } + searchJob?.cancel() + searchJob = viewModelScope.launch { delay(350); reload() } + } + + fun toggleSelecting() = _state.update { it.copy(selecting = !it.selecting && it.canSelect, selected = emptySet()) } + + fun toggle(loan: StaffLoan) { + if (!LoanActions.isBulkSelectable(loan)) return + _state.update { s -> s.copy(selected = if (loan.id in s.selected) s.selected - loan.id else s.selected + loan.id) } + } + + fun bulkExtend(days: Int) { + val ids = _state.value.selected.toList() + if (ids.isEmpty() || _state.value.bulkBusy) return + _state.update { it.copy(bulkBusy = true) } + viewModelScope.launch { + val res = circulation.bulkExtend(ids, days) + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + bulkBusy = false, selecting = false, selected = emptySet(), + message = StaffMessage(R.string.staff_bulk_extended, args = listOf(res.data.extended, res.data.days)), + ) + is ApiResult.Failure -> it.copy(bulkBusy = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + if (res is ApiResult.Success) reload() + } + } + + fun bulkRecall() { + val ids = _state.value.selected.toList() + if (ids.isEmpty() || _state.value.bulkBusy) return + _state.update { it.copy(bulkBusy = true) } + viewModelScope.launch { + val res = circulation.bulkRecall(ids) + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + bulkBusy = false, selecting = false, selected = emptySet(), + message = StaffMessage(R.string.staff_bulk_recalled, args = listOf(res.data.sent, res.data.skipped)), + ) + is ApiResult.Failure -> it.copy(bulkBusy = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + if (res is ApiResult.Success) reload() + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +private val BUCKETS = listOf(LoanBucket.PENDING, LoanBucket.PICKUP, LoanBucket.SCHEDULED, LoanBucket.ACTIVE, LoanBucket.OVERDUE, LoanBucket.HISTORY, LoanBucket.ALL) + +internal fun bucketLabel(bucket: LoanBucket): Int = when (bucket) { + LoanBucket.PENDING -> R.string.staff_loans_pending + LoanBucket.PICKUP -> R.string.staff_loans_pickup + LoanBucket.SCHEDULED -> R.string.staff_loans_scheduled + LoanBucket.ACTIVE -> R.string.staff_loans_active + LoanBucket.OVERDUE -> R.string.staff_loans_overdue + LoanBucket.HISTORY -> R.string.staff_loans_history + LoanBucket.ALL -> R.string.staff_loans_all +} + +/** Loans by desk bucket, with search and, on Active/Overdue, multi-select for bulk extend/recall. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffLoansScreen(onNavigateUp: () -> Unit, onOpenLoan: (Int) -> Unit) { + val vm: StaffLoansViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + var extendOpen by rememberSaveable { mutableStateOf(false) } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.circulation, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) vm.reload() } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + + Scaffold( + topBar = { + PinakesTopBar( + title = stringResource(R.string.staff_loans_title), + onNavigateUp = onNavigateUp, + actions = { + if (state.canSelect) { + IconButton(onClick = vm::toggleSelecting) { + Icon( + if (state.selecting) Icons.Outlined.Close else Icons.Outlined.Checklist, + contentDescription = stringResource(if (state.selecting) R.string.staff_select_done else R.string.staff_select_many), + ) + } + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbar) }, + bottomBar = { + if (state.selecting) { + Surface(color = MaterialTheme.colorScheme.surfaceContainer) { + Row( + Modifier.fillMaxWidth().padding(horizontal = Spacing.lg, vertical = Spacing.sm), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(stringResource(R.string.staff_selected_count, state.selected.size), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + SecondaryButton(stringResource(R.string.staff_loan_action_recall), onClick = vm::bulkRecall, enabled = state.selected.isNotEmpty() && !state.bulkBusy) + PrimaryButton(stringResource(R.string.staff_bulk_extend), onClick = { extendOpen = true }, enabled = state.selected.isNotEmpty(), loading = state.bulkBusy) + } + } + } + }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + val selectedIndex = BUCKETS.indexOf(state.bucket).coerceAtLeast(0) + PrimaryScrollableTabRow(selectedTabIndex = selectedIndex, edgePadding = Spacing.lg) { + BUCKETS.forEach { bucket -> + Tab(selected = bucket == state.bucket, onClick = { vm.selectBucket(bucket) }, text = { Text(stringResource(bucketLabel(bucket))) }) + } + } + SearchField( + query = state.query, onQueryChange = vm::onQueryChange, placeholder = stringResource(R.string.staff_loans_search), + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg, vertical = Spacing.sm), + ) + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize()) { + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = state.items.isEmpty(), + emptyTitle = stringResource(R.string.staff_loans_empty), emptyIcon = Icons.Outlined.SwapHoriz, onRetry = vm::refresh, + ) { + val listState = rememberLazyListState() + val nearEnd by remember { derivedStateOf { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index?.let { it >= state.items.size - 5 } == true } } + LaunchedEffect(nearEnd, state.nextCursor) { if (nearEnd) vm.loadMore() } + LazyColumn( + Modifier.fillMaxSize(), state = listState, + contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp), + ) { + items(state.items, key = { it.id }) { loan -> + val selectable = state.selecting && LoanActions.isBulkSelectable(loan) + StaffLoanRow( + loan = loan, + selected = if (selectable) loan.id in state.selected else null, + onClick = { if (state.selecting) vm.toggle(loan) else onOpenLoan(loan.id) }, + ) + } + } + } + } + } + } + + if (extendOpen) { + ExtendDaysDialog( + count = state.selected.size, + onConfirm = { days -> extendOpen = false; vm.bulkExtend(days) }, + onDismiss = { extendOpen = false }, + ) + } +} + +@Composable +private fun ExtendDaysDialog(count: Int, onConfirm: (Int) -> Unit, onDismiss: () -> Unit) { + var days by rememberSaveable { mutableStateOf("14") } + val value = days.toIntOrNull()?.takeIf { it in 1..365 } + AlertDialog( + onDismissRequest = onDismiss, + shape = MaterialTheme.shapes.large, + title = { Text(stringResource(R.string.staff_bulk_extend), style = MaterialTheme.typography.titleMedium) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(stringResource(R.string.staff_bulk_extend_body, count), style = MaterialTheme.typography.bodyMedium) + PinakesTextField( + value = days, onValueChange = { days = it.filter(Char::isDigit) }, label = stringResource(R.string.staff_bulk_extend_days), + isError = value == null, errorText = stringResource(R.string.staff_bulk_extend_days_invalid), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), maxLength = 3, modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { PrimaryButton(stringResource(R.string.staff_bulk_extend), onClick = { value?.let(onConfirm) }, enabled = value != null) }, + dismissButton = { PinakesTextButton(stringResource(R.string.action_cancel), onClick = onDismiss) }, + ) +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffReservationsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffReservationsScreen.kt new file mode 100644 index 0000000..50ba62b --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffReservationsScreen.kt @@ -0,0 +1,302 @@ +package com.pinakes.app.ui.screens.staff.circulation + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Event +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.ReservationWrite +import com.pinakes.app.data.model.StaffReservation +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffCirculationRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SearchField +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.DatePickerField +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.ReasonDialog +import com.pinakes.app.ui.screens.staff.StaffFab +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** The reservation status filters of `GET /staff/reservations`. */ +enum class ReservationFilter(val wire: String, val label: Int) { + ACTIVE("attiva", R.string.staff_reservations_active), + COMPLETED("completata", R.string.staff_reservations_completed), + CANCELLED("annullata", R.string.staff_reservations_cancelled), + ALL("all", R.string.staff_reservations_all), +} + +/** The edit sheet's draft; unchanged dates are not sent. */ +data class ReservationDraft(val id: Int, val from: String?, val to: String?, val expires: String?) + +data class StaffReservationsUiState( + val filter: ReservationFilter = ReservationFilter.ACTIVE, + val query: String = "", + val items: List = emptyList(), + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + val selected: StaffReservation? = null, + val cancelling: StaffReservation? = null, + val editing: ReservationDraft? = null, + val busy: Boolean = false, + val message: StaffMessage? = null, +) { + /** Book → its queue, in the server's order (book, then queue position). */ + val groups: List>> get() = items.groupBy { it.book.id }.toList() +} + +@HiltViewModel +class StaffReservationsViewModel @Inject constructor(private val circulation: StaffCirculationRepository) : ViewModel() { + private val _state = MutableStateFlow(StaffReservationsUiState()) + val state: StateFlow = _state.asStateFlow() + private var searchJob: Job? = null + private var generation = 0 + + fun reload() { + val gen = ++generation + _state.update { it.copy(loading = it.items.isEmpty() && !it.refreshing, error = null) } + viewModelScope.launch { + val s = _state.value + val res = circulation.reservations(s.filter.wire, s.query) + if (gen != generation) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy(items = res.data.items, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + reload() + } + + fun setFilter(filter: ReservationFilter) { + if (filter == _state.value.filter) return + _state.update { it.copy(filter = filter, items = emptyList()) } + reload() + } + + fun onQueryChange(value: String) { + _state.update { it.copy(query = value) } + searchJob?.cancel() + searchJob = viewModelScope.launch { delay(350); reload() } + } + + fun select(r: StaffReservation?) = _state.update { it.copy(selected = r) } + + fun askCancel() = _state.update { it.copy(cancelling = it.selected, selected = null) } + fun dismissCancel() = _state.update { it.copy(cancelling = null) } + + fun confirmCancel(reason: String) { + val r = _state.value.cancelling ?: return + _state.update { it.copy(cancelling = null) } + mutate(R.string.staff_reservation_cancelled) { circulation.cancelReservation(r.id, reason) } + } + + fun reactivate() { + val r = _state.value.selected ?: return + _state.update { it.copy(selected = null) } + mutate(R.string.staff_reservation_reactivated) { circulation.updateReservation(r.id, ReservationWrite(status = "attiva")) } + } + + fun startEdit() = _state.update { s -> + val r = s.selected ?: return@update s + s.copy(selected = null, editing = ReservationDraft(r.id, r.requestedFrom?.take(10), r.requestedTo?.take(10), r.expiresAt?.take(10))) + } + + fun editDraft(transform: (ReservationDraft) -> ReservationDraft) = _state.update { s -> s.copy(editing = s.editing?.let(transform)) } + fun closeEdit() = _state.update { it.copy(editing = null) } + + fun saveEdit() { + val draft = _state.value.editing ?: return + val original = _state.value.items.firstOrNull { it.id == draft.id } ?: return + // The server merges over the row: only changed dates go out. + val body = ReservationWrite( + requestedFrom = draft.from?.takeIf { it != original.requestedFrom?.take(10) }, + requestedTo = draft.to?.takeIf { it != original.requestedTo?.take(10) }, + expiresAt = draft.expires?.takeIf { it != original.expiresAt?.take(10) }, + ) + if (body == ReservationWrite()) { + closeEdit() + return + } + mutate(R.string.staff_reservation_updated, closeEdit = true) { circulation.updateReservation(draft.id, body) } + } + + private fun mutate(success: Int, closeEdit: Boolean = false, call: suspend () -> ApiResult) { + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = call() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, message = StaffMessage(success), editing = if (closeEdit) null else it.editing) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + if (res is ApiResult.Success) reload() + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** Reservation queues grouped by title, with edit, cancel and reactivate. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffReservationsScreen( + onNavigateUp: () -> Unit, + onNewReservation: () -> Unit, + onOpenUser: (Int) -> Unit, + onNewLoan: (userId: Int, bookId: Int) -> Unit, +) { + val vm: StaffReservationsViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.circulation, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) vm.reload() } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_reservations), onNavigateUp = onNavigateUp) }, + floatingActionButton = { StaffFab(Icons.Outlined.Add, stringResource(R.string.staff_reservation_new), onNewReservation) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + PrimaryScrollableTabRow(selectedTabIndex = state.filter.ordinal, edgePadding = Spacing.lg) { + ReservationFilter.entries.forEach { f -> + Tab(selected = f == state.filter, onClick = { vm.setFilter(f) }, text = { Text(stringResource(f.label)) }) + } + } + SearchField( + query = state.query, onQueryChange = vm::onQueryChange, placeholder = stringResource(R.string.staff_reservations_search), + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg, vertical = Spacing.sm), + ) + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize()) { + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = state.items.isEmpty(), + emptyTitle = stringResource(R.string.staff_reservations_empty), emptyIcon = Icons.Outlined.Event, onRetry = vm::refresh, + ) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp)) { + state.groups.forEach { (bookId, queue) -> + item(key = "book-$bookId") { + Text( + queue.first().book.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = Spacing.md, bottom = Spacing.xs), + ) + } + items(queue, key = { it.id }) { r -> StaffReservationRow(r, onClick = { vm.select(r) }, showBook = false) } + } + } + } + } + } + } + + state.selected?.let { r -> + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { vm.select(null) }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(r.book.title, style = MaterialTheme.typography.headlineSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text(r.user.name, style = MaterialTheme.typography.bodyLarge) + if (r.status == "attiva") { + PrimaryButton(stringResource(R.string.staff_loan_new), onClick = { vm.select(null); onNewLoan(r.user.id, r.book.id) }, modifier = Modifier.fillMaxWidth()) + SecondaryButton(stringResource(R.string.staff_reservation_edit), onClick = vm::startEdit, modifier = Modifier.fillMaxWidth()) + SecondaryButton(stringResource(R.string.staff_reservation_cancel), onClick = vm::askCancel, modifier = Modifier.fillMaxWidth()) + } + if (r.status == "annullata") { + PrimaryButton(stringResource(R.string.staff_reservation_reactivate), onClick = vm::reactivate, loading = state.busy, modifier = Modifier.fillMaxWidth()) + } + SecondaryButton(stringResource(R.string.staff_user_open), onClick = { vm.select(null); onOpenUser(r.user.id) }, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(Spacing.lg)) + } + } + } + state.cancelling?.let { + ReasonDialog( + title = stringResource(R.string.staff_reservation_cancel), body = stringResource(R.string.staff_reservation_cancel_body, it.user.name), + confirmLabel = stringResource(R.string.staff_reservation_cancel), onConfirm = vm::confirmCancel, onDismiss = vm::dismissCancel, + ) + } + state.editing?.let { draft -> + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { if (!state.busy) vm.closeEdit() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(stringResource(R.string.staff_reservation_edit), style = MaterialTheme.typography.headlineSmall) + FormSectionTitle(stringResource(R.string.staff_loan_dates)) + DatePickerField(stringResource(R.string.staff_reservation_from), draft.from, { v -> vm.editDraft { it.copy(from = v) } }) + DatePickerField(stringResource(R.string.staff_reservation_to), draft.to, { v -> vm.editDraft { it.copy(to = v) } }) + DatePickerField(stringResource(R.string.staff_reservation_expiry), draft.expires, { v -> vm.editDraft { it.copy(expires = v) } }) + PrimaryButton(stringResource(R.string.action_save), onClick = vm::saveEdit, loading = state.busy, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(Spacing.lg)) + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt new file mode 100644 index 0000000..2cb1b44 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffUserScreen.kt @@ -0,0 +1,160 @@ +package com.pinakes.app.ui.screens.staff.circulation + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.StaffUserDetail +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffCirculationRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class StaffUserUiState( + val user: StaffUserDetail? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffUserViewModel @Inject constructor( + private val circulation: StaffCirculationRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val userId: Int = savedState.get(Routes.ARG_USER_ID) ?: 0 + private val _state = MutableStateFlow(StaffUserUiState()) + val state: StateFlow = _state.asStateFlow() + + fun load() { + _state.update { it.copy(loading = it.user == null && !it.refreshing, error = null) } + viewModelScope.launch { + val res = circulation.user(userId) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(user = res.data, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + load() + } +} + +internal fun userStatusLabel(status: String?): Int? = when (status) { + "attivo" -> R.string.staff_user_status_active + "sospeso" -> R.string.staff_user_status_suspended + "scaduto" -> R.string.staff_user_status_expired + else -> null +} + +/** A borrower at the desk: card, status, open loans and active reservations. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffUserScreen(onNavigateUp: () -> Unit, onOpenLoan: (Int) -> Unit, onNewLoan: (Int) -> Unit) { + val vm: StaffUserViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.circulation, onNavigateUp) + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) vm.load() } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + + Scaffold(topBar = { PinakesTopBar(title = state.user?.name ?: stringResource(R.string.staff_pick_user), onNavigateUp = onNavigateUp) }) { padding -> + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize().padding(padding)) { + val user = state.user + when { + state.loading -> LoadingState() + user == null -> ErrorState(message = state.error?.resolve() ?: stringResource(R.string.staff_error_load), onRetry = vm::refresh) + else -> LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + item(key = "card") { + InfoCard(Modifier.padding(top = Spacing.sm).testTag("user-card")) { + Text(user.name, style = MaterialTheme.typography.titleLarge) + InfoLine(stringResource(R.string.staff_user_card), user.cardNumber) + InfoLine(stringResource(R.string.staff_field_email), user.email) + InfoLine(stringResource(R.string.staff_user_status), userStatusLabel(user.status)?.let { stringResource(it) } ?: user.status) + InfoLine(stringResource(R.string.staff_user_card_expires), user.cardExpiresAt?.let(DateFormat::date)) + InfoLine(stringResource(R.string.staff_loans_active), user.activeLoans.toString()) + InfoLine(stringResource(R.string.staff_reservations), user.activeReservations.toString()) + if (!user.eligible) { + Text( + stringResource(R.string.staff_user_not_eligible, user.eligibilityReason ?: user.status.orEmpty()), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, + ) + } + } + } + item(key = "new") { + PrimaryButton(stringResource(R.string.staff_loan_new), onClick = { onNewLoan(user.id) }, enabled = user.eligible, + modifier = Modifier.fillMaxWidth().padding(top = Spacing.md)) + } + item(key = "loans-title") { FormSectionTitle(stringResource(R.string.staff_user_open_loans)) } + if (user.loans.isEmpty()) item(key = "loans-empty") { + Text(stringResource(R.string.staff_loans_empty), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + items(user.loans, key = { "l${it.id}" }) { loan -> StaffLoanRow(loan, onClick = { onOpenLoan(loan.id) }, showUser = false) } + item(key = "res-title") { FormSectionTitle(stringResource(R.string.staff_reservations)) } + if (user.reservations.isEmpty()) item(key = "res-empty") { + Text(stringResource(R.string.staff_reservations_empty), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + items(user.reservations, key = { "r${it.id}" }) { r -> StaffReservationRow(r, onClick = {}) } + } + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffLabelsSettingsSheet.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffLabelsSettingsSheet.kt new file mode 100644 index 0000000..a02e3c1 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffLabelsSettingsSheet.kt @@ -0,0 +1,190 @@ +package com.pinakes.app.ui.screens.staff.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.LabelSettings +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffContentRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** The label form: dimensions as typed text (mm), validated 10..100 before saving. */ +data class LabelForm( + val width: String = "", + val height: String = "", + val padding: String = "", + val showAppName: Boolean = true, + val showTitle: Boolean = true, + val showSubtitle: Boolean = false, + val showAuthorPublisher: Boolean = true, + val showDewey: Boolean = true, +) { + fun dimension(text: String): Double? = text.trim().replace(',', '.').toDoubleOrNull() + + /** Width and height in 10..100 mm; padding in 0..20 mm (blank keeps the stored value). */ + val widthValid: Boolean get() = dimension(width)?.let { it in 10.0..100.0 } == true + val heightValid: Boolean get() = dimension(height)?.let { it in 10.0..100.0 } == true + val paddingValid: Boolean get() = padding.isBlank() || dimension(padding)?.let { it in 0.0..20.0 } == true + val valid: Boolean get() = widthValid && heightValid && paddingValid + + fun toSettings(): LabelSettings = LabelSettings( + width = dimension(width), height = dimension(height), padding = dimension(padding), + showAppName = showAppName, showTitle = showTitle, showSubtitle = showSubtitle, + showAuthorPublisher = showAuthorPublisher, showDewey = showDewey, + ) + + companion object { + private fun fmt(v: Double?): String = v?.let { if (it % 1.0 == 0.0) it.toInt().toString() else it.toString() }.orEmpty() + + fun from(s: LabelSettings): LabelForm = LabelForm( + width = fmt(s.width), height = fmt(s.height), padding = fmt(s.padding), + showAppName = s.showAppName ?: true, showTitle = s.showTitle ?: true, showSubtitle = s.showSubtitle ?: false, + showAuthorPublisher = s.showAuthorPublisher ?: true, showDewey = s.showDewey ?: true, + ) + } +} + +data class LabelsUiState( + val form: LabelForm? = null, + val loading: Boolean = true, + val saving: Boolean = false, + val error: StaffMessage? = null, + val saved: Boolean = false, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffLabelsViewModel @Inject constructor(private val content: StaffContentRepository) : ViewModel() { + private val _state = MutableStateFlow(LabelsUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load() } + + fun load() { + _state.update { it.copy(loading = true, error = null) } + viewModelScope.launch { + val res = content.labelSettings() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(form = LabelForm.from(res.data), loading = false) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + /** The sheet closed after a save; the ViewModel outlives it (hub-scoped). */ + fun consumeSaved() = _state.update { it.copy(saved = false) } + + fun edit(transform: (LabelForm) -> LabelForm) = _state.update { s -> s.copy(form = s.form?.let(transform), error = null) } + + fun save() { + val form = _state.value.form ?: return + if (!form.valid || _state.value.saving) return + _state.update { it.copy(saving = true) } + viewModelScope.launch { + val res = content.saveLabelSettings(form.toSettings()) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(saving = false, form = LabelForm.from(res.data), saved = true) + is ApiResult.Failure -> it.copy(saving = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } +} + +/** Label size and content, shared by every label PDF the library prints. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffLabelsSettingsSheet(onDismiss: () -> Unit) { + val vm: StaffLabelsViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + LaunchedEffect(state.saved, state.forbidden) { + if (state.saved || state.forbidden) { + vm.consumeSaved() + onDismiss() + } + } + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(R.string.staff_labels_settings), style = MaterialTheme.typography.headlineSmall) + val form = state.form + state.error?.let { InlineErrorBanner(message = it.resolve(), onRetry = if (form == null) vm::load else null) } + when { + state.loading -> LoadingState(modifier = Modifier.heightIn(max = 160.dp)) + form != null -> { + Text(stringResource(R.string.staff_labels_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + MmField(stringResource(R.string.staff_labels_width), form.width, !form.widthValid, Modifier.weight(1f)) { v -> vm.edit { it.copy(width = v) } } + MmField(stringResource(R.string.staff_labels_height), form.height, !form.heightValid, Modifier.weight(1f)) { v -> vm.edit { it.copy(height = v) } } + } + MmField(stringResource(R.string.staff_labels_padding), form.padding, !form.paddingValid, Modifier.fillMaxWidth()) { v -> vm.edit { it.copy(padding = v) } } + SwitchRow(stringResource(R.string.staff_labels_show_app_name), form.showAppName, { v -> vm.edit { it.copy(showAppName = v) } }) + SwitchRow(stringResource(R.string.staff_labels_show_title), form.showTitle, { v -> vm.edit { it.copy(showTitle = v) } }) + SwitchRow(stringResource(R.string.staff_labels_show_subtitle), form.showSubtitle, { v -> vm.edit { it.copy(showSubtitle = v) } }) + SwitchRow(stringResource(R.string.staff_labels_show_author_publisher), form.showAuthorPublisher, { v -> vm.edit { it.copy(showAuthorPublisher = v) } }) + SwitchRow(stringResource(R.string.staff_labels_show_dewey), form.showDewey, { v -> vm.edit { it.copy(showDewey = v) } }) + PrimaryButton(stringResource(R.string.action_save), onClick = vm::save, loading = state.saving, enabled = form.valid, modifier = Modifier.fillMaxWidth()) + } + } + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +@Composable +private fun MmField(label: String, value: String, error: Boolean, modifier: Modifier, onChange: (String) -> Unit) { + PinakesTextField( + value = value, onValueChange = onChange, label = label, isError = error, + errorText = stringResource(R.string.staff_labels_range), maxLength = 6, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = modifier, + ) +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/BarcodeAnalyzer.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/BarcodeAnalyzer.kt index 0228c5a..3723bd6 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/BarcodeAnalyzer.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/BarcodeAnalyzer.kt @@ -12,7 +12,9 @@ import com.google.zxing.ReaderException import com.google.zxing.common.HybridBinarizer /** - * CameraX analyzer that decodes EAN-13 barcodes with ZXing (pure Java, no Play services). + * CameraX analyzer that decodes barcodes with ZXing (pure Java, no Play services). [formats] + * defaults to EAN-13 (book ISBN/EAN); the copy scanner adds CODE 128 and CODE 39, the + * symbologies of the inventory labels the web prints. * * Only the luminance (Y) plane is used, copied row by row to honour `rowStride`, and cropped * to the centre of the frame where the viewfinder guides the user. Frames alternate between @@ -20,12 +22,23 @@ import com.google.zxing.common.HybridBinarizer * horizontally or vertically. Decoded text goes to [onCode] on the analysis thread; the caller * validates the checksum and debounces repeated reads. */ -class BarcodeAnalyzer(private val onCode: (String) -> Unit) : ImageAnalysis.Analyzer { +class BarcodeAnalyzer( + private val formats: List = ISBN_FORMATS, + private val onCode: (String) -> Unit, +) : ImageAnalysis.Analyzer { + + companion object { + /** Book identifiers (ISBN-13 / EAN-13). */ + val ISBN_FORMATS: List = listOf(BarcodeFormat.EAN_13) + + /** Copy inventory labels (CODE 128, CODE 39) plus EAN-13 for libraries that label with it. */ + val COPY_FORMATS: List = listOf(BarcodeFormat.CODE_128, BarcodeFormat.CODE_39, BarcodeFormat.EAN_13) + } private val reader = MultiFormatReader().apply { setHints( mapOf( - DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.EAN_13), + DecodeHintType.POSSIBLE_FORMATS to formats, DecodeHintType.TRY_HARDER to true, ) ) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/CameraPreview.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/CameraPreview.kt index 0cb4ba6..9d06193 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/CameraPreview.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/CameraPreview.kt @@ -45,6 +45,7 @@ fun CameraPreview( onUnavailable: () -> Unit, accent: Color, modifier: Modifier = Modifier, + formats: List = BarcodeAnalyzer.ISBN_FORMATS, ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current @@ -71,7 +72,7 @@ fun CameraPreview( .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() .also { a -> - a.setAnalyzer(executor, BarcodeAnalyzer { code -> mainExecutor.execute { currentOnCode(code) } }) + a.setAnalyzer(executor, BarcodeAnalyzer(formats) { code -> mainExecutor.execute { currentOnCode(code) } }) } p.unbindAll() val selector = if (p.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA)) CameraSelector.DEFAULT_BACK_CAMERA diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt new file mode 100644 index 0000000..90977fa --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt @@ -0,0 +1,410 @@ +package com.pinakes.app.ui.screens.staff.scan + +import android.Manifest +import android.content.pm.PackageManager +import android.os.SystemClock +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.CopyLookup +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffCirculationRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.CopyCodeCodec +import com.pinakes.app.data.staff.ReturnOutcome +import com.pinakes.app.data.staff.ScanDebouncer +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.AvailabilityChip +import com.pinakes.app.ui.components.AvailabilityStatus +import com.pinakes.app.ui.components.BookCover +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.circulation.ReturnSheet +import com.pinakes.app.ui.screens.staff.circulation.loanChip +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.LocalPinakesColors +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +sealed interface CopyScanPhase { + data object Scanning : CopyScanPhase + data class Looking(val code: String) : CopyScanPhase + data class Found(val code: String, val lookup: CopyLookup) : CopyScanPhase + data class NotFound(val code: String) : CopyScanPhase +} + +data class CopyScanUiState( + val phase: CopyScanPhase = CopyScanPhase.Scanning, + val manualInput: String = "", + val manualError: Boolean = false, + val returning: Boolean = false, + val saving: Boolean = false, + val returnedCount: Int = 0, + val acceptedTick: Int = 0, + val message: StaffMessage? = null, + val forbidden: Boolean = false, +) { + val sheetOpen: Boolean get() = phase is CopyScanPhase.Found || phase is CopyScanPhase.NotFound +} + +/** + * The desk scanner: read a copy's inventory label (CODE 128 / CODE 39, or EAN-13), see the copy + * and its open loan, then return it with an outcome, lend it, or open the book. + */ +@HiltViewModel +class StaffCopyScanViewModel @Inject constructor(private val circulation: StaffCirculationRepository) : ViewModel() { + private val _state = MutableStateFlow(CopyScanUiState()) + val state: StateFlow = _state.asStateFlow() + private val debouncer = ScanDebouncer() + + fun onDecoded(raw: String) { + if (_state.value.phase != CopyScanPhase.Scanning) return + val code = CopyCodeCodec.normalize(raw).takeIf { it.isNotEmpty() } ?: return + val accepted = debouncer.offer(code, SystemClock.elapsedRealtime()) ?: return + _state.update { it.copy(acceptedTick = it.acceptedTick + 1) } + lookup(accepted) + } + + fun onManualInput(value: String) = _state.update { it.copy(manualInput = value.take(CopyCodeCodec.MAX_LENGTH), manualError = false) } + + fun submitManual() { + val code = CopyCodeCodec.normalize(_state.value.manualInput) + if (code.isEmpty()) { + _state.update { it.copy(manualError = true) } + return + } + if (_state.value.phase != CopyScanPhase.Scanning) return + _state.update { it.copy(manualInput = "") } + lookup(code) + } + + private fun lookup(code: String) { + debouncer.pause() + _state.update { it.copy(phase = CopyScanPhase.Looking(code)) } + viewModelScope.launch { + val res = circulation.copyByCode(code) + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + phase = if (res.data.found && res.data.copy != null) CopyScanPhase.Found(code, res.data) else CopyScanPhase.NotFound(code), + ) + is ApiResult.Failure -> it.copy(phase = CopyScanPhase.Scanning, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_load)) + } + } + if (_state.value.phase == CopyScanPhase.Scanning) debouncer.resume(SystemClock.elapsedRealtime()) + } + } + + fun openReturn() = _state.update { it.copy(returning = true) } + fun closeReturn() = _state.update { it.copy(returning = false) } + + /** Return by code: the same canonical path as the web's quick return. */ + fun returnCopy(outcome: ReturnOutcome, notes: String, penalty: Double?) { + val phase = _state.value.phase as? CopyScanPhase.Found ?: return + if (_state.value.saving) return + _state.update { it.copy(saving = true) } + viewModelScope.launch { + var res = circulation.returnByCode(phase.code, outcome.wire, notes, penalty) + if (res is ApiResult.Failure && res.code == "concurrent_retry") res = circulation.returnByCode(phase.code, outcome.wire, notes, penalty) + val result = res + _state.update { + when (result) { + is ApiResult.Success -> it.copy( + saving = false, returning = false, phase = CopyScanPhase.Scanning, returnedCount = it.returnedCount + 1, + message = StaffMessage(R.string.staff_copy_returned, args = listOf(result.data.book.title)), + ) + is ApiResult.Failure -> it.copy(saving = false, forbidden = result.isForbidden(), message = result.toStaffMessage(R.string.staff_error_save)) + } + } + if (result is ApiResult.Success) debouncer.resume(SystemClock.elapsedRealtime()) + } + } + + /** Close the sheet and scan the next copy. */ + fun dismiss() { + _state.update { it.copy(phase = CopyScanPhase.Scanning, returning = false) } + debouncer.resume(SystemClock.elapsedRealtime()) + } + + /** Leaving for another screen: keep the scanner paused until we are back. */ + fun leave() { + _state.update { it.copy(phase = CopyScanPhase.Scanning, returning = false) } + debouncer.resume(SystemClock.elapsedRealtime()) + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffCopyScanScreen( + onNavigateUp: () -> Unit, + onNewLoan: (bookId: Int, copyCode: String) -> Unit, + onOpenBook: (Int) -> Unit, + onOpenLoan: (Int) -> Unit, +) { + val vm: StaffCopyScanViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val context = LocalContext.current + val haptics = LocalHapticFeedback.current + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.circulation, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + LaunchedEffect(state.acceptedTick) { if (state.acceptedTick > 0) haptics.performHapticFeedback(HapticFeedbackType.LongPress) } + + var granted by remember { mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) } + var asked by rememberSaveable { mutableStateOf(false) } + var cameraBroken by remember { mutableStateOf(false) } + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted = it; asked = true } + LaunchedEffect(Unit) { if (!granted && !asked) permission.launch(Manifest.permission.CAMERA) } + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + + Scaffold( + topBar = { + PinakesTopBar( + title = stringResource(R.string.staff_scan_copy), + onNavigateUp = onNavigateUp, + actions = { + if (state.returnedCount > 0) { + Surface(shape = RoundedCornerShape(50), color = MaterialTheme.colorScheme.primaryContainer) { + Text( + stringResource(R.string.staff_copy_returned_count, state.returnedCount), + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xs), + ) + } + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + Column( + Modifier.fillMaxSize().padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg, vertical = Spacing.sm), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + Box( + Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(RoundedCornerShape(16.dp)).background(LocalPinakesColors.current.coverBlank), + contentAlignment = Alignment.Center, + ) { + if (granted && !cameraBroken) { + CameraPreview( + onCode = vm::onDecoded, torchOn = false, onHasTorch = {}, onUnavailable = { cameraBroken = true }, + accent = LocalPinakesColors.current.accent, modifier = Modifier.fillMaxSize(), formats = BarcodeAnalyzer.COPY_FORMATS, + ) + } else { + Column(Modifier.padding(Spacing.xl), horizontalAlignment = Alignment.CenterHorizontally) { + Text( + stringResource(if (cameraBroken) R.string.staff_camera_unavailable else R.string.staff_scan_copy_permission), + color = Color.White, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, + ) + if (!granted && !cameraBroken) { + Spacer(Modifier.height(Spacing.md)) + PrimaryButton(stringResource(R.string.staff_scan_permission_allow), onClick = { permission.launch(Manifest.permission.CAMERA) }) + } + } + } + if (state.phase is CopyScanPhase.Looking) { + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f)), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = Color.White, strokeWidth = 3.dp) + } + } + } + Text(stringResource(R.string.staff_scan_copy_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + PinakesTextField( + value = state.manualInput, onValueChange = vm::onManualInput, label = stringResource(R.string.staff_copy_code), + isError = state.manualError, errorText = stringResource(R.string.staff_copy_code_required), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii, imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { vm.submitManual() }), + trailingIcon = { + IconButton(onClick = vm::submitManual, enabled = state.phase == CopyScanPhase.Scanning) { + Icon(Icons.Outlined.Search, contentDescription = stringResource(R.string.staff_scan_lookup)) + } + }, + modifier = Modifier.fillMaxWidth().testTag("copy-code-input"), + ) + Spacer(Modifier.height(Spacing.xl)) + } + } + + when (val phase = state.phase) { + is CopyScanPhase.Found -> if (state.returning) { + ReturnSheet( + title = stringResource(R.string.staff_loan_action_return), saving = state.saving, + onConfirm = vm::returnCopy, onDismiss = vm::closeReturn, + ) + } else { + CopyResultSheet( + phase = phase, + onReturn = vm::openReturn, + onNewLoan = { bookId -> vm.leave(); onNewLoan(bookId, phase.code) }, + onOpenBook = { id -> vm.leave(); onOpenBook(id) }, + onOpenLoan = { id -> vm.leave(); onOpenLoan(id) }, + onDismiss = vm::dismiss, + ) + } + is CopyScanPhase.NotFound -> { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = vm::dismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(stringResource(R.string.staff_copy_not_found), style = MaterialTheme.typography.titleMedium) + Text( + stringResource( + if (CopyCodeCodec.looksLikeBookIdentifier(phase.code)) R.string.staff_copy_not_found_isbn else R.string.staff_copy_not_found_body, + phase.code, + ), + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SecondaryButton(stringResource(R.string.staff_scan_rescan), onClick = vm::dismiss, modifier = Modifier.fillMaxWidth()) + Spacer(Modifier.height(Spacing.lg)) + } + } + } + else -> Unit + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CopyResultSheet( + phase: CopyScanPhase.Found, + onReturn: () -> Unit, + onNewLoan: (Int) -> Unit, + onOpenBook: (Int) -> Unit, + onOpenLoan: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val copy = phase.lookup.copy ?: return + val loan = copy.loan + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding().testTag("copy-sheet"), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.lg)) { + BookCover(copy.book?.title.orEmpty(), copy.book?.coverUrl, Modifier.width(72.dp).aspectRatio(2f / 3f), compact = true) + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Text(copy.book?.title.orEmpty(), style = MaterialTheme.typography.titleLarge, maxLines = 3, overflow = TextOverflow.Ellipsis) + Text( + stringResource(R.string.staff_copy_code_value, copy.inventoryNumber ?: phase.code), + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (loan != null) { + val (status, label) = loanChip(loan) + AvailabilityChip(status = status, label = label) + } else { + AvailabilityChip( + status = if (copy.available == true) AvailabilityStatus.Available else AvailabilityStatus.Unavailable, + label = copy.statusLabel ?: copy.status, + ) + } + } + } + if (loan != null) { + InfoLine(stringResource(R.string.staff_loan_borrower), listOfNotNull(loan.user.name, loan.user.cardNumber).joinToString(" · ")) + InfoLine(stringResource(R.string.staff_loan_due), loan.dueAt?.let(DateFormat::date)) + val returnable = loan.status == "in_corso" || loan.status == "in_ritardo" || "return" in loan.actions + if (returnable) PrimaryButton(stringResource(R.string.staff_loan_action_return), onClick = onReturn, modifier = Modifier.fillMaxWidth()) + SecondaryButton(stringResource(R.string.staff_loan_open), onClick = { onOpenLoan(loan.id) }, modifier = Modifier.fillMaxWidth()) + } + if (copy.available == true) { + PrimaryButton(stringResource(R.string.staff_loan_new_with_copy), onClick = { onNewLoan(copy.book?.id ?: 0) }, + enabled = (copy.book?.id ?: 0) > 0, modifier = Modifier.fillMaxWidth()) + } + SecondaryButton(stringResource(R.string.staff_open_book), onClick = { onOpenBook(copy.book?.id ?: 0) }, + enabled = (copy.book?.id ?: 0) > 0, modifier = Modifier.fillMaxWidth()) + PinakesTextButton(stringResource(R.string.staff_scan_next), onClick = onDismiss, modifier = Modifier.align(Alignment.CenterHorizontally)) + Spacer(Modifier.height(Spacing.lg)) + } + } +} diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml index aa000e3..11c5aec 100644 --- a/app/src/main/res/xml/file_paths.xml +++ b/app/src/main/res/xml/file_paths.xml @@ -1,5 +1,7 @@ - + + From 9b788bc13970801152a1b159c3ba447e980fa4e5 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 01:37:42 +0200 Subject: [PATCH 35/62] Send an Idempotency-Key with every new loan and reservation One key per submission, kept across the automatic retry after a dropped connection and across a manual retry of the same form, so a request that reached the server is never created twice: a replay answers the original resource. A changed form is a new submission with a new key; a success or a 422 idempotency_conflict discards it, and the conflict has its own message. --- .../java/com/pinakes/app/data/model/Models.kt | 2 + .../app/data/network/StaffCirculationApi.kt | 6 ++- .../repository/StaffCirculationRepository.kt | 13 +++-- .../com/pinakes/app/data/staff/StaffErrors.kt | 1 + .../pinakes/app/data/staff/SubmissionKeys.kt | 36 ++++++++++++++ .../circulation/StaffLoanCreateScreen.kt | 34 ++++++++++---- .../pinakes/app/staff/SubmissionKeysTest.kt | 47 +++++++++++++++++++ i18n/de.json | 4 +- i18n/en.json | 4 +- i18n/fr.json | 4 +- i18n/it.json | 4 +- 11 files changed, 136 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/data/staff/SubmissionKeys.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/SubmissionKeysTest.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 4cbf6a4..4f7828c 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 @@ -37,6 +37,8 @@ data class Meta( val merged: Boolean? = null, val message: String? = null, @SerialName("retry_after") val retryAfter: Int? = null, + // A create replayed through its Idempotency-Key: the original resource, nothing new made. + val replayed: Boolean? = null, ) @Serializable diff --git a/app/src/main/java/com/pinakes/app/data/network/StaffCirculationApi.kt b/app/src/main/java/com/pinakes/app/data/network/StaffCirculationApi.kt index b445533..7417450 100644 --- a/app/src/main/java/com/pinakes/app/data/network/StaffCirculationApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/StaffCirculationApi.kt @@ -22,6 +22,7 @@ import com.pinakes.app.data.model.StaffUser import com.pinakes.app.data.model.StaffUserDetail import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.Header import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path @@ -49,8 +50,9 @@ interface StaffCirculationApi { @GET("staff/loans/{id}") suspend fun loan(@Path("id") id: Int): Envelope + /** [key] is the submission's Idempotency-Key: a replay answers 200 with the original loan. */ @POST("staff/loans") - suspend fun createLoan(@Body body: LoanCreateRequest): Envelope + suspend fun createLoan(@Body body: LoanCreateRequest, @Header("Idempotency-Key") key: String? = null): Envelope @PUT("staff/loans/{id}") suspend fun updateLoan(@Path("id") id: Int, @Body body: LoanEditRequest): Envelope @@ -102,7 +104,7 @@ interface StaffCirculationApi { suspend fun reservation(@Path("id") id: Int): Envelope @POST("staff/reservations") - suspend fun createReservation(@Body body: ReservationWrite): Envelope + suspend fun createReservation(@Body body: ReservationWrite, @Header("Idempotency-Key") key: String? = null): Envelope @PUT("staff/reservations/{id}") suspend fun updateReservation(@Path("id") id: Int, @Body body: ReservationWrite): Envelope diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt index 1776740..4a1659b 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt @@ -59,8 +59,14 @@ class StaffCirculationRepository( suspend fun loan(id: Int): ApiResult = session.staffCall { api.loan(id) } - suspend fun createLoan(request: LoanCreateRequest): ApiResult = - session.staffCall { api.createLoan(request.copy(copyCode = request.copyCode?.let(CopyCodeCodec::normalize), notes = request.notes.blankToNull())) } + /** + * [idempotencyKey] identifies this submission: the same key on every retry of it, so a + * request that reached the server before the connection dropped is not created twice. + */ + suspend fun createLoan(request: LoanCreateRequest, idempotencyKey: String? = null): ApiResult = + session.staffCall { + api.createLoan(request.copy(copyCode = request.copyCode?.let(CopyCodeCodec::normalize), notes = request.notes.blankToNull()), idempotencyKey) + } suspend fun updateLoan(id: Int, request: LoanEditRequest): ApiResult = session.staffCall { api.updateLoan(id, request) } @@ -102,7 +108,8 @@ class StaffCirculationRepository( api.reservations(status = status, q = query.blankToNull(), bookId = bookId, userId = userId, cursor = cursor, limit = limit) }.toPage() - suspend fun createReservation(body: ReservationWrite): ApiResult = session.staffCall { api.createReservation(body) } + suspend fun createReservation(body: ReservationWrite, idempotencyKey: String? = null): ApiResult = + session.staffCall { api.createReservation(body, idempotencyKey) } suspend fun updateReservation(id: Int, body: ReservationWrite): ApiResult = session.staffCall { api.updateReservation(id, body) } diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt index fae8e7d..c854344 100644 --- a/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt @@ -35,6 +35,7 @@ object StaffErrors { "job_busy" -> R.string.staff_err_job_busy "gone" -> R.string.staff_err_gone "upload_rejected" -> R.string.staff_err_upload_rejected + SubmissionKeys.CONFLICT -> R.string.staff_err_idempotency_conflict "feature_disabled" -> R.string.staff_err_feature_disabled ErrorCodes.RATE_LIMITED -> R.string.staff_err_rate_limited ErrorCodes.NETWORK -> R.string.offline_generic diff --git a/app/src/main/java/com/pinakes/app/data/staff/SubmissionKeys.kt b/app/src/main/java/com/pinakes/app/data/staff/SubmissionKeys.kt new file mode 100644 index 0000000..f728878 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/SubmissionKeys.kt @@ -0,0 +1,36 @@ +package com.pinakes.app.data.staff + +import java.util.UUID + +/** + * Idempotency keys for staff creates (`POST /staff/loans`, `POST /staff/reservations`). One key + * per user-initiated submission: retrying the same payload (automatically after a dropped + * connection, or by tapping again) reuses it, so the server answers the original resource + * (`meta.replayed`) instead of creating a second one; a changed payload is a new submission and + * gets a new key. After a success, or a 422 `idempotency_conflict`, the key is discarded. Pure. + */ +class SubmissionKeys(private val newKey: () -> String = { UUID.randomUUID().toString() }) { + private var key: String? = null + private var payload: Any? = null + + /** The key for [submission] (compared by value: use a data class). */ + fun keyFor(submission: Any): String { + val current = key + if (current != null && payload == submission) return current + return newKey().also { + key = it + payload = submission + } + } + + /** Forget the key: the next submission, even with the same payload, is a new one. */ + fun reset() { + key = null + payload = null + } + + companion object { + /** 422: the key was already used for a different payload. */ + const val CONFLICT = "idempotency_conflict" + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt index 550886d..6343686 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt @@ -37,7 +37,9 @@ import com.pinakes.app.data.network.ApiResult import com.pinakes.app.data.repository.StaffCirculationRepository import com.pinakes.app.data.repository.StaffRepository import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.network.ErrorCodes import com.pinakes.app.data.staff.CopyCodeCodec +import com.pinakes.app.data.staff.SubmissionKeys import com.pinakes.app.ui.common.AppViewModel import com.pinakes.app.ui.common.DateFormat import com.pinakes.app.ui.components.InlineErrorBanner @@ -114,6 +116,7 @@ class StaffLoanCreateViewModel @Inject constructor( ), ) val state: StateFlow = _state.asStateFlow() + private val keys = SubmissionKeys() val users = SearchController(viewModelScope, minLength = 2) { q -> when (val res = circulation.users(q)) { @@ -195,9 +198,8 @@ class StaffLoanCreateViewModel @Inject constructor( _state.update { it.copy(saving = true, error = null) } viewModelScope.launch { if (s.reservation) { - val res = circulation.createReservation( - ReservationWrite(userId = user.id, bookId = book.id, requestedFrom = s.start, requestedTo = s.end), - ) + val body = ReservationWrite(userId = user.id, bookId = book.id, requestedFrom = s.start, requestedTo = s.end) + val res = withIdempotency(body) { key -> circulation.createReservation(body, key) } _state.update { when (res) { is ApiResult.Success -> it.copy(saving = false, createdLoanId = -1) @@ -206,14 +208,13 @@ class StaffLoanCreateViewModel @Inject constructor( } return@launch } - val res = circulation.createLoan( - LoanCreateRequest( - userId = user.id, bookId = book.id, - copyCode = s.copyCode.takeIf { CopyCodeCodec.isValid(it) }, - loanedAt = s.start, dueAt = s.end, notes = s.notes, - immediate = s.handOverNow && s.canHandOver, - ), + val request = LoanCreateRequest( + userId = user.id, bookId = book.id, + copyCode = s.copyCode.takeIf { CopyCodeCodec.isValid(it) }, + loanedAt = s.start, dueAt = s.end, notes = s.notes, + immediate = s.handOverNow && s.canHandOver, ) + val res = withIdempotency(request) { key -> circulation.createLoan(request, key) } _state.update { when (res) { is ApiResult.Success -> it.copy(saving = false, createdLoanId = res.data.id) @@ -222,6 +223,19 @@ class StaffLoanCreateViewModel @Inject constructor( } } } + + /** + * Send [payload] under its submission key: one automatic retry with the same key when the + * connection dropped (the server may already have created it: the replay returns it), a + * fresh key after a success or a key conflict. + */ + private suspend fun withIdempotency(payload: Any, call: suspend (String) -> ApiResult): ApiResult { + val key = keys.keyFor(payload) + var res = call(key) + if (res is ApiResult.Failure && res.code == ErrorCodes.NETWORK) res = call(key) + if (res is ApiResult.Success || (res as? ApiResult.Failure)?.code == SubmissionKeys.CONFLICT) keys.reset() + return res + } } @OptIn(ExperimentalMaterial3Api::class) diff --git a/app/src/test/java/com/pinakes/app/staff/SubmissionKeysTest.kt b/app/src/test/java/com/pinakes/app/staff/SubmissionKeysTest.kt new file mode 100644 index 0000000..a826a49 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/SubmissionKeysTest.kt @@ -0,0 +1,47 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.LoanCreateRequest +import com.pinakes.app.data.staff.StaffErrors +import com.pinakes.app.data.staff.SubmissionKeys +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Test + +/** One Idempotency-Key per submission: stable across retries, new for a new payload or after a reset. */ +class SubmissionKeysTest { + + private var counter = 0 + private val keys = SubmissionKeys { "k${++counter}" } + private val loan = LoanCreateRequest(userId = 3, bookId = 5, immediate = true) + + @Test fun retriesOfTheSameSubmissionReuseTheKey() { + val first = keys.keyFor(loan) + assertEquals(first, keys.keyFor(loan.copy())) + assertEquals(first, keys.keyFor(LoanCreateRequest(userId = 3, bookId = 5, immediate = true))) + } + + @Test fun aChangedPayloadIsANewSubmission() { + val first = keys.keyFor(loan) + val second = keys.keyFor(loan.copy(dueAt = "2026-11-30")) + assertNotEquals(first, second) + // Going back to the first payload is again a new submission (the server would see a new body). + assertNotEquals(first, keys.keyFor(loan)) + } + + @Test fun resetAfterSuccessOrConflictGivesAFreshKey() { + val first = keys.keyFor(loan) + keys.reset() + assertNotEquals(first, keys.keyFor(loan)) + } + + @Test fun defaultKeysAreUuids() { + val key = SubmissionKeys().keyFor(loan) + assertEquals(36, key.length) + assertNotNull(java.util.UUID.fromString(key)) + } + + @Test fun conflictHasItsOwnMessage() { + assertNotNull(StaffErrors.messageRes(SubmissionKeys.CONFLICT)) + } +} diff --git a/i18n/de.json b/i18n/de.json index 8ac593c..652912c 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1023,5 +1023,7 @@ "staff_reservations_cancelled": "Storniert", "staff_reservations_all": "Alle", "staff_reservations_empty": "Keine Vormerkungen", - "staff_reservations_search": "Titel oder Nutzer" + "staff_reservations_search": "Titel oder Nutzer", + "staff_return_title": "Rücknahme erfassen", + "staff_err_idempotency_conflict": "Der Server hat diese Anfrage schon mit anderen Daten erhalten. Formular prüfen und erneut senden." } diff --git a/i18n/en.json b/i18n/en.json index 704af59..5176a75 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1023,5 +1023,7 @@ "staff_reservations_cancelled": "Cancelled", "staff_reservations_all": "All", "staff_reservations_empty": "No reservations", - "staff_reservations_search": "Title or borrower" + "staff_reservations_search": "Title or borrower", + "staff_return_title": "Record the return", + "staff_err_idempotency_conflict": "The server saw this request with different data. Check the form and send it again." } diff --git a/i18n/fr.json b/i18n/fr.json index cd806b0..d72c477 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1023,5 +1023,7 @@ "staff_reservations_cancelled": "Annulées", "staff_reservations_all": "Toutes", "staff_reservations_empty": "Aucune réservation", - "staff_reservations_search": "Titre ou lecteur" + "staff_reservations_search": "Titre ou lecteur", + "staff_return_title": "Enregistrer le retour", + "staff_err_idempotency_conflict": "Le serveur a déjà reçu cette demande avec d’autres données. Vérifiez le formulaire et renvoyez-le." } diff --git a/i18n/it.json b/i18n/it.json index 0ccb4b6..ecca522 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1023,5 +1023,7 @@ "staff_reservations_cancelled": "Annullate", "staff_reservations_all": "Tutte", "staff_reservations_empty": "Nessuna prenotazione", - "staff_reservations_search": "Titolo o utente" + "staff_reservations_search": "Titolo o utente", + "staff_return_title": "Registra la restituzione", + "staff_err_idempotency_conflict": "Il server ha già ricevuto questa richiesta con dati diversi. Controlla il modulo e invialo di nuovo." } From bf13e1c84a158b989416cfef5393bf4d7b3d5d8a Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 01:37:42 +0200 Subject: [PATCH 36/62] Cover the circulation desk with on-device flows Return with outcome and penalty, the penalty validation, the borrower page, a 409 mapped to its message, a new loan with user search and its Idempotency-Key, an ineligible borrower, return by a typed copy code, an unknown code, reservation cancel and create, and the circulation section hiding offline and coming back. The five return outcomes now wrap instead of scrolling sideways. --- .../assets/fixtures/circulation_summary.json | 22 +++ .../assets/fixtures/copy_by_code_found.json | 78 ++++++++++ .../assets/fixtures/copy_by_code_missing.json | 8 + .../assets/fixtures/health_staff.json | 16 +- .../assets/fixtures/health_staff_phase1.json | 28 ++++ .../fixtures/staff_book_availability.json | 137 ++++++++++++++++++ .../assets/fixtures/staff_loan_7.json | 54 +++++++ .../fixtures/staff_loan_7_returned.json | 50 +++++++ .../assets/fixtures/staff_loan_created.json | 54 +++++++ .../assets/fixtures/staff_loans_active.json | 111 ++++++++++++++ .../staff_reservation_31_cancelled.json | 32 ++++ .../assets/fixtures/staff_reservations.json | 69 +++++++++ .../assets/fixtures/staff_user_3.json | 100 +++++++++++++ .../assets/fixtures/staff_users.json | 26 ++++ .../com/pinakes/app/staffe2e/AppFlowTest.kt | 48 ++++++ .../app/staffe2e/CirculationFlowTest.kt | 77 ++++++++++ .../app/staffe2e/LoanCreateFlowTest.kt | 63 ++++++++ .../app/staffe2e/ReservationsFlowTest.kt | 53 +++++++ .../app/staffe2e/ReturnByCodeFlowTest.kt | 42 ++++++ .../app/staffe2e/StaffOfflineFlowTest.kt | 44 ++++++ .../staff/circulation/CirculationUi.kt | 29 ++-- .../circulation/StaffLoanDetailScreen.kt | 7 +- .../screens/staff/scan/StaffCopyScanScreen.kt | 4 +- 23 files changed, 1137 insertions(+), 15 deletions(-) create mode 100644 app/src/androidTest/assets/fixtures/circulation_summary.json create mode 100644 app/src/androidTest/assets/fixtures/copy_by_code_found.json create mode 100644 app/src/androidTest/assets/fixtures/copy_by_code_missing.json create mode 100644 app/src/androidTest/assets/fixtures/health_staff_phase1.json create mode 100644 app/src/androidTest/assets/fixtures/staff_book_availability.json create mode 100644 app/src/androidTest/assets/fixtures/staff_loan_7.json create mode 100644 app/src/androidTest/assets/fixtures/staff_loan_7_returned.json create mode 100644 app/src/androidTest/assets/fixtures/staff_loan_created.json create mode 100644 app/src/androidTest/assets/fixtures/staff_loans_active.json create mode 100644 app/src/androidTest/assets/fixtures/staff_reservation_31_cancelled.json create mode 100644 app/src/androidTest/assets/fixtures/staff_reservations.json create mode 100644 app/src/androidTest/assets/fixtures/staff_user_3.json create mode 100644 app/src/androidTest/assets/fixtures/staff_users.json create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/CirculationFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/LoanCreateFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/ReservationsFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/ReturnByCodeFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/StaffOfflineFlowTest.kt diff --git a/app/src/androidTest/assets/fixtures/circulation_summary.json b/app/src/androidTest/assets/fixtures/circulation_summary.json new file mode 100644 index 0000000..fcf9584 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/circulation_summary.json @@ -0,0 +1,22 @@ +{ + "data": { + "pending": 2, + "pickup_ready": 1, + "scheduled": 0, + "active": 5, + "overdue": 3, + "reservations": 4, + "due_today": 1, + "returned_today": 2, + "today": "2026-10-14", + "settings": { + "loan_duration_days": 30, + "pickup_expiry_days": 3, + "max_renewals": 2, + "max_active_loans_per_user": 5, + "allow_multiple_loans_same_book": false + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/copy_by_code_found.json b/app/src/androidTest/assets/fixtures/copy_by_code_found.json new file mode 100644 index 0000000..45c83d4 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/copy_by_code_found.json @@ -0,0 +1,78 @@ +{ + "data": { + "found": true, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato", + "status_label": "In prestito", + "notes": null, + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "held": false, + "deletable": false, + "on_loan": { + "loan_id": 7, + "user_name": "Ada Rossi", + "due_at": "2026-10-31" + }, + "available": false, + "loan": { + "id": 7, + "status": "in_corso", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "renew", + "edit", + "receipt", + "email_receipt" + ] + } + } + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/copy_by_code_missing.json b/app/src/androidTest/assets/fixtures/copy_by_code_missing.json new file mode 100644 index 0000000..30434a1 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/copy_by_code_missing.json @@ -0,0 +1,8 @@ +{ + "data": { + "found": false, + "copy": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/health_staff.json b/app/src/androidTest/assets/fixtures/health_staff.json index 1ac9d0d..18b3c4a 100644 --- a/app/src/androidTest/assets/fixtures/health_staff.json +++ b/app/src/androidTest/assets/fixtures/health_staff.json @@ -16,7 +16,21 @@ "desiderata": false, "staff_catalog": true, "catalog_sync": true, - "calendar_feed": true + "calendar_feed": true, + "staff_circulation": true, + "staff_copies": true, + "staff_series": true, + "staff_merges": true, + "staff_labels": true, + "staff_imports": true, + "staff_covers": true, + "staff_desiderata": true, + "staff_archives": true, + "staff_periodicals": false, + "staff_bookclub": false, + "staff_frbr": false, + "staff_digital_library": false, + "staff_librarything": false }, "catalogue_mode": false, "loan_approval_required": true, diff --git a/app/src/androidTest/assets/fixtures/health_staff_phase1.json b/app/src/androidTest/assets/fixtures/health_staff_phase1.json new file mode 100644 index 0000000..1ac9d0d --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_staff_phase1.json @@ -0,0 +1,28 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": true, + "catalog_sync": true, + "calendar_feed": true + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_book_availability.json b/app/src/androidTest/assets/fixtures/staff_book_availability.json new file mode 100644 index 0000000..27dd420 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_book_availability.json @@ -0,0 +1,137 @@ +{ + "data": { + "total_copies": 2, + "earliest_available": "2026-10-14", + "unavailable_dates": [], + "days": [ + { + "date": "2026-10-14", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-15", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-16", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-17", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-18", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-19", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-20", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-21", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-22", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-23", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-24", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-25", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-26", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-27", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-28", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-29", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-30", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + }, + { + "date": "2026-10-31", + "available": 1, + "loaned": 1, + "reserved": 0, + "state": "partial" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_loan_7.json b/app/src/androidTest/assets/fixtures/staff_loan_7.json new file mode 100644 index 0000000..a8f3db7 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_loan_7.json @@ -0,0 +1,54 @@ +{ + "data": { + "id": 7, + "status": "in_corso", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "renew", + "edit", + "receipt", + "email_receipt" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_loan_7_returned.json b/app/src/androidTest/assets/fixtures/staff_loan_7_returned.json new file mode 100644 index 0000000..7195c27 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_loan_7_returned.json @@ -0,0 +1,50 @@ +{ + "data": { + "id": 7, + "status": "danneggiato", + "status_label": "Danneggiato", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "danneggiato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": "2026-10-14", + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": 12.5, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "receipt" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_loan_created.json b/app/src/androidTest/assets/fixtures/staff_loan_created.json new file mode 100644 index 0000000..ecd6847 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_loan_created.json @@ -0,0 +1,54 @@ +{ + "data": { + "id": 21, + "status": "in_corso", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "renew", + "edit", + "receipt", + "email_receipt" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_loans_active.json b/app/src/androidTest/assets/fixtures/staff_loans_active.json new file mode 100644 index 0000000..33dee2e --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_loans_active.json @@ -0,0 +1,111 @@ +{ + "data": [ + { + "id": 7, + "status": "in_corso", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "renew", + "edit", + "receipt", + "email_receipt" + ] + }, + { + "id": 8, + "status": "in_ritardo", + "status_label": "In ritardo", + "origin": "diretto", + "book": { + "id": 6, + "title": "Il barone rampante", + "cover_url": null, + "isbn13": null + }, + "copy": { + "id": 12, + "inventory_number": "INV-0012", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-01", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 13, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "edit", + "recall", + "receipt", + "email_receipt" + ] + } + ], + "meta": { + "count": 2, + "limit": 30, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_reservation_31_cancelled.json b/app/src/androidTest/assets/fixtures/staff_reservation_31_cancelled.json new file mode 100644 index 0000000..10cf3ee --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_reservation_31_cancelled.json @@ -0,0 +1,32 @@ +{ + "data": { + "id": 31, + "status": "annullata", + "status_label": "Annullata", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "queue_position": 1, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "reactivate" + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_reservations.json b/app/src/androidTest/assets/fixtures/staff_reservations.json new file mode 100644 index 0000000..8be538c --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_reservations.json @@ -0,0 +1,69 @@ +{ + "data": [ + { + "id": 31, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "queue_position": 1, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + }, + { + "id": 32, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 4, + "name": "Bruno Sospeso", + "email": null, + "card_number": "C-0013", + "status": "sospeso" + }, + "queue_position": 2, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + } + ], + "meta": { + "count": 2, + "limit": 50, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_user_3.json b/app/src/androidTest/assets/fixtures/staff_user_3.json new file mode 100644 index 0000000..d542e18 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_user_3.json @@ -0,0 +1,100 @@ +{ + "data": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo", + "card_expires_at": "2027-12-31", + "role": "standard", + "active_loans": 1, + "active_reservations": 1, + "eligible": true, + "eligibility_reason": null, + "loans": [ + { + "id": 7, + "status": "in_corso", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 0, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "renew", + "edit", + "receipt", + "email_receipt" + ] + } + ], + "reservations": [ + { + "id": 31, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "queue_position": 1, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_users.json b/app/src/androidTest/assets/fixtures/staff_users.json new file mode 100644 index 0000000..65e502f --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_users.json @@ -0,0 +1,26 @@ +{ + "data": [ + { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + { + "id": 4, + "name": "Bruno Sospeso", + "email": "bruno@example.org", + "card_number": "C-0013", + "status": "sospeso", + "card_expires_at": "2027-01-01", + "role": "standard", + "active_loans": 0, + "active_reservations": 0, + "eligible": false, + "eligibility_reason": "Suspended" + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt index b8d2401..d9f579f 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt @@ -8,6 +8,7 @@ import androidx.compose.ui.test.junit4.createEmptyComposeRule import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput import androidx.test.core.app.ActivityScenario import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By @@ -74,6 +75,11 @@ abstract class AppFlowTest { compose.waitUntil(timeoutMs) { compose.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().isNotEmpty() } } + /** Like [waitFor] on the merged tree (a field's label or a button's text belong to the merged node). */ + protected fun waitForMerged(matcher: androidx.compose.ui.test.SemanticsMatcher, timeoutMs: Long = 10_000) { + compose.waitUntil(timeoutMs) { compose.onAllNodes(matcher).fetchSemanticsNodes().isNotEmpty() } + } + protected fun waitGone(matcher: androidx.compose.ui.test.SemanticsMatcher, timeoutMs: Long = 10_000) { compose.waitUntil(timeoutMs) { compose.onAllNodes(matcher, useUnmergedTree = true).fetchSemanticsNodes().isEmpty() } } @@ -132,6 +138,48 @@ abstract class AppFlowTest { device.waitForIdle() } + /** Profile → Staff area (the hub). */ + protected fun openStaffHub() { + openTab(R.string.nav_profile) + clickUntil(text(R.string.profile_action_staff), text(R.string.staff_scan_subtitle)) + } + + protected fun clickTag(tag: String) { + waitFor(androidx.compose.ui.test.hasTestTag(tag)) + compose.onAllNodes(androidx.compose.ui.test.hasTestTag(tag)).onFirst().performScrollToSafely().performClick() + compose.waitForIdle() + } + + /** Click the button labelled [value] inside the open dialog (not the identical one behind it). */ + protected fun clickInDialog(value: String) { + val matcher = hasText(value) and androidx.compose.ui.test.hasClickAction() and androidx.compose.ui.test.hasAnyAncestor(androidx.compose.ui.test.isDialog()) + waitForMerged(matcher) + compose.onAllNodes(matcher).onFirst().performClick() + compose.waitForIdle() + } + + /** Type into the text field whose label (or placeholder) is [label]. */ + protected fun typeInto(label: String, value: String) { + val matcher = androidx.compose.ui.test.hasSetTextAction() and hasText(label, substring = true) + waitForMerged(matcher) + compose.onAllNodes(matcher).onFirst().performScrollToSafely().performTextInput(value) + compose.waitForIdle() + } + + /** Type into the text field tagged [tag] (search fields without a label). */ + protected fun typeIntoTag(tag: String, value: String) { + val matcher = androidx.compose.ui.test.hasSetTextAction() and androidx.compose.ui.test.hasTestTag(tag) + waitFor(matcher) + compose.onAllNodes(matcher).onFirst().performTextInput(value) + compose.waitForIdle() + } + + /** The UTF-8 body of the last [method] [path] request. */ + protected fun lastBody(method: String, path: String): String { + waitRequest(method, path) + return api.recorded(method, path).last().body.clone().readUtf8() + } + protected fun setAirplaneMode(on: Boolean) { val mode = if (on) "enable" else "disable" instrumentation.uiAutomation.executeShellCommand("cmd connectivity airplane-mode $mode").close() diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/CirculationFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/CirculationFlowTest.kt new file mode 100644 index 0000000..254d2a7 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/CirculationFlowTest.kt @@ -0,0 +1,77 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Circulation desk → counters → On loan → a loan → Return as damaged with a penalty. */ +class CirculationFlowTest : AppFlowTest() { + + private fun desk() = launch { + json("GET staff/circulation/summary", "circulation_summary") + json("GET staff/loans", "staff_loans_active") + json("GET staff/loans/7", "staff_loan_7") + json("POST staff/loans/7/return", "staff_loan_7_returned") + json("GET staff/users/3", "staff_user_3") + } + + @Test fun returnALoanAsDamagedWithAPenalty() { + desk() + openStaffHub() + click(text(R.string.staff_circulation_desk)) + waitText(text(R.string.staff_circulation_today, 1, 2)) + clickTag("counter-active") + waitRequest("GET", "staff/loans") + assertEquals("active", api.recorded("GET", "staff/loans").last().requestUrl?.queryParameter("bucket")) + click("Orlando") + waitText("INV-0011") + clickTag("action-return") + waitText(text(R.string.staff_return_title)) + click(text(R.string.staff_return_outcome_damaged)) + typeInto(text(R.string.staff_penalty), "12,50") + clickTag("return-confirm") + val body = lastBody("POST", "staff/loans/7/return") + assertTrue(body, body.contains("\"outcome\":\"danneggiato\"")) + assertTrue(body, body.contains("\"penalty\":12.5")) + waitText(text(R.string.staff_loan_returned)) + } + + @Test fun anInvalidPenaltyIsNotSent() { + desk() + openStaffHub() + click(text(R.string.staff_circulation_desk)) + clickTag("counter-active") + click("Orlando") + clickTag("action-return") + click(text(R.string.staff_return_outcome_lost)) + typeInto(text(R.string.staff_penalty), "12.345") + waitText(text(R.string.staff_penalty_invalid)) + clickTag("return-confirm") + compose.waitForIdle() + assertTrue(api.recorded("POST", "staff/loans/7/return").isEmpty()) + } + + @Test fun theBorrowerOpensFromTheLoan() { + desk() + openStaffHub() + click(text(R.string.staff_circulation_desk)) + clickTag("counter-active") + click("Orlando") + click(text(R.string.staff_user_open)) + waitText("C-0012") + waitText(text(R.string.staff_user_open_loans).uppercase()) + } + + @Test fun aServerConflictShowsTheServerMessage() { + desk() + api.body("POST staff/loans/7/renew", 409, """{"data":null,"meta":{},"error":{"code":"max_renewals","message":"x"}}""") + openStaffHub() + click(text(R.string.staff_circulation_desk)) + clickTag("counter-active") + click("Orlando") + clickTag("action-renew") + clickInDialog(text(R.string.staff_loan_action_renew)) + waitText(text(R.string.staff_err_max_renewals)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/LoanCreateFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/LoanCreateFlowTest.kt new file mode 100644 index 0000000..d328842 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/LoanCreateFlowTest.kt @@ -0,0 +1,63 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → New loan: search the borrower, pick the book, hand over now, then the loan opens. */ +class LoanCreateFlowTest : AppFlowTest() { + + private fun setUp() = launch { + json("GET staff/users", "staff_users") + json("GET staff/books/5/availability", "staff_book_availability") + json("POST staff/loans", "staff_loan_created", status = 201) + json("GET staff/loans/21", "staff_loan_created") + } + + private fun fillForm() { + openStaffHub() + click(text(R.string.staff_loan_new)) + click(text(R.string.staff_pick_user_placeholder)) + typeIntoTag("picker-search", "Ada") + click("Ada Rossi") + click(text(R.string.staff_pick_book_placeholder)) + typeIntoTag("picker-search", "Orl") + click("Orlando") + waitRequest("GET", "staff/books/5/availability") + } + + @Test fun createAnImmediateLoanSendsTheFormAndAnIdempotencyKey() { + setUp() + fillForm() + assertEquals("3", api.recorded("GET", "staff/books/5/availability").last().requestUrl?.queryParameter("user_id")) + click(text(R.string.staff_loan_create)) + waitRequest("POST", "staff/loans") + val request = api.recorded("POST", "staff/loans").last() + val body = request.body.clone().readUtf8() + assertTrue(body, body.contains("\"user_id\":3")) + assertTrue(body, body.contains("\"book_id\":5")) + assertTrue(body, body.contains("\"immediate\":true")) + val key = request.getHeader("Idempotency-Key") + assertEquals(36, key?.length) + // The created loan opens. + waitRequest("GET", "staff/loans/21") + waitText("INV-0011") + } + + @Test fun anIneligibleBorrowerIsFlaggedAndTheServerRefusalShown() { + setUp() + api.body("POST staff/loans", 409, """{"data":null,"meta":{"reason":"user_suspended"},"error":{"code":"not_eligible","message":"x"}}""") + openStaffHub() + click(text(R.string.staff_loan_new)) + click(text(R.string.staff_pick_user_placeholder)) + typeIntoTag("picker-search", "Bru") + waitText("Suspended") + click("Bruno Sospeso") + click(text(R.string.staff_pick_book_placeholder)) + typeIntoTag("picker-search", "Orl") + click("Orlando") + click(text(R.string.staff_loan_create)) + waitText(text(R.string.staff_err_not_eligible)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ReservationsFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReservationsFlowTest.kt new file mode 100644 index 0000000..a541aa7 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReservationsFlowTest.kt @@ -0,0 +1,53 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Reservations: the queue by title, cancel with a reason, and the new-reservation form. */ +class ReservationsFlowTest : AppFlowTest() { + + @Test fun cancelAReservationWithAReason() { + launch { + json("GET staff/reservations", "staff_reservations") + json("POST staff/reservations/31/cancel", "staff_reservation_31_cancelled") + } + openStaffHub() + click(text(R.string.staff_reservations)) + waitText("Orlando") + waitFor(androidx.compose.ui.test.hasText(text(R.string.staff_queue_position, 2), substring = true)) + clickTag("reservation-31") + click(text(R.string.staff_reservation_cancel)) + typeInto(text(R.string.staff_reason_optional), "Requested by phone") + clickInDialog(text(R.string.staff_reservation_cancel)) + val body = lastBody("POST", "staff/reservations/31/cancel") + assertTrue(body, body.contains("Requested by phone")) + waitText(text(R.string.staff_reservation_cancelled)) + } + + @Test fun newReservationPostsUserAndBookWithAKey() { + launch { + json("GET staff/reservations", "staff_reservations") + json("GET staff/users", "staff_users") + json("GET staff/books/5/availability", "staff_book_availability") + json("POST staff/reservations", "staff_reservation_31_cancelled", status = 201) + } + openStaffHub() + click(text(R.string.staff_reservations)) + clickDescription(text(R.string.staff_reservation_new)) + click(text(R.string.staff_pick_user_placeholder)) + typeIntoTag("picker-search", "Ada") + click("Ada Rossi") + click(text(R.string.staff_pick_book_placeholder)) + typeIntoTag("picker-search", "Orl") + click("Orlando") + click(text(R.string.staff_reservation_create)) + waitRequest("POST", "staff/reservations") + val request = api.recorded("POST", "staff/reservations").last() + val body = request.body.clone().readUtf8() + assertTrue(body, body.contains("\"user_id\":3") && body.contains("\"book_id\":5")) + assertTrue(request.getHeader("Idempotency-Key").orEmpty().length == 36) + // Back on the queue. + waitText(text(R.string.staff_reservations_active)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ReturnByCodeFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReturnByCodeFlowTest.kt new file mode 100644 index 0000000..9b92efb --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReturnByCodeFlowTest.kt @@ -0,0 +1,42 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Scan a copy → type the inventory code → the copy and its loan → Return. */ +class ReturnByCodeFlowTest : AppFlowTest() { + + @Test fun typedCopyCodeFindsTheLoanAndReturnsIt() { + launch { + json("GET staff/copies/by-code", "copy_by_code_found") + json("POST staff/loans/return-by-code", "staff_loan_7_returned") + } + openStaffHub() + click(text(R.string.staff_scan_copy)) + denyCameraPromptIfShown() + typeInto(text(R.string.staff_copy_code), " INV-0011 ") + clickDescription(text(R.string.staff_scan_lookup)) + waitRequest("GET", "staff/copies/by-code") + assertEquals("INV-0011", api.recorded("GET", "staff/copies/by-code").last().requestUrl?.queryParameter("code")) + waitText("Ada Rossi · C-0012") + clickTag("copy-return") + clickTag("return-confirm") + val body = lastBody("POST", "staff/loans/return-by-code") + assertTrue(body, body.contains("\"code\":\"INV-0011\"")) + assertTrue(body, body.contains("\"outcome\":\"restituito\"")) + waitText(text(R.string.staff_copy_returned_count, 1)) + } + + @Test fun anUnknownCodeSaysSo() { + launch { json("GET staff/copies/by-code", "copy_by_code_missing") } + openStaffHub() + click(text(R.string.staff_scan_copy)) + denyCameraPromptIfShown() + typeInto(text(R.string.staff_copy_code), "NOPE-1") + clickDescription(text(R.string.staff_scan_lookup)) + waitText(text(R.string.staff_copy_not_found)) + waitText(text(R.string.staff_copy_not_found_body, "NOPE-1")) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffOfflineFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffOfflineFlowTest.kt new file mode 100644 index 0000000..50b3338 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/StaffOfflineFlowTest.kt @@ -0,0 +1,44 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Offline the circulation section is hidden (not disabled); the book tools stay; back online it returns. */ +class StaffOfflineFlowTest : AppFlowTest() { + + @Test fun circulationDisappearsOfflineAndComesBack() { + launch { json("GET staff/circulation/summary", "circulation_summary") } + openStaffHub() + waitText(text(R.string.staff_circulation_desk)) + setAirplaneMode(true) + waitGone(androidx.compose.ui.test.hasText(text(R.string.staff_circulation_desk)), timeoutMs = 15_000) + assertFalse(exists(text(R.string.staff_scan_copy))) + assertFalse(exists(text(R.string.staff_loan_new))) + waitText(text(R.string.staff_offline_circulation_hidden)) + assertTrue(exists(text(R.string.staff_books))) + assertTrue(exists(text(R.string.staff_section_books).uppercase())) + setAirplaneMode(false) + waitText(text(R.string.staff_circulation_desk), timeoutMs = 20_000) + } + + @Test fun anOpenDeskScreenClosesWhenTheDeviceGoesOffline() { + launch { json("GET staff/circulation/summary", "circulation_summary") } + openStaffHub() + click(text(R.string.staff_circulation_desk)) + waitText(text(R.string.staff_circulation_today, 1, 2)) + setAirplaneMode(true) + waitText(text(R.string.staff_offline_circulation_hidden), timeoutMs = 15_000) + } + + @Test fun withoutTheFlagThereIsNoCirculationSection() { + launch(health = "health_staff_phase1") + openStaffHub() + waitText(text(R.string.staff_books)) + assertFalse(exists(text(R.string.staff_circulation_desk))) + assertFalse(exists(text(R.string.staff_offline_circulation_hidden))) + assertFalse(exists(text(R.string.staff_section_books).uppercase())) + assertFalse(exists(text(R.string.staff_section_plugins).uppercase())) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt index e53c2ce..ace9952 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt @@ -2,6 +2,8 @@ package com.pinakes.app.ui.screens.staff.circulation import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -16,6 +18,8 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.CheckBox import androidx.compose.material.icons.outlined.CheckBoxOutlineBlank import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -40,8 +44,6 @@ import com.pinakes.app.data.staff.ReturnOutcome import com.pinakes.app.ui.common.DateFormat import com.pinakes.app.ui.common.StatusMapping import com.pinakes.app.ui.components.AvailabilityStatus -import com.pinakes.app.ui.components.FilterChipRow -import com.pinakes.app.ui.components.FilterOption import com.pinakes.app.ui.components.MediaRow import com.pinakes.app.ui.components.PinakesTextField import com.pinakes.app.ui.components.PrimaryButton @@ -133,7 +135,7 @@ internal fun StaffReservationRow( * The return form: the copy's outcome, notes, and a penalty for lost or damaged copies. The * penalty is validated before sending ([LoanActions.parsePenalty]). */ -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @Composable internal fun ReturnSheet( title: String, @@ -153,11 +155,20 @@ internal fun ReturnSheet( ) { Text(title, style = MaterialTheme.typography.headlineSmall) Text(stringResource(R.string.staff_return_outcome), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) - FilterChipRow( - options = ReturnOutcome.entries.map { FilterOption(it, stringResource(returnOutcomeLabel(it))) }, - selectedIds = setOf(outcome), - onToggle = { outcome = it; if (!it.allowsPenalty) penalty = "" }, - ) + // Wrapping chips: all five outcomes stay visible on a 360dp screen. + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + ReturnOutcome.entries.forEach { option -> + FilterChip( + selected = option == outcome, + onClick = { outcome = option; if (!option.allowsPenalty) penalty = "" }, + label = { Text(stringResource(returnOutcomeLabel(option)), style = MaterialTheme.typography.labelMedium) }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + } + } PinakesTextField( value = notes, onValueChange = { notes = it }, label = stringResource(R.string.staff_field_notes), singleLine = false, maxLength = 1000, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp), @@ -173,7 +184,7 @@ internal fun ReturnSheet( PrimaryButton( stringResource(R.string.staff_loan_action_return), loading = saving, enabled = parsed !is PenaltyInput.Invalid, onClick = { onConfirm(outcome, notes, (parsed as? PenaltyInput.Amount)?.value) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag("return-confirm"), ) Spacer(Modifier.height(Spacing.lg)) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt index 426dd8b..e711d78 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt @@ -346,10 +346,11 @@ fun StaffLoanDetailScreen(onNavigateUp: () -> Unit, onOpenUser: (Int) -> Unit, o FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { actions.forEachIndexed { index, action -> val label = stringResource(loanActionLabel(action)) + val tag = Modifier.testTag("action-${action.wire}") if (index == 0) { - PrimaryButton(label, onClick = { vm.onAction(action) }, loading = state.busy == action, enabled = state.busy == null) + PrimaryButton(label, onClick = { vm.onAction(action) }, loading = state.busy == action, enabled = state.busy == null, modifier = tag) } else { - SecondaryButton(label, onClick = { vm.onAction(action) }, enabled = state.busy == null) + SecondaryButton(label, onClick = { vm.onAction(action) }, enabled = state.busy == null, modifier = tag) } } } @@ -407,7 +408,7 @@ fun StaffLoanDetailScreen(onNavigateUp: () -> Unit, onOpenUser: (Int) -> Unit, o onDismiss = vm::dismissPending, ) LoanAction.RETURN -> ReturnSheet( - title = stringResource(R.string.staff_loan_action_return), + title = stringResource(R.string.staff_return_title), saving = state.busy == LoanAction.RETURN, onConfirm = vm::returnLoan, onDismiss = vm::dismissPending, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt index 90977fa..bc169ab 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt @@ -319,7 +319,7 @@ fun StaffCopyScanScreen( when (val phase = state.phase) { is CopyScanPhase.Found -> if (state.returning) { ReturnSheet( - title = stringResource(R.string.staff_loan_action_return), saving = state.saving, + title = stringResource(R.string.staff_return_title), saving = state.saving, onConfirm = vm::returnCopy, onDismiss = vm::closeReturn, ) } else { @@ -394,7 +394,7 @@ private fun CopyResultSheet( InfoLine(stringResource(R.string.staff_loan_borrower), listOfNotNull(loan.user.name, loan.user.cardNumber).joinToString(" · ")) InfoLine(stringResource(R.string.staff_loan_due), loan.dueAt?.let(DateFormat::date)) val returnable = loan.status == "in_corso" || loan.status == "in_ritardo" || "return" in loan.actions - if (returnable) PrimaryButton(stringResource(R.string.staff_loan_action_return), onClick = onReturn, modifier = Modifier.fillMaxWidth()) + if (returnable) PrimaryButton(stringResource(R.string.staff_loan_action_return), onClick = onReturn, modifier = Modifier.fillMaxWidth().testTag("copy-return")) SecondaryButton(stringResource(R.string.staff_loan_open), onClick = { onOpenLoan(loan.id) }, modifier = Modifier.fillMaxWidth()) } if (copy.available == true) { From ae03e829ca8a9934c4e9b9cfb6def4f96efaa1d3 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 01:48:17 +0200 Subject: [PATCH 37/62] Manage copies, series and duplicates from the staff area The book editor lists every copy with its state and notes: a copy can be edited, deleted (the server's refusal for copies on loan, held or with history is shown as such), or added with its own inventory code, and the book and copy label PDFs open in the app or go to the share sheet. Series are browsed as a tree with search and edited in place: details, rename (an existing name asks before merging), merge into another series, delete, parent work, book numbers, removal and multi-select assignment. Duplicate authors are merged from the server's suggestions or a manual pick, publishers by pick, and genres by choosing the one that goes and the one that stays. --- .../assets/fixtures/copies_added_code.json | 14 + .../assets/fixtures/merge_result.json | 7 + .../fixtures/staff_author_duplicates.json | 18 + .../assets/fixtures/staff_book_5_copies.json | 61 ++ .../assets/fixtures/staff_copy_11.json | 17 + .../assets/fixtures/staff_lookups.json | 34 +- .../assets/fixtures/staff_series.json | 31 + .../assets/fixtures/staff_series_5.json | 39 + .../fixtures/staff_series_5_numbered.json | 39 + .../pinakes/app/staffe2e/CopiesFlowTest.kt | 69 ++ .../com/pinakes/app/staffe2e/MergeFlowTest.kt | 25 + .../pinakes/app/staffe2e/SeriesFlowTest.kt | 54 ++ .../com/pinakes/app/data/staff/SeriesTree.kt | 44 + .../app/ui/navigation/PinakesNavHost.kt | 21 + .../ui/screens/staff/StaffBookEditScreen.kt | 10 + .../screens/staff/StaffBookEditViewModel.kt | 9 + .../staff/content/StaffCopiesSection.kt | 338 ++++++++ .../screens/staff/content/StaffMergeScreen.kt | 358 ++++++++ .../staff/content/StaffSeriesScreens.kt | 781 ++++++++++++++++++ .../com/pinakes/app/staff/SeriesTreeTest.kt | 71 ++ i18n/de.json | 79 +- i18n/en.json | 79 +- i18n/fr.json | 79 +- i18n/it.json | 79 +- 24 files changed, 2351 insertions(+), 5 deletions(-) create mode 100644 app/src/androidTest/assets/fixtures/copies_added_code.json create mode 100644 app/src/androidTest/assets/fixtures/merge_result.json create mode 100644 app/src/androidTest/assets/fixtures/staff_author_duplicates.json create mode 100644 app/src/androidTest/assets/fixtures/staff_book_5_copies.json create mode 100644 app/src/androidTest/assets/fixtures/staff_copy_11.json create mode 100644 app/src/androidTest/assets/fixtures/staff_series.json create mode 100644 app/src/androidTest/assets/fixtures/staff_series_5.json create mode 100644 app/src/androidTest/assets/fixtures/staff_series_5_numbered.json create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/CopiesFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/MergeFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/SeriesFlowTest.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/SeriesTree.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffSeriesScreens.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/SeriesTreeTest.kt diff --git a/app/src/androidTest/assets/fixtures/copies_added_code.json b/app/src/androidTest/assets/fixtures/copies_added_code.json new file mode 100644 index 0000000..690c477 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/copies_added_code.json @@ -0,0 +1,14 @@ +{ + "data": { + "copies_total": 3, + "copies_available": 2, + "created": [ + { + "id": 13, + "inventory_number": "LIB-7" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/merge_result.json b/app/src/androidTest/assets/fixtures/merge_result.json new file mode 100644 index 0000000..f105727 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/merge_result.json @@ -0,0 +1,7 @@ +{ + "data": { + "primary_id": 3 + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_author_duplicates.json b/app/src/androidTest/assets/fixtures/staff_author_duplicates.json new file mode 100644 index 0000000..16b1ca2 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_author_duplicates.json @@ -0,0 +1,18 @@ +{ + "data": [ + [ + { + "id": 3, + "name": "Virginia Woolf", + "books_count": 4 + }, + { + "id": 8, + "name": "Woolf, Virginia", + "books_count": 1 + } + ] + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_book_5_copies.json b/app/src/androidTest/assets/fixtures/staff_book_5_copies.json new file mode 100644 index 0000000..20ad349 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_book_5_copies.json @@ -0,0 +1,61 @@ +{ + "data": { + "id": 5, + "title": "Orlando", + "subtitle": "A Biography", + "isbn13": "9780156701600", + "year": 1928, + "pages": 333, + "language": "English", + "media_type": "libro", + "copies_total": 2, + "copies_available": 1, + "genre": { + "id": 11, + "name": "Romanzo" + }, + "authors": [ + { + "id": 1, + "name": "Virginia Woolf", + "role": "principale" + }, + { + "id": 7, + "name": "Nadia Fusini", + "role": "traduttore" + } + ], + "publishers": [ + { + "id": 4, + "name": "Hogarth" + } + ], + "location": { + "shelf_id": 7, + "shelf_code": "A", + "level_id": 70, + "level_number": 1, + "position": 3, + "label": "A.1.3" + }, + "copies": [ + { + "id": 11, + "inventory_number": "INV-0011", + "status": "disponibile", + "notes": null + }, + { + "id": 12, + "inventory_number": "INV-0012", + "status": "prestato", + "notes": "Spine worn" + } + ], + "is_wanted": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_copy_11.json b/app/src/androidTest/assets/fixtures/staff_copy_11.json new file mode 100644 index 0000000..f62e523 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_copy_11.json @@ -0,0 +1,17 @@ +{ + "data": { + "id": 11, + "inventory_number": "INV-0011", + "status": "manutenzione", + "status_label": "In manutenzione", + "notes": "Cover loose", + "book": { + "id": 5, + "title": "Orlando" + }, + "held": false, + "deletable": true + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_lookups.json b/app/src/androidTest/assets/fixtures/staff_lookups.json index 177ef63..9807797 100644 --- a/app/src/androidTest/assets/fixtures/staff_lookups.json +++ b/app/src/androidTest/assets/fixtures/staff_lookups.json @@ -32,7 +32,39 @@ "copy_states": [ { "value": "disponibile", - "label": "Disponibile" + "label": "Disponibile", + "editable": true + }, + { + "value": "prestato", + "label": "In prestito", + "editable": false + }, + { + "value": "manutenzione", + "label": "In manutenzione", + "editable": true + }, + { + "value": "perso", + "label": "Perso", + "editable": true + } + ], + "series_types": [ + { + "value": "serie", + "label": "Serie" + }, + { + "value": "universo", + "label": "Universo" + } + ], + "return_outcomes": [ + { + "value": "restituito", + "label": "Restituito" } ] }, diff --git a/app/src/androidTest/assets/fixtures/staff_series.json b/app/src/androidTest/assets/fixtures/staff_series.json new file mode 100644 index 0000000..70cbbe3 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_series.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": 4, + "name": "Discworld", + "type": "universo", + "book_count": 3, + "complete": false, + "children": [ + { + "id": 5, + "name": "Rincewind", + "type": "serie", + "parent_id": 4, + "book_count": 2, + "children": [] + } + ] + }, + { + "id": 6, + "name": "Gialli Mondadori", + "type": "serie", + "book_count": 1, + "complete": true, + "children": [] + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_series_5.json b/app/src/androidTest/assets/fixtures/staff_series_5.json new file mode 100644 index 0000000..45d0e54 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_series_5.json @@ -0,0 +1,39 @@ +{ + "data": { + "id": 5, + "name": "Rincewind", + "type": "serie", + "parent_id": 4, + "parent_name": "Discworld", + "group": null, + "cycle": null, + "cycle_order": null, + "complete": false, + "book_count": 2, + "min_number": "1", + "max_number": "2", + "children": [], + "description": "The wizard.", + "books": [ + { + "id": 5, + "title": "Orlando", + "cover_url": null, + "series_number": "1", + "is_primary": true, + "year": "1928" + }, + { + "id": 7, + "title": "Sourcery", + "cover_url": null, + "series_number": null, + "is_primary": true, + "year": "1988" + } + ], + "related": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_series_5_numbered.json b/app/src/androidTest/assets/fixtures/staff_series_5_numbered.json new file mode 100644 index 0000000..7d5ea65 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_series_5_numbered.json @@ -0,0 +1,39 @@ +{ + "data": { + "id": 5, + "name": "Rincewind", + "type": "serie", + "parent_id": 4, + "parent_name": "Discworld", + "group": null, + "cycle": null, + "cycle_order": null, + "complete": false, + "book_count": 2, + "min_number": "1", + "max_number": "2", + "children": [], + "description": "The wizard.", + "books": [ + { + "id": 5, + "title": "Orlando", + "cover_url": null, + "series_number": "1", + "is_primary": true, + "year": "1928" + }, + { + "id": 7, + "title": "Sourcery", + "cover_url": null, + "series_number": "2", + "is_primary": true, + "year": "1988" + } + ], + "related": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/CopiesFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/CopiesFlowTest.kt new file mode 100644 index 0000000..b12d94a --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/CopiesFlowTest.kt @@ -0,0 +1,69 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTextReplacement +import com.pinakes.app.R +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Book editor → copies: edit a copy's state and notes, a refused delete, a copy with its own code. */ +class CopiesFlowTest : AppFlowTest() { + + private fun openBook(configure: FakePinakes.() -> Unit = {}) { + launch { + json("GET staff/books/5", "staff_book_5_copies") + configure() + } + openStaffHub() + click(text(R.string.staff_books)) + click("Orlando") + waitText("INV-0011") + } + + @Test fun editACopyStateAndNotes() { + openBook { json("PUT staff/copies/11", "staff_copy_11") } + click("INV-0011") + click(text(R.string.staff_copy_status)) + click("In manutenzione") + compose.onAllNodes(hasSetTextAction() and androidx.compose.ui.test.hasTestTag("copy-notes")).onFirst().performTextReplacement("Cover loose") + clickTag("copy-save") + val body = lastBody("PUT", "staff/copies/11") + assertTrue(body, body.contains("\"status\":\"manutenzione\"")) + assertTrue(body, body.contains("\"notes\":\"Cover loose\"")) + waitText(text(R.string.staff_copy_saved)) + } + + @Test fun aCopyWithHistoryCannotBeDeleted() { + openBook { + body("DELETE staff/copies/12", 409, """{"data":null,"meta":{"reason":"copy_has_history"},"error":{"code":"copy_has_history","message":"x"}}""") + } + click("INV-0012") + click(text(R.string.staff_copy_delete)) + clickInDialog(text(R.string.staff_copy_delete)) + waitRequest("DELETE", "staff/copies/12") + waitText(text(R.string.staff_err_copy_has_history)) + } + + @Test fun addACopyWithAnExplicitCode() { + openBook { json("POST staff/books/5/copies", "copies_added_code", status = 201) } + click(text(R.string.staff_copy_add_with_code)) + typeInto(text(R.string.staff_copy_code), "LIB-7") + clickTag("copy-save") + val body = lastBody("POST", "staff/books/5/copies") + assertTrue(body, body.contains("\"quantity\":1")) + assertTrue(body, body.contains("\"inventory_number\":\"LIB-7\"")) + waitText(text(R.string.staff_copy_added)) + } + + @Test fun aTakenCodeIsReported() { + openBook { + body("POST staff/books/5/copies", 409, """{"data":null,"meta":{"existing":{"id":9,"inventory_number":"LIB-7","book_id":2}},"error":{"code":"duplicate","message":"x"}}""") + } + click(text(R.string.staff_copy_add_with_code)) + typeInto(text(R.string.staff_copy_code), "LIB-7") + clickTag("copy-save") + waitText(text(R.string.staff_copy_code_taken, "LIB-7")) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/MergeFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/MergeFlowTest.kt new file mode 100644 index 0000000..6ee6544 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/MergeFlowTest.kt @@ -0,0 +1,25 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Merge duplicates: a suggested author group merged into the record with most books. */ +class MergeFlowTest : AppFlowTest() { + + @Test fun mergeASuggestedDuplicateGroup() { + launch { + json("GET staff/authors/duplicates", "staff_author_duplicates") + json("POST staff/authors/merge", "merge_result") + } + openStaffHub() + click(text(R.string.staff_merges)) + waitText(text(R.string.staff_merge_author_books, "Woolf, Virginia", 1)) + clickTag("dup-group-0") + clickTag("merge-confirm") + val body = lastBody("POST", "staff/authors/merge") + assertTrue(body, body.contains("\"ids\":[3,8]")) + assertTrue(body, body.contains("\"primary_id\":3")) + waitText(text(R.string.staff_merge_done, 2)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/SeriesFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/SeriesFlowTest.kt new file mode 100644 index 0000000..4be325d --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/SeriesFlowTest.kt @@ -0,0 +1,54 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTextReplacement +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Series: the tree, a series' books, a number, and a rename that clashes and merges. */ +class SeriesFlowTest : AppFlowTest() { + + private fun openRincewind(configure: FakePinakes.() -> Unit = {}) { + launch { + json("GET staff/series", "staff_series") + json("GET staff/series/5", "staff_series_5") + configure() + } + openStaffHub() + click(text(R.string.staff_series)) + waitText("Discworld") + assertTrue(!exists("Rincewind")) + clickDescription(text(R.string.staff_expand)) + click("Rincewind") + waitText("Sourcery") + } + + @Test fun numberABookInTheSeries() { + openRincewind { json("PUT staff/series/5/books/7", "staff_series_5_numbered") } + clickTag("series-book-7") + typeInto(text(R.string.staff_field_series_number), "2") + clickInDialog(text(R.string.action_save)) + assertEquals("""{"series_number":"2"}""", lastBody("PUT", "staff/series/5/books/7")) + waitText(text(R.string.staff_series_number_saved)) + } + + @Test fun renameOntoAnExistingNameAsksAndMerges() { + openRincewind { + body("POST staff/series/5/rename", 409, """{"data":null,"meta":{"existing":{"id":6,"name":"Gialli Mondadori"}},"error":{"code":"duplicate","message":"x"}}""") + } + clickDescription(text(R.string.staff_more_actions)) + click(text(R.string.staff_series_rename)) + compose.onAllNodes(hasSetTextAction() and hasTestTag("text-input")).onFirst().performTextReplacement("Gialli Mondadori") + clickInDialog(text(R.string.staff_series_rename)) + waitText(text(R.string.staff_series_rename_clash, "Gialli Mondadori")) + assertTrue(lastBody("POST", "staff/series/5/rename").contains("\"merge_if_exists\":false")) + api.json("POST staff/series/5/rename", "staff_series_5") + clickInDialog(text(R.string.staff_series_merge_into, "Gialli Mondadori")) + compose.waitUntil(10_000) { api.recorded("POST", "staff/series/5/rename").size == 2 } + assertTrue(api.recorded("POST", "staff/series/5/rename").last().body.clone().readUtf8().contains("\"merge_if_exists\":true")) + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/SeriesTree.kt b/app/src/main/java/com/pinakes/app/data/staff/SeriesTree.kt new file mode 100644 index 0000000..2fbbd3e --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/SeriesTree.kt @@ -0,0 +1,44 @@ +package com.pinakes.app.data.staff + +import com.pinakes.app.data.model.StaffSeries + +/** One visible row of the series tree. */ +data class SeriesRow(val series: StaffSeries, val depth: Int, val hasChildren: Boolean, val expanded: Boolean) + +/** + * The series tree as rows: roots first, a node's children right under it when [expanded] + * contains its id. A non-blank [query] shows every matching node with its ancestors, fully + * expanded, so a match deep in a universe is never hidden. Pure. + */ +object SeriesTree { + + fun rows(roots: List, expanded: Set, query: String = ""): List { + val q = query.trim() + val out = ArrayList() + fun matches(node: StaffSeries): Boolean = node.name.contains(q, ignoreCase = true) || node.children.any(::matches) + fun walk(nodes: List, depth: Int) { + for (node in nodes) { + if (q.isNotEmpty() && !matches(node)) continue + val open = q.isNotEmpty() || node.id in expanded + out += SeriesRow(node, depth, node.children.isNotEmpty(), open && node.children.isNotEmpty()) + if (open) walk(node.children, depth + 1) + } + } + walk(roots, 0) + return out + } + + /** Every node, depth-first, for pickers (merge target). */ + fun flatten(roots: List): List { + val out = ArrayList() + fun walk(nodes: List) { nodes.forEach { out += it; walk(it.children) } } + walk(roots) + return out + } + + /** [id] and all its descendants: a series cannot be merged into itself or a child. */ + fun subtreeIds(roots: List, id: Int): Set { + val node = flatten(roots).firstOrNull { it.id == id } ?: return setOf(id) + return flatten(listOf(node)).map { it.id }.toSet() + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index 414e09a..c5d9fd3 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -55,6 +55,9 @@ import com.pinakes.app.ui.screens.staff.circulation.StaffLoansScreen import com.pinakes.app.ui.screens.staff.circulation.StaffReservationsScreen import com.pinakes.app.ui.screens.staff.circulation.StaffUserScreen import com.pinakes.app.ui.screens.staff.scan.StaffCopyScanScreen +import com.pinakes.app.ui.screens.staff.content.StaffMergeScreen +import com.pinakes.app.ui.screens.staff.content.StaffSeriesDetailScreen +import com.pinakes.app.ui.screens.staff.content.StaffSeriesScreen /** * Root navigation. The high-level [AuthState] decides the start destination; within the @@ -244,6 +247,24 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onNewLoan = { navController.navigate(Routes.staffLoanNew(userId = it)) }, ) } + // ---- Staff book tools (Mobile API 1.7.0) ---- + composable(Routes.STAFF_SERIES, enterTransition = slideIn, popExitTransition = slideOut) { + StaffSeriesScreen(onNavigateUp = { navController.popBackStack() }, onOpen = { navController.navigate(Routes.staffSeriesDetail(it)) }) + } + composable( + Routes.STAFF_SERIES_DETAIL, + arguments = listOf(navArgument(Routes.ARG_SERIES_ID) { type = NavType.IntType }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StaffSeriesDetailScreen( + onNavigateUp = { navController.popBackStack() }, + onOpenSeries = { id -> navController.navigate(Routes.staffSeriesDetail(id)) { popUpTo(Routes.STAFF_SERIES) } }, + onOpenBook = { navController.navigate(Routes.staffBookEdit(it)) }, + ) + } + composable(Routes.STAFF_MERGES, enterTransition = slideIn, popExitTransition = slideOut) { + StaffMergeScreen(onNavigateUp = { navController.popBackStack() }) + } composable(Routes.STAFF_COPY_SCAN, enterTransition = slideIn, popExitTransition = slideOut) { StaffCopyScanScreen( onNavigateUp = { navController.popBackStack() }, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt index 6e0f6a1..5cf5003 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt @@ -297,6 +297,16 @@ private fun BookForm(state: StaffBookEditUiState, vm: StaffBookEditViewModel) { label = stringResource(R.string.staff_add_copies), onClick = vm::addCopies, enabled = !state.addingCopies, leadingIcon = Icons.Outlined.Add, modifier = Modifier.fillMaxWidth(), ) + // Mobile API 1.7.0: one row per copy (status, notes, delete), a copy with its own + // code, and the label PDFs. Hidden on servers without `staff_copies`/`staff_labels`. + com.pinakes.app.ui.screens.staff.content.StaffCopiesSection( + copies = original?.copies.orEmpty(), + copiesTotal = original?.copiesTotal ?: 0, + lookups = state.lookups, + onBookRefreshed = vm::applyCopies, + onMessage = vm::showMessage, + onForbidden = vm::markForbidden, + ) } else { QuantityStepper(stringResource(R.string.staff_copies), f.copies, { n -> vm.edit { it.copy(copies = n) } }, range = StaffBookForm.COPIES_RANGE) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt index b806e35..6df3fa2 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditViewModel.kt @@ -435,6 +435,15 @@ class StaffBookEditViewModel @Inject constructor( } } + /** The copies section re-read the record: take its copies and counters, never the form. */ + fun applyCopies(book: com.pinakes.app.data.model.StaffBook) = _state.update { + it.copy(original = it.original?.copy(copies = book.copies, copiesTotal = book.copiesTotal, copiesAvailable = book.copiesAvailable)) + } + + fun showMessage(text: String) = _state.update { it.copy(snackbar = text) } + + fun markForbidden() = _state.update { it.copy(forbidden = true) } + fun delete() { if (!_state.value.isEdit || _state.value.deleting) return _state.update { it.copy(deleting = true) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt new file mode 100644 index 0000000..dd3a9d4 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCopiesSection.kt @@ -0,0 +1,338 @@ +package com.pinakes.app.ui.screens.staff.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Label +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.StaffBook +import com.pinakes.app.data.model.StaffCopy +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffContentRepository +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.StaffRepository +import com.pinakes.app.data.repository.isDuplicate +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.CopyCodeCodec +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.ExportFileDialog +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** The copy being edited (sheet) or created (id 0). */ +data class CopyDraft(val id: Int = 0, val code: String = "", val status: String = "", val notes: String = "", val originalStatus: String = "") + +data class CopiesUiState( + val copies: List = emptyList(), + val editing: CopyDraft? = null, + val deleting: StaffCopy? = null, + val saving: Boolean = false, + val formError: StaffMessage? = null, + val message: StaffMessage? = null, + val downloading: Boolean = false, + val export: File? = null, + val forbidden: Boolean = false, + /** The reloaded record after a copy change, for the editor's counters. */ + val refreshed: StaffBook? = null, +) + +/** + * Per-copy management inside the book editor (Mobile API 1.7.0): edit status and notes, delete, + * add one copy with an explicit inventory code, and the label PDFs. After every change the book + * is read again, so the copy list and the availability counters always come from the server. + */ +@HiltViewModel +class StaffCopiesViewModel @Inject constructor( + private val staff: StaffRepository, + private val content: StaffContentRepository, + private val files: StaffFilesRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val bookId: Int = savedState.get(Routes.ARG_BOOK_ID) ?: 0 + private val _state = MutableStateFlow(CopiesUiState()) + val state: StateFlow = _state.asStateFlow() + + /** The editor already loaded the record: start from its copies. */ + fun seed(copies: List) = _state.update { it.copy(copies = copies) } + + fun reload() { + if (bookId <= 0) return + viewModelScope.launch { + when (val res = staff.book(bookId)) { + is ApiResult.Success -> _state.update { it.copy(copies = res.data.copies, refreshed = res.data) } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden()) } + } + } + } + + fun consumeRefreshed() = _state.update { it.copy(refreshed = null) } + + fun startAdd() = _state.update { it.copy(editing = CopyDraft(status = "disponibile"), formError = null) } + + fun startEdit(copy: StaffCopy) = _state.update { + it.copy(editing = CopyDraft(copy.id, copy.inventoryNumber.orEmpty(), copy.status.orEmpty(), copy.notes.orEmpty(), copy.status.orEmpty()), formError = null) + } + + fun edit(transform: (CopyDraft) -> CopyDraft) = _state.update { s -> s.copy(editing = s.editing?.let(transform), formError = null) } + fun closeEditor() = _state.update { it.copy(editing = null, formError = null) } + + fun save() { + val draft = _state.value.editing ?: return + if (_state.value.saving) return + _state.update { it.copy(saving = true, formError = null) } + viewModelScope.launch { + val failure: ApiResult.Failure? = if (draft.id == 0) { + when (val res = staff.addCopy(bookId, CopyCodeCodec.normalize(draft.code), draft.status, draft.notes)) { + is ApiResult.Success -> null + is ApiResult.Failure -> res + } + } else { + // Send the status only when it changed: an unchanged circulation state is not a request. + val status = draft.status.takeIf { it != draft.originalStatus } + when (val res = content.updateCopy(draft.id, status, draft.notes)) { + is ApiResult.Success -> null + is ApiResult.Failure -> res + } + } + if (failure == null) { + _state.update { + it.copy(saving = false, editing = null, message = StaffMessage(if (draft.id == 0) R.string.staff_copy_added else R.string.staff_copy_saved)) + } + reload() + } else { + _state.update { + it.copy( + saving = false, forbidden = failure.isForbidden(), + formError = if (failure.isDuplicate()) StaffMessage(R.string.staff_copy_code_taken, args = listOf(draft.code.trim())) + else failure.toStaffMessage(R.string.staff_error_save), + ) + } + } + } + } + + fun askDelete(copy: StaffCopy) = _state.update { it.copy(deleting = copy, editing = null) } + fun dismissDelete() = _state.update { it.copy(deleting = null) } + + fun confirmDelete() { + val copy = _state.value.deleting ?: return + _state.update { it.copy(deleting = null) } + viewModelScope.launch { + when (val res = content.deleteCopy(copy.id)) { + is ApiResult.Success -> { + _state.update { it.copy(message = StaffMessage(R.string.staff_copy_deleted)) } + reload() + } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) } + } + } + } + + /** `book.pdf` (the record's spine label) or `copies.pdf` (all copies, or one with [copyId]). */ + fun downloadLabels(copies: Boolean, copyId: Int? = null) { + if (_state.value.downloading) return + _state.update { it.copy(downloading = true) } + val path = if (copies) "staff/books/$bookId/labels/copies.pdf" + (copyId?.let { "?copy_id=$it" } ?: "") else "staff/books/$bookId/labels/book.pdf" + val name = if (copies) "etichette_copie_$bookId.pdf" else "etichetta_$bookId.pdf" + viewModelScope.launch { + val res = files.download(path, name) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(downloading = false, export = res.data) + is ApiResult.Failure -> it.copy(downloading = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_download_failed)) + } + } + } + } + + fun closeExport() = _state.update { it.copy(export = null) } + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** Label for a copy state: the server's `status_label`, else the lookup label, else the raw value. */ +internal fun copyStateLabel(copy: StaffCopy, lookups: StaffLookups): String = + copy.statusLabel?.takeIf { it.isNotBlank() } + ?: lookups.copyStateOptions.firstOrNull { it.value == copy.status }?.label + ?: copy.status.orEmpty() + +/** + * The per-copy list of the book editor. Shown only when the server offers per-copy management + * (`staff_copies`); label buttons only with `staff_labels`. Messages go to [onMessage] (the + * editor's snackbar); counter changes to [onBookRefreshed]. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun StaffCopiesSection( + copies: List, + copiesTotal: Int, + lookups: StaffLookups, + onBookRefreshed: (StaffBook) -> Unit, + onMessage: (String) -> Unit, + onForbidden: () -> Unit, +) { + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + if (!areas.copies && !areas.labels) return + val vm: StaffCopiesViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { vm.seed(copies) } + // The editor's "Add copies" changed the total: read the copies again. + LaunchedEffect(copiesTotal) { if (copiesTotal != state.copies.size) vm.reload() } + LaunchedEffect(state.refreshed) { state.refreshed?.let { onBookRefreshed(it); vm.consumeRefreshed() } } + LaunchedEffect(state.forbidden) { if (state.forbidden) onForbidden() } + val message = state.message?.resolve() + LaunchedEffect(state.message) { if (!message.isNullOrBlank()) { onMessage(message); vm.consumeMessage() } } + + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier.testTag("copies-section")) { + if (areas.copies) { + state.copies.forEach { copy -> + Surface( + onClick = { vm.startEdit(copy) }, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().testTag("copy-${copy.id}"), + ) { + Row(Modifier.padding(Spacing.md), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(copy.inventoryNumber ?: stringResource(R.string.staff_copy_no_code), style = MaterialTheme.typography.bodyLarge) + Text( + listOfNotNull(copyStateLabel(copy, lookups).takeIf { it.isNotBlank() }, copy.notes?.takeIf { it.isNotBlank() }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon(Icons.Outlined.Edit, contentDescription = stringResource(R.string.staff_copy_edit), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + SecondaryButton(stringResource(R.string.staff_copy_add_with_code), onClick = vm::startAdd, leadingIcon = Icons.Outlined.Add, modifier = Modifier.fillMaxWidth()) + } + if (areas.labels) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + SecondaryButton(stringResource(R.string.staff_labels_book_pdf), onClick = { vm.downloadLabels(copies = false) }, + leadingIcon = Icons.AutoMirrored.Outlined.Label, enabled = !state.downloading) + SecondaryButton(stringResource(R.string.staff_labels_copies_pdf), onClick = { vm.downloadLabels(copies = true) }, + leadingIcon = Icons.AutoMirrored.Outlined.Label, enabled = !state.downloading) + } + } + } + + state.editing?.let { CopyEditorSheet(it, state, lookups, vm, labels = areas.labels) } + state.deleting?.let { copy -> + ConfirmDialog( + title = stringResource(R.string.staff_copy_delete), + body = stringResource(R.string.staff_copy_delete_body, copy.inventoryNumber ?: copy.id.toString()), + confirmLabel = stringResource(R.string.staff_copy_delete), + onConfirm = vm::confirmDelete, onDismiss = vm::dismissDelete, + ) + } + state.export?.let { ExportFileDialog(it, "application/pdf", stringResource(R.string.staff_labels_ready), onDismiss = vm::closeExport) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun CopyEditorSheet(draft: CopyDraft, state: CopiesUiState, lookups: StaffLookups, vm: StaffCopiesViewModel, labels: Boolean) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val editable = lookups.editableCopyStateOptions + // A circulation state (on loan, held) can be shown but not picked. + val options = if (draft.originalStatus.isNotBlank() && editable.none { it.value == draft.originalStatus }) { + listOf(LookupOption(draft.originalStatus, lookups.copyStateOptions.firstOrNull { it.value == draft.originalStatus }?.label ?: draft.originalStatus)) + editable + } else editable + ModalBottomSheet(onDismissRequest = { if (!state.saving) vm.closeEditor() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(if (draft.id == 0) R.string.staff_copy_add_with_code else R.string.staff_copy_edit), style = MaterialTheme.typography.headlineSmall) + state.formError?.let { InlineErrorBanner(message = it.resolve()) } + if (draft.id == 0) { + PinakesTextField( + value = draft.code, onValueChange = { v -> vm.edit { it.copy(code = v.take(CopyCodeCodec.MAX_LENGTH)) } }, + label = stringResource(R.string.staff_copy_code), supportingText = stringResource(R.string.staff_copy_code_new_hint), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Ascii), modifier = Modifier.fillMaxWidth(), + ) + } else { + Text(stringResource(R.string.staff_copy_code_value, draft.code.ifBlank { draft.id.toString() }), style = MaterialTheme.typography.titleMedium) + } + LookupField( + label = stringResource(R.string.staff_copy_status), value = draft.status, + displayValue = options.firstOrNull { it.value == draft.status }?.label ?: draft.status, + options = options.filter { it.value in editable.map(LookupOption::value) }, editable = false, allowClear = false, + onValueChange = { v -> vm.edit { it.copy(status = v) } }, modifier = Modifier.fillMaxWidth(), + ) + PinakesTextField( + value = draft.notes, onValueChange = { v -> vm.edit { it.copy(notes = v) } }, label = stringResource(R.string.staff_field_notes), + singleLine = false, maxLength = 1000, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).testTag("copy-notes"), + ) + PrimaryButton(stringResource(R.string.action_save), onClick = vm::save, loading = state.saving, modifier = Modifier.fillMaxWidth().testTag("copy-save")) + if (draft.id > 0) { + if (labels) { + SecondaryButton(stringResource(R.string.staff_labels_this_copy), onClick = { vm.downloadLabels(copies = true, copyId = draft.id) }, + enabled = !state.downloading, modifier = Modifier.fillMaxWidth()) + } + PinakesTextButton(stringResource(R.string.staff_copy_delete), onClick = { + vm.askDelete(StaffCopy(id = draft.id, inventoryNumber = draft.code)) + }, modifier = Modifier.align(Alignment.CenterHorizontally)) + } + Spacer(Modifier.height(Spacing.lg)) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt new file mode 100644 index 0000000..6ea3449 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt @@ -0,0 +1,358 @@ +package com.pinakes.app.ui.screens.staff.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.MergeType +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.DuplicateAuthor +import com.pinakes.app.data.model.FlatGenre +import com.pinakes.app.data.model.flatten +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffContentRepository +import com.pinakes.app.data.repository.StaffRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.PickerRow +import com.pinakes.app.ui.screens.staff.SearchController +import com.pinakes.app.ui.screens.staff.SearchPickerSheet +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +enum class MergeTab(val label: Int) { AUTHORS(R.string.staff_authors), PUBLISHERS(R.string.staff_publishers), GENRES(R.string.staff_genres) } + +/** The records about to be merged into one: the primary keeps its id; the others disappear. */ +data class MergeSelection(val items: List = emptyList(), val primaryId: Int? = null, val newName: String = "") { + val ready: Boolean get() = items.size >= 2 && primaryId != null && items.any { it.id == primaryId } +} + +data class MergeUiState( + val tab: MergeTab = MergeTab.AUTHORS, + val duplicates: List> = emptyList(), + val duplicatesLoading: Boolean = true, + val selection: MergeSelection = MergeSelection(), + val picking: Boolean = false, + val confirming: Boolean = false, + val genres: List = emptyList(), + val genreSource: FlatGenre? = null, + val genreTarget: FlatGenre? = null, + val genrePicking: Boolean? = null, + val busy: Boolean = false, + val error: StaffMessage? = null, + val message: StaffMessage? = null, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffMergeViewModel @Inject constructor( + private val content: StaffContentRepository, + private val staff: StaffRepository, +) : ViewModel() { + private val _state = MutableStateFlow(MergeUiState()) + val state: StateFlow = _state.asStateFlow() + + val search = SearchController(viewModelScope, minLength = 2) { q -> + when (_state.value.tab) { + MergeTab.PUBLISHERS -> when (val res = staff.publishers(q, 30)) { + is ApiResult.Success -> ApiResult.Success(res.data.map { PickItem(it.id, it.name, it.website) }) + is ApiResult.Failure -> res + } + else -> when (val res = staff.authors(q, 30)) { + is ApiResult.Success -> ApiResult.Success(res.data.map { PickItem(it.id, it.display, listOfNotNull(it.born, it.died).joinToString("–").ifBlank { null }) }) + is ApiResult.Failure -> res + } + } + }.also { c -> c.onFailure = { f -> if (f.isForbidden()) _state.update { it.copy(forbidden = true) } } } + + init { loadDuplicates() } + + fun loadDuplicates() { + _state.update { it.copy(duplicatesLoading = true) } + viewModelScope.launch { + val res = content.authorDuplicates() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(duplicates = res.data.filter { g -> g.size >= 2 }, duplicatesLoading = false) + is ApiResult.Failure -> it.copy(duplicatesLoading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun selectTab(tab: MergeTab) { + _state.update { it.copy(tab = tab, selection = MergeSelection(), error = null) } + search.clear() + if (tab == MergeTab.GENRES && _state.value.genres.isEmpty()) viewModelScope.launch { + when (val res = staff.genres()) { + is ApiResult.Success -> _state.update { it.copy(genres = res.data.flatten()) } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) } + } + } + } + + /** A duplicate group goes to the confirm sheet; the record with most books is the default primary. */ + fun useGroup(group: List) = _state.update { + it.copy( + selection = MergeSelection( + items = group.map { a -> PickItem(a.id, a.name, null) }, + primaryId = group.maxByOrNull { a -> a.booksCount }?.id, + ), + confirming = true, error = null, + ) + } + + fun openPicker() = _state.update { it.copy(picking = true) } + fun closePicker() { _state.update { it.copy(picking = false) }; search.clear() } + + fun toggle(item: PickItem) = _state.update { s -> + val items = if (s.selection.items.any { it.id == item.id }) s.selection.items.filterNot { it.id == item.id } else s.selection.items + item + s.copy(selection = s.selection.copy(items = items, primaryId = s.selection.primaryId?.takeIf { id -> items.any { it.id == id } } ?: items.firstOrNull()?.id)) + } + + fun remove(item: PickItem) = toggle(item) + fun setPrimary(id: Int) = _state.update { it.copy(selection = it.selection.copy(primaryId = id)) } + fun setNewName(value: String) = _state.update { it.copy(selection = it.selection.copy(newName = value)) } + fun openConfirm() = _state.update { it.copy(confirming = it.selection.ready, picking = false) } + fun closeConfirm() = _state.update { it.copy(confirming = false, error = null) } + + fun merge() { + val s = _state.value + if (!s.selection.ready || s.busy) return + _state.update { it.copy(busy = true, error = null) } + viewModelScope.launch { + val ids = s.selection.items.map { it.id } + val res = if (s.tab == MergeTab.PUBLISHERS) content.mergePublishers(ids, s.selection.primaryId, s.selection.newName) + else content.mergeAuthors(ids, s.selection.primaryId, s.selection.newName) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, confirming = false, selection = MergeSelection(), message = StaffMessage(R.string.staff_merge_done, args = listOf(ids.size))) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_save)) + } + } + if (res is ApiResult.Success && s.tab == MergeTab.AUTHORS) loadDuplicates() + } + } + + fun pickGenre(source: Boolean?) = _state.update { it.copy(genrePicking = source) } + + fun setGenre(genre: FlatGenre) = _state.update { + if (it.genrePicking == true) it.copy(genreSource = genre, genrePicking = null, error = null) else it.copy(genreTarget = genre, genrePicking = null, error = null) + } + + fun mergeGenre() { + val source = _state.value.genreSource ?: return + val target = _state.value.genreTarget ?: return + if (source.id == target.id || _state.value.busy) return + _state.update { it.copy(busy = true, error = null) } + viewModelScope.launch { + val res = content.mergeGenre(source.id, target.id) + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + busy = false, genreSource = null, genreTarget = null, genres = it.genres.filterNot { g -> g.id == source.id }, + message = StaffMessage(R.string.staff_merge_genre_done, args = listOf(res.data.booksUpdated, res.data.childrenMoved)), + ) + is ApiResult.Failure -> it.copy( + busy = false, forbidden = res.isForbidden(), + error = if (res.meta?.reason == "descendant") StaffMessage(R.string.staff_merge_genre_descendant) else res.toStaffMessage(R.string.staff_error_save), + ) + } + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** Merge duplicate authors (suggested groups or a manual pick), publishers, and genres. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffMergeScreen(onNavigateUp: () -> Unit) { + val vm: StaffMergeViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.merges, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_merges), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + PrimaryTabRow(selectedTabIndex = state.tab.ordinal) { + MergeTab.entries.forEach { tab -> Tab(selected = tab == state.tab, onClick = { vm.selectTab(tab) }, text = { Text(stringResource(tab.label)) }) } + } + if (state.tab == MergeTab.GENRES) GenreMerge(state, vm) else RecordMerge(state, vm) + } + } + + if (state.picking) { + val s by vm.search.state.collectAsStateWithLifecycle() + SearchPickerSheet( + title = stringResource(R.string.staff_merge_pick), placeholder = stringResource(if (state.tab == MergeTab.PUBLISHERS) R.string.staff_search_publishers else R.string.staff_search_authors), + state = s, onQuery = vm.search::onQuery, onPick = vm::toggle, onDismiss = vm::closePicker, + selected = state.selection.items.map { it.id }.toSet(), onConfirm = vm::closePicker, hint = stringResource(R.string.staff_search_min_chars), + ) + } + if (state.confirming) MergeConfirmSheet(state, vm) + state.genrePicking?.let { source -> + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { vm.pickGenre(null) }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + LazyColumn(Modifier.fillMaxWidth().navigationBarsPadding(), contentPadding = PaddingValues(Spacing.lg)) { + item { Text(stringResource(if (source) R.string.staff_merge_genre_source else R.string.staff_merge_genre_target), style = MaterialTheme.typography.headlineSmall) } + items(state.genres, key = { it.id }) { g -> + Surface(onClick = { vm.setGenre(g) }, shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(start = (g.depth * 16).dp, top = Spacing.xs, bottom = Spacing.xs)) { + Text(g.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(Spacing.md)) + } + } + } + } + } +} + +@Composable +private fun RecordMerge(state: MergeUiState, vm: StaffMergeViewModel) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + item(key = "manual") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.md)) { + Text(stringResource(R.string.staff_merge_manual_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + state.selection.items.forEach { item -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text(item.title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + IconButton(onClick = { vm.remove(item) }) { Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.cd_remove)) } + } + } + SecondaryButton(stringResource(R.string.staff_merge_pick), onClick = vm::openPicker, modifier = Modifier.fillMaxWidth()) + PrimaryButton(stringResource(R.string.staff_merge_review), onClick = vm::openConfirm, enabled = state.selection.items.size >= 2, modifier = Modifier.fillMaxWidth()) + } + } + if (state.tab == MergeTab.AUTHORS) { + item(key = "dup-title") { FormSectionTitle(stringResource(R.string.staff_merge_suggested)) } + if (!state.duplicatesLoading && state.duplicates.isEmpty()) item(key = "dup-empty") { + Text(stringResource(R.string.staff_merge_no_duplicates), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + itemsIndexed(state.duplicates, key = { i, g -> "g$i-${g.first().id}" }) { index, group -> + Surface( + onClick = { vm.useGroup(group) }, shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("dup-group-$index"), + ) { + Row(Modifier.padding(Spacing.md), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + group.forEach { a -> + Text(stringResource(R.string.staff_merge_author_books, a.name, a.booksCount), style = MaterialTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + Icon(Icons.AutoMirrored.Outlined.MergeType, contentDescription = stringResource(R.string.staff_merge_review), tint = MaterialTheme.colorScheme.primary) + } + } + } + } + } +} + +@Composable +private fun GenreMerge(state: MergeUiState, vm: StaffMergeViewModel) { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(stringResource(R.string.staff_merge_genre_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + state.error?.let { InlineErrorBanner(message = it.resolve()) } + PickerRow(stringResource(R.string.staff_merge_genre_source), state.genreSource?.path, stringResource(R.string.staff_merge_genre_pick), onClick = { vm.pickGenre(true) }) + PickerRow(stringResource(R.string.staff_merge_genre_target), state.genreTarget?.path, stringResource(R.string.staff_merge_genre_pick), onClick = { vm.pickGenre(false) }) + PrimaryButton( + stringResource(R.string.staff_merge_action), onClick = vm::mergeGenre, loading = state.busy, + enabled = state.genreSource != null && state.genreTarget != null && state.genreSource.id != state.genreTarget.id, modifier = Modifier.fillMaxWidth(), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MergeConfirmSheet(state: MergeUiState, vm: StaffMergeViewModel) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { if (!state.busy) vm.closeConfirm() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(stringResource(R.string.staff_merge_review), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.staff_merge_primary_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + state.error?.let { InlineErrorBanner(message = it.resolve()) } + state.selection.items.forEach { item -> + Row(verticalAlignment = Alignment.CenterVertically) { + RadioButton(selected = item.id == state.selection.primaryId, onClick = { vm.setPrimary(item.id) }) + Text(item.title, style = MaterialTheme.typography.bodyLarge) + } + } + PinakesTextField(value = state.selection.newName, onValueChange = vm::setNewName, label = stringResource(R.string.staff_merge_new_name), + supportingText = stringResource(R.string.staff_merge_new_name_hint), modifier = Modifier.fillMaxWidth()) + PrimaryButton(stringResource(R.string.staff_merge_action), onClick = vm::merge, loading = state.busy, enabled = state.selection.ready, + modifier = Modifier.fillMaxWidth().testTag("merge-confirm")) + Spacer(Modifier.height(Spacing.lg)) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffSeriesScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffSeriesScreens.kt new file mode 100644 index 0000000..2c05957 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffSeriesScreens.kt @@ -0,0 +1,781 @@ +package com.pinakes.app.ui.screens.staff.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.CollectionsBookmark +import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.IdName +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.SeriesBook +import com.pinakes.app.data.model.SeriesWrite +import com.pinakes.app.data.model.StaffLookups +import com.pinakes.app.data.model.StaffSeries +import com.pinakes.app.data.model.StaffSeriesDetail +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffContentRepository +import com.pinakes.app.data.repository.StaffRepository +import com.pinakes.app.data.repository.isDuplicate +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.SeriesTree +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.MediaRow +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SearchField +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.SearchController +import com.pinakes.app.ui.screens.staff.SearchPickerSheet +import com.pinakes.app.ui.screens.staff.StaffFab +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** The series types of the web form, used when the server's lookups predate `series_types`. */ +private val DEFAULT_SERIES_TYPES = listOf("serie", "universo", "ciclo", "stagione", "spin_off", "arco", "collezione_editoriale", "altro") + +/** Series form (create and edit). Numbers stay text until sent. */ +data class SeriesForm( + val name: String = "", + val type: String = "serie", + val parentName: String = "", + val group: String = "", + val cycle: String = "", + val cycleOrder: String = "", + val description: String = "", + val complete: Boolean = false, +) { + val cycleOrderValid: Boolean get() = cycleOrder.isBlank() || cycleOrder.trim().toIntOrNull()?.let { it in 0..65535 } == true + val valid: Boolean get() = name.isNotBlank() && name.trim().length <= 100 && cycleOrderValid + + /** Cleared optional fields go as "" so an edit can empty them (absent keys are kept). */ + fun toWrite(): SeriesWrite = SeriesWrite( + name = name.trim(), type = type.ifBlank { null }, parentName = parentName.trim(), group = group.trim(), cycle = cycle.trim(), + cycleOrder = cycleOrder.trim().toIntOrNull(), description = description.trim(), complete = complete, + ) + + companion object { + fun from(d: StaffSeriesDetail) = SeriesForm( + name = d.name, type = d.type.orEmpty(), parentName = d.parentName.orEmpty(), group = d.group.orEmpty(), cycle = d.cycle.orEmpty(), + cycleOrder = d.cycleOrder?.toString().orEmpty(), description = d.description.orEmpty(), complete = d.complete, + ) + } +} + +internal fun seriesTypeOptions(lookups: StaffLookups): List = + lookups.seriesTypeOptions.ifEmpty { DEFAULT_SERIES_TYPES.map { LookupOption(it, it.replace('_', ' ').replaceFirstChar(Char::uppercase)) } } + +// ============================================================================================= +// List +// ============================================================================================= + +data class SeriesListUiState( + val roots: List = emptyList(), + val query: String = "", + val expanded: Set = emptySet(), + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + val lookups: StaffLookups = StaffLookups(), + val creating: SeriesForm? = null, + val saving: Boolean = false, + val formError: StaffMessage? = null, + val duplicate: IdName? = null, + val createdId: Int? = null, +) + +@HiltViewModel +class StaffSeriesViewModel @Inject constructor( + private val content: StaffContentRepository, + private val staff: StaffRepository, +) : ViewModel() { + private val _state = MutableStateFlow(SeriesListUiState()) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { (staff.lookups() as? ApiResult.Success)?.data?.let { l -> _state.update { it.copy(lookups = l) } } } + } + + fun reload() { + _state.update { it.copy(loading = it.roots.isEmpty() && !it.refreshing, error = null) } + viewModelScope.launch { + val res = content.series() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(roots = res.data, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + reload() + } + + fun onQuery(value: String) = _state.update { it.copy(query = value) } + fun toggle(id: Int) = _state.update { s -> s.copy(expanded = if (id in s.expanded) s.expanded - id else s.expanded + id) } + + fun startCreate() = _state.update { it.copy(creating = SeriesForm(name = it.query.trim()), formError = null) } + fun edit(transform: (SeriesForm) -> SeriesForm) = _state.update { s -> s.copy(creating = s.creating?.let(transform), formError = null) } + fun closeCreate() = _state.update { it.copy(creating = null, duplicate = null) } + + fun create() { + val form = _state.value.creating ?: return + if (!form.valid || _state.value.saving) return + _state.update { it.copy(saving = true) } + viewModelScope.launch { + val res = content.saveSeries(null, form.toWrite()) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(saving = false, creating = null, createdId = res.data.id) + is ApiResult.Failure -> when { + res.isForbidden() -> it.copy(saving = false, forbidden = true) + res.isDuplicate() -> it.copy(saving = false, duplicate = content.existingSeries(res) ?: IdName(0, form.name)) + else -> it.copy(saving = false, formError = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + } + + fun consumeCreated() = _state.update { it.copy(createdId = null) } + fun dismissDuplicate() = _state.update { it.copy(duplicate = null) } +} + +/** Series as a tree (universes, cycles, spin-offs), searchable, with a create sheet. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffSeriesScreen(onNavigateUp: () -> Unit, onOpen: (Int) -> Unit) { + val vm: StaffSeriesViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.series, onNavigateUp) + LaunchedEffect(Unit) { vm.reload() } + LaunchedEffect(state.createdId) { state.createdId?.let { vm.consumeCreated(); onOpen(it) } } + val rows = remember(state.roots, state.expanded, state.query) { SeriesTree.rows(state.roots, state.expanded, state.query) } + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_series), onNavigateUp = onNavigateUp) }, + floatingActionButton = { StaffFab(Icons.Outlined.Add, stringResource(R.string.staff_series_new), vm::startCreate) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + SearchField( + query = state.query, onQueryChange = vm::onQuery, placeholder = stringResource(R.string.staff_series_search), + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg, vertical = Spacing.sm), + ) + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize()) { + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = rows.isEmpty(), + emptyTitle = stringResource(R.string.staff_series_empty), emptyIcon = Icons.Outlined.CollectionsBookmark, onRetry = vm::refresh, + ) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp)) { + items(rows, key = { it.series.id }) { row -> + Surface( + onClick = { onOpen(row.series.id) }, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(start = (row.depth * 20).dp, top = Spacing.xs, bottom = Spacing.xs), + ) { + Row(Modifier.padding(start = Spacing.lg, top = Spacing.sm, bottom = Spacing.sm, end = Spacing.xs), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(row.series.name, style = MaterialTheme.typography.bodyLarge, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text( + listOfNotNull( + seriesTypeOptions(state.lookups).firstOrNull { it.value == row.series.type }?.label, + stringResource(R.string.staff_series_book_count, row.series.bookCount), + if (row.series.complete) stringResource(R.string.staff_series_complete) else null, + ).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (row.hasChildren && state.query.isBlank()) { + IconButton(onClick = { vm.toggle(row.series.id) }) { + Icon( + if (row.expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, + contentDescription = stringResource(if (row.expanded) R.string.staff_collapse else R.string.staff_expand), + ) + } + } + } + } + } + } + } + } + } + } + + state.creating?.let { form -> + SeriesFormSheet( + title = stringResource(R.string.staff_series_new), form = form, lookups = state.lookups, saving = state.saving, + error = state.formError, nameEditable = true, onEdit = vm::edit, onSave = vm::create, onDismiss = vm::closeCreate, + ) + } + state.duplicate?.let { existing -> + ConfirmDialog( + title = stringResource(R.string.staff_record_duplicate_title), + body = stringResource(R.string.staff_series_exists, existing.name), + confirmLabel = stringResource(R.string.staff_series_open_existing), + onConfirm = { vm.dismissDuplicate(); vm.closeCreate(); if (existing.id > 0) onOpen(existing.id) }, + onDismiss = vm::dismissDuplicate, + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SeriesFormSheet( + title: String, + form: SeriesForm, + lookups: StaffLookups, + saving: Boolean, + error: StaffMessage?, + nameEditable: Boolean, + onEdit: ((SeriesForm) -> SeriesForm) -> Unit, + onSave: () -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { if (!saving) onDismiss() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(title, style = MaterialTheme.typography.headlineSmall) + error?.let { InlineErrorBanner(message = it.resolve()) } + PinakesTextField( + value = form.name, onValueChange = { v -> onEdit { it.copy(name = v) } }, label = stringResource(R.string.staff_field_name), + maxLength = 100, enabled = nameEditable, modifier = Modifier.fillMaxWidth(), + supportingText = if (nameEditable) "" else stringResource(R.string.staff_series_rename_hint), + ) + val types = seriesTypeOptions(lookups) + LookupField( + label = stringResource(R.string.staff_series_type), value = form.type, + displayValue = types.firstOrNull { it.value == form.type }?.label ?: form.type, options = types, + editable = false, allowClear = false, onValueChange = { v -> onEdit { it.copy(type = v) } }, modifier = Modifier.fillMaxWidth(), + ) + PinakesTextField(value = form.parentName, onValueChange = { v -> onEdit { it.copy(parentName = v) } }, + label = stringResource(R.string.staff_series_parent), supportingText = stringResource(R.string.staff_series_parent_hint), modifier = Modifier.fillMaxWidth()) + PinakesTextField(value = form.group, onValueChange = { v -> onEdit { it.copy(group = v) } }, label = stringResource(R.string.staff_series_group), modifier = Modifier.fillMaxWidth()) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + PinakesTextField(value = form.cycle, onValueChange = { v -> onEdit { it.copy(cycle = v) } }, label = stringResource(R.string.staff_series_cycle), modifier = Modifier.weight(2f)) + PinakesTextField( + value = form.cycleOrder, onValueChange = { v -> onEdit { it.copy(cycleOrder = v.filter(Char::isDigit)) } }, + label = stringResource(R.string.staff_series_cycle_order), isError = !form.cycleOrderValid, errorText = stringResource(R.string.staff_field_invalid), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), maxLength = 5, modifier = Modifier.weight(1f), + ) + } + PinakesTextField(value = form.description, onValueChange = { v -> onEdit { it.copy(description = v) } }, + label = stringResource(R.string.staff_field_description), singleLine = false, modifier = Modifier.fillMaxWidth().heightIn(min = 96.dp)) + SwitchRow(stringResource(R.string.staff_series_complete), form.complete, { v -> onEdit { it.copy(complete = v) } }) + PrimaryButton(stringResource(R.string.action_save), onClick = onSave, loading = saving, enabled = form.valid, modifier = Modifier.fillMaxWidth().testTag("series-save")) + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +// ============================================================================================= +// Detail +// ============================================================================================= + +enum class SeriesDialog { RENAME, MERGE, DELETE, PARENT_WORK, ADD_BOOKS, EDIT } + +data class SeriesDetailUiState( + val detail: StaffSeriesDetail? = null, + val loading: Boolean = true, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + val lookups: StaffLookups = StaffLookups(), + val dialog: SeriesDialog? = null, + val form: SeriesForm? = null, + val busy: Boolean = false, + val formError: StaffMessage? = null, + /** Rename target that already exists: ask before merging into it. */ + val renameClash: Pair? = null, + val numbering: SeriesBook? = null, + val mergeTargets: List = emptyList(), + val selectedBooks: Set = emptySet(), + val message: StaffMessage? = null, + /** Set when the series no longer exists here (merged or deleted): the screen moves on. */ + val goneTo: Int? = null, + val openBook: Int? = null, +) + +@HiltViewModel +class StaffSeriesDetailViewModel @Inject constructor( + private val content: StaffContentRepository, + private val staff: StaffRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val seriesId: Int = savedState.get(Routes.ARG_SERIES_ID) ?: 0 + private val _state = MutableStateFlow(SeriesDetailUiState()) + val state: StateFlow = _state.asStateFlow() + + val books = SearchController(viewModelScope, minLength = 2) { q -> + when (val res = staff.books(q, cursor = null, limit = 30)) { + is ApiResult.Success -> ApiResult.Success(res.data.items.map { PickItem(it.id, it.title, it.author) }) + is ApiResult.Failure -> res + } + }.also { c -> c.onFailure = { f -> if (f.isForbidden()) _state.update { it.copy(forbidden = true) } } } + + init { + load() + viewModelScope.launch { (staff.lookups() as? ApiResult.Success)?.data?.let { l -> _state.update { it.copy(lookups = l) } } } + } + + fun load() { + viewModelScope.launch { + val res = content.seriesDetail(seriesId) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(detail = res.data, loading = false, error = null) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun open(dialog: SeriesDialog) { + val d = _state.value.detail ?: return + _state.update { it.copy(dialog = dialog, formError = null, form = if (dialog == SeriesDialog.EDIT) SeriesForm.from(d) else null, selectedBooks = emptySet()) } + if (dialog == SeriesDialog.MERGE) viewModelScope.launch { + (content.series() as? ApiResult.Success)?.data?.let { roots -> + val excluded = SeriesTree.subtreeIds(roots, d.id) + _state.update { it.copy(mergeTargets = SeriesTree.flatten(roots).filter { s -> s.id !in excluded }) } + } + } + } + + fun close() { + _state.update { it.copy(dialog = null, form = null, formError = null, renameClash = null, numbering = null) } + books.clear() + } + + fun editForm(transform: (SeriesForm) -> SeriesForm) = _state.update { s -> s.copy(form = s.form?.let(transform), formError = null) } + + fun saveEdit() { + val form = _state.value.form ?: return + if (!form.valid) return + mutate(R.string.staff_series_saved) { content.saveSeries(seriesId, form.toWrite()) } + } + + fun rename(name: String, merge: Boolean = false) { + val clean = name.trim() + if (clean.isEmpty() || _state.value.busy) return + _state.update { it.copy(busy = true, formError = null) } + viewModelScope.launch { + val res = content.renameSeries(seriesId, clean, merge) + _state.update { + when (res) { + is ApiResult.Success -> { + val merged = res.meta?.merged == true + it.copy( + busy = false, dialog = null, renameClash = null, detail = res.data, + message = StaffMessage(if (merged) R.string.staff_series_merged else R.string.staff_series_renamed), + goneTo = if (merged && res.data.id != seriesId) res.data.id else null, + ) + } + is ApiResult.Failure -> when { + res.isForbidden() -> it.copy(busy = false, forbidden = true) + res.isDuplicate() -> it.copy(busy = false, renameClash = clean to (content.existingSeries(res) ?: IdName(0, clean))) + else -> it.copy(busy = false, formError = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + } + + fun merge(targetId: Int) { + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = content.mergeSeries(seriesId, targetId) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, dialog = null, goneTo = res.data.target.id.takeIf { id -> id > 0 } ?: targetId, + message = StaffMessage(R.string.staff_series_merged_count, args = listOf(res.data.booksMoved))) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), formError = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + + fun delete() { + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = content.deleteSeries(seriesId) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, dialog = null, goneTo = 0) + is ApiResult.Failure -> it.copy(busy = false, dialog = null, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + + fun parentWork(title: String) { + if (title.isBlank() || _state.value.busy) return + _state.update { it.copy(busy = true, formError = null) } + viewModelScope.launch { + val res = content.parentWork(seriesId, title) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, dialog = null, openBook = res.data.bookId.takeIf { id -> id > 0 }, + message = StaffMessage(R.string.staff_series_parent_work_created)) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), formError = res.toStaffMessage(R.string.staff_error_save)) + } + } + if (res is ApiResult.Success) load() + } + } + + fun toggleBook(item: PickItem) = _state.update { s -> + s.copy(selectedBooks = if (item.id in s.selectedBooks) s.selectedBooks - item.id else s.selectedBooks + item.id) + } + + fun assignSelected() { + val ids = _state.value.selectedBooks.toList() + if (ids.isEmpty() || _state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = content.assignBooks(seriesId, ids) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, dialog = null, selectedBooks = emptySet(), + message = StaffMessage(R.string.staff_series_books_added, args = listOf(res.data.assigned))) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + books.clear() + if (res is ApiResult.Success) load() + } + } + + fun openNumbering(book: SeriesBook) = _state.update { it.copy(numbering = book) } + + fun setNumber(book: SeriesBook, number: String) = + mutate(R.string.staff_series_number_saved) { content.setSeriesNumber(seriesId, book.id, number) } + + fun removeBook(book: SeriesBook) = + mutate(R.string.staff_series_book_removed) { content.removeFromSeries(seriesId, book.id) } + + private fun mutate(success: Int, call: suspend () -> ApiResult) { + if (_state.value.busy) return + _state.update { it.copy(busy = true, formError = null) } + viewModelScope.launch { + val res = call() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, detail = res.data, dialog = null, form = null, numbering = null, message = StaffMessage(success)) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), formError = res.toStaffMessage(R.string.staff_error_save), + message = if (it.dialog == null && it.numbering == null) res.toStaffMessage(R.string.staff_error_save) else null) + } + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } + fun consumeGone() = _state.update { it.copy(goneTo = null) } + fun consumeOpenBook() = _state.update { it.copy(openBook = null) } +} + +/** A series: facts, its books in order (number, remove), and rename / merge / delete / parent work. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffSeriesDetailScreen( + onNavigateUp: () -> Unit, + onOpenSeries: (Int) -> Unit, + onOpenBook: (Int) -> Unit, +) { + val vm: StaffSeriesDetailViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + var menu by remember { mutableStateOf(false) } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.series, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + LaunchedEffect(state.goneTo) { + when (val target = state.goneTo) { + null -> Unit + 0 -> { vm.consumeGone(); onNavigateUp() } + else -> { vm.consumeGone(); onOpenSeries(target) } + } + } + LaunchedEffect(state.openBook) { state.openBook?.let { vm.consumeOpenBook(); onOpenBook(it) } } + + Scaffold( + topBar = { + PinakesTopBar( + title = state.detail?.name ?: stringResource(R.string.staff_series), + onNavigateUp = onNavigateUp, + actions = { + if (state.detail != null) { + IconButton(onClick = { menu = true }) { Icon(Icons.Outlined.MoreVert, contentDescription = stringResource(R.string.staff_more_actions)) } + DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) { + listOf( + SeriesDialog.EDIT to R.string.staff_series_edit, SeriesDialog.RENAME to R.string.staff_series_rename, + SeriesDialog.MERGE to R.string.staff_series_merge, SeriesDialog.PARENT_WORK to R.string.staff_series_parent_work, + SeriesDialog.DELETE to R.string.staff_series_delete, + ).forEach { (dialog, label) -> + DropdownMenuItem(text = { Text(stringResource(label)) }, onClick = { menu = false; vm.open(dialog) }) + } + } + } + }, + ) + }, + floatingActionButton = { if (state.detail != null) StaffFab(Icons.Outlined.Add, stringResource(R.string.staff_series_add_books)) { vm.open(SeriesDialog.ADD_BOOKS) } }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + val d = state.detail + when { + state.loading -> LoadingState(modifier = Modifier.padding(padding)) + d == null -> ErrorState(message = state.error?.resolve() ?: stringResource(R.string.staff_error_load), onRetry = vm::load, modifier = Modifier.padding(padding)) + else -> LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp)) { + item(key = "facts") { + InfoCard(Modifier.padding(top = Spacing.sm)) { + InfoLine(stringResource(R.string.staff_series_type), seriesTypeOptions(state.lookups).firstOrNull { it.value == d.type }?.label ?: d.type) + InfoLine(stringResource(R.string.staff_series_parent), d.parentName) + InfoLine(stringResource(R.string.staff_series_group), d.group) + InfoLine(stringResource(R.string.staff_series_cycle), listOfNotNull(d.cycle, d.cycleOrder?.toString()).joinToString(" · ").ifBlank { null }) + InfoLine(stringResource(R.string.staff_series_numbers), listOfNotNull(d.minNumber, d.maxNumber).distinct().joinToString("–").ifBlank { null }) + InfoLine(stringResource(R.string.staff_series_complete), if (d.complete) stringResource(R.string.staff_yes) else null) + d.description?.takeIf { it.isNotBlank() }?.let { Text(it, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = Spacing.sm)) } + } + } + if (d.children.isNotEmpty()) { + item(key = "children-title") { FormSectionTitle(stringResource(R.string.staff_series_children)) } + items(d.children, key = { "c${it.id}" }) { child -> + SecondaryButton(child.name, onClick = { onOpenSeries(child.id) }, modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs)) + } + } + item(key = "books-title") { FormSectionTitle(stringResource(R.string.staff_series_books, d.books.size)) } + items(d.books, key = { "b${it.id}" }) { book -> + MediaRow( + title = book.title, coverUrl = book.coverUrl, + line1 = book.seriesNumber?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.staff_series_number_value, it) } + ?: stringResource(R.string.staff_series_no_number), + line2 = book.year, onClick = { vm.openNumbering(book) }, + modifier = Modifier.padding(vertical = Spacing.xs).testTag("series-book-${book.id}"), + ) + } + } + } + } + + val d = state.detail ?: return + when (state.dialog) { + SeriesDialog.EDIT -> state.form?.let { form -> + SeriesFormSheet( + title = stringResource(R.string.staff_series_edit), form = form, lookups = state.lookups, saving = state.busy, error = state.formError, + nameEditable = false, onEdit = vm::editForm, onSave = vm::saveEdit, onDismiss = vm::close, + ) + } + SeriesDialog.RENAME -> TextInputDialog( + title = stringResource(R.string.staff_series_rename), label = stringResource(R.string.staff_field_name), initial = d.name, + confirm = stringResource(R.string.staff_series_rename), busy = state.busy, error = state.formError, + onConfirm = { vm.rename(it) }, onDismiss = vm::close, + ) + SeriesDialog.PARENT_WORK -> TextInputDialog( + title = stringResource(R.string.staff_series_parent_work), label = stringResource(R.string.staff_field_title), initial = d.name, + body = stringResource(R.string.staff_series_parent_work_body), confirm = stringResource(R.string.staff_series_parent_work_create), + busy = state.busy, error = state.formError, onConfirm = vm::parentWork, onDismiss = vm::close, + ) + SeriesDialog.DELETE -> ConfirmDialog( + title = stringResource(R.string.staff_series_delete), body = stringResource(R.string.staff_series_delete_body, d.name, d.books.size), + confirmLabel = stringResource(R.string.staff_series_delete), onConfirm = vm::delete, onDismiss = vm::close, + ) + SeriesDialog.MERGE -> MergeTargetSheet(state, vm) + SeriesDialog.ADD_BOOKS -> { + val s by vm.books.state.collectAsStateWithLifecycle() + SearchPickerSheet( + title = stringResource(R.string.staff_series_add_books), placeholder = stringResource(R.string.staff_books_search), state = s, + onQuery = vm.books::onQuery, onPick = vm::toggleBook, onDismiss = vm::close, selected = state.selectedBooks, + onConfirm = vm::assignSelected, hint = stringResource(R.string.staff_search_min_chars), + ) + } + null -> Unit + } + state.renameClash?.let { (name, existing) -> + ConfirmDialog( + title = stringResource(R.string.staff_record_duplicate_title), + body = stringResource(R.string.staff_series_rename_clash, name), + confirmLabel = stringResource(R.string.staff_series_merge_into, existing.name.ifBlank { name }), + onConfirm = { vm.rename(name, merge = true) }, onDismiss = vm::close, + ) + } + state.numbering?.let { book -> NumberDialog(book, state, vm, onOpenBook) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun MergeTargetSheet(state: SeriesDetailUiState, vm: StaffSeriesDetailViewModel) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + var query by rememberSaveable { mutableStateOf("") } + var target by remember { mutableStateOf(null) } + ModalBottomSheet(onDismissRequest = vm::close, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(stringResource(R.string.staff_series_merge), style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.staff_series_merge_body), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + state.formError?.let { InlineErrorBanner(message = it.resolve()) } + SearchField(query = query, onQueryChange = { query = it }, placeholder = stringResource(R.string.staff_series_search), modifier = Modifier.fillMaxWidth()) + LazyColumn(Modifier.fillMaxWidth().heightIn(max = 360.dp)) { + items(state.mergeTargets.filter { query.isBlank() || it.name.contains(query, ignoreCase = true) }, key = { it.id }) { s -> + Surface( + onClick = { target = s }, shape = MaterialTheme.shapes.medium, + color = if (target?.id == s.id) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs), + ) { + Text(s.name, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(Spacing.md)) + } + } + } + PrimaryButton( + target?.let { stringResource(R.string.staff_series_merge_into, it.name) } ?: stringResource(R.string.staff_series_merge), + onClick = { target?.let { vm.merge(it.id) } }, enabled = target != null, loading = state.busy, modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(Spacing.lg)) + } + } +} + +@Composable +private fun NumberDialog(book: SeriesBook, state: SeriesDetailUiState, vm: StaffSeriesDetailViewModel, onOpenBook: (Int) -> Unit) { + var number by rememberSaveable(book.id) { mutableStateOf(book.seriesNumber.orEmpty()) } + AlertDialog( + onDismissRequest = vm::close, + shape = MaterialTheme.shapes.large, + title = { Text(book.title, style = MaterialTheme.typography.titleMedium, maxLines = 2, overflow = TextOverflow.Ellipsis) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + state.formError?.let { InlineErrorBanner(message = it.resolve()) } + PinakesTextField(value = number, onValueChange = { number = it }, label = stringResource(R.string.staff_field_series_number), maxLength = 50, + supportingText = stringResource(R.string.staff_series_number_hint), modifier = Modifier.fillMaxWidth()) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + PinakesTextButton(stringResource(R.string.staff_series_remove_book), onClick = { vm.removeBook(book) }) + Spacer(Modifier.width(Spacing.xs)) + PinakesTextButton(stringResource(R.string.staff_open_book), onClick = { vm.close(); onOpenBook(book.id) }) + } + } + }, + confirmButton = { PrimaryButton(stringResource(R.string.action_save), onClick = { vm.setNumber(book, number) }, loading = state.busy) }, + dismissButton = { PinakesTextButton(stringResource(R.string.action_cancel), onClick = vm::close) }, + ) +} + +/** A one-field dialog (rename, parent work title). */ +@Composable +internal fun TextInputDialog( + title: String, + label: String, + initial: String, + confirm: String, + busy: Boolean, + error: StaffMessage?, + onConfirm: (String) -> Unit, + onDismiss: () -> Unit, + body: String? = null, +) { + var value by rememberSaveable { mutableStateOf(initial) } + AlertDialog( + onDismissRequest = onDismiss, + shape = MaterialTheme.shapes.large, + title = { Text(title, style = MaterialTheme.typography.titleMedium) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + body?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + error?.let { InlineErrorBanner(message = it.resolve()) } + PinakesTextField(value = value, onValueChange = { value = it }, label = label, maxLength = 255, modifier = Modifier.fillMaxWidth().testTag("text-input")) + } + }, + confirmButton = { PrimaryButton(confirm, onClick = { onConfirm(value) }, enabled = value.isNotBlank(), loading = busy) }, + dismissButton = { PinakesTextButton(stringResource(R.string.action_cancel), onClick = onDismiss) }, + ) +} diff --git a/app/src/test/java/com/pinakes/app/staff/SeriesTreeTest.kt b/app/src/test/java/com/pinakes/app/staff/SeriesTreeTest.kt new file mode 100644 index 0000000..0fc1c15 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/SeriesTreeTest.kt @@ -0,0 +1,71 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.StaffSeries +import com.pinakes.app.data.staff.SeriesTree +import com.pinakes.app.ui.screens.staff.content.LabelForm +import com.pinakes.app.ui.screens.staff.content.SeriesForm +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The series tree rows, merge-target exclusion, and the series/label forms. */ +class SeriesTreeTest { + + private val tree = listOf( + StaffSeries(id = 1, name = "Discworld", children = listOf( + StaffSeries(id = 2, name = "Rincewind", children = listOf(StaffSeries(id = 3, name = "Luggage"))), + StaffSeries(id = 4, name = "Watch"), + )), + StaffSeries(id = 5, name = "Gialli"), + ) + + @Test fun collapsedShowsRootsOnly() { + val rows = SeriesTree.rows(tree, expanded = emptySet()) + assertEquals(listOf(1, 5), rows.map { it.series.id }) + assertTrue(rows.first().hasChildren) + assertFalse(rows.first().expanded) + } + + @Test fun expandingRevealsChildrenWithDepth() { + val rows = SeriesTree.rows(tree, expanded = setOf(1)) + assertEquals(listOf(1, 2, 4, 5), rows.map { it.series.id }) + assertEquals(listOf(0, 1, 1, 0), rows.map { it.depth }) + assertEquals(listOf(1, 2, 3, 4, 5), SeriesTree.rows(tree, setOf(1, 2)).map { it.series.id }) + } + + @Test fun searchKeepsAncestorsOfDeepMatches() { + val rows = SeriesTree.rows(tree, expanded = emptySet(), query = "lugg") + assertEquals(listOf(1, 2, 3), rows.map { it.series.id }) + assertEquals(listOf(5), SeriesTree.rows(tree, emptySet(), "GIALLI").map { it.series.id }) + assertTrue(SeriesTree.rows(tree, emptySet(), "nothing").isEmpty()) + } + + @Test fun flattenAndSubtree() { + assertEquals(listOf(1, 2, 3, 4, 5), SeriesTree.flatten(tree).map { it.id }) + assertEquals(setOf(2, 3), SeriesTree.subtreeIds(tree, 2)) + assertEquals(setOf(99), SeriesTree.subtreeIds(tree, 99)) + } + + @Test fun seriesFormValidationAndWrite() { + assertFalse(SeriesForm(name = " ").valid) + assertFalse(SeriesForm(name = "x".repeat(101)).valid) + assertFalse(SeriesForm(name = "A", cycleOrder = "70000").valid) + val write = SeriesForm(name = " Discworld ", cycleOrder = "3", parentName = "", complete = true).toWrite() + assertEquals("Discworld", write.name) + assertEquals(3, write.cycleOrder) + assertEquals("", write.parentName) + assertEquals(true, write.complete) + } + + @Test fun labelFormRanges() { + val ok = LabelForm(width = "70", height = "36,5", padding = "") + assertTrue(ok.valid) + assertEquals(36.5, ok.toSettings().height!!, 0.0) + assertEquals(null, ok.toSettings().padding) + assertFalse(LabelForm(width = "9", height = "30").valid) + assertFalse(LabelForm(width = "50", height = "101").valid) + assertFalse(LabelForm(width = "50", height = "30", padding = "25").valid) + assertEquals("70", LabelForm.from(ok.toSettings().copy(width = 70.0)).width) + } +} diff --git a/i18n/de.json b/i18n/de.json index 652912c..603debe 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1025,5 +1025,82 @@ "staff_reservations_empty": "Keine Vormerkungen", "staff_reservations_search": "Titel oder Nutzer", "staff_return_title": "Rücknahme erfassen", - "staff_err_idempotency_conflict": "Der Server hat diese Anfrage schon mit anderen Daten erhalten. Formular prüfen und erneut senden." + "staff_err_idempotency_conflict": "Der Server hat diese Anfrage schon mit anderen Daten erhalten. Formular prüfen und erneut senden.", + "staff_copy_no_code": "Exemplar ohne Code", + "staff_copy_edit": "Exemplar bearbeiten", + "staff_copy_add_with_code": "Ein Exemplar mit Code hinzufügen", + "staff_copy_code_new_hint": "Leer lassen, damit die Bibliothek es nummeriert.", + "staff_copy_status": "Zustand", + "staff_copy_added": "Exemplar hinzugefügt", + "staff_copy_saved": "Exemplar gespeichert", + "staff_copy_deleted": "Exemplar gelöscht", + "staff_copy_delete": "Exemplar löschen", + "staff_copy_delete_body": "Exemplar %1$s löschen? Nur ein nie ausgeliehenes Exemplar kann gelöscht werden.", + "staff_copy_code_taken": "Der Code %1$s wird bereits von einem anderen Exemplar verwendet.", + "staff_labels_book_pdf": "Buchetikett", + "staff_labels_copies_pdf": "Exemplaretiketten", + "staff_labels_this_copy": "Etikett für dieses Exemplar", + "staff_labels_ready": "Etiketten", + "staff_series_new": "Neue Reihe", + "staff_series_search": "Reihen suchen", + "staff_series_empty": "Noch keine Reihen", + "staff_series_book_count": "%1$d Bücher", + "staff_series_complete": "Vollständig", + "staff_series_exists": "Eine Reihe „%1$s“ gibt es schon. Öffnen?", + "staff_series_open_existing": "Öffnen", + "staff_series_type": "Typ", + "staff_series_parent": "Gehört zu", + "staff_series_parent_hint": "Name der übergeordneten Reihe (wird angelegt, falls sie fehlt).", + "staff_series_group": "Gruppe", + "staff_series_cycle": "Zyklus", + "staff_series_cycle_order": "Reihenfolge", + "staff_series_rename_hint": "Zum Ändern des Namens „Umbenennen“ verwenden.", + "staff_series_saved": "Reihe gespeichert", + "staff_series_edit": "Details bearbeiten", + "staff_series_rename": "Umbenennen", + "staff_series_renamed": "Reihe umbenannt", + "staff_series_rename_clash": "Eine Reihe „%1$s“ gibt es schon: beide zusammenführen?", + "staff_series_merge": "Mit einer anderen Reihe zusammenführen", + "staff_series_merge_body": "Die Bücher wandern in die gewählte Reihe, dann wird diese Reihe entfernt.", + "staff_series_merge_into": "Mit %1$s zusammenführen", + "staff_series_merged": "Reihen zusammengeführt", + "staff_series_merged_count": "Reihen zusammengeführt: %1$d Bücher verschoben", + "staff_series_delete": "Reihe löschen", + "staff_series_delete_body": "„%1$s“ löschen? Die %2$d Bücher bleiben ohne Reihe im Katalog.", + "staff_series_parent_work": "Übergeordnetes Werk anlegen", + "staff_series_parent_work_body": "Ein Katalogeintrag, der die ganze Reihe zusammenfasst, wie im Web.", + "staff_series_parent_work_create": "Anlegen", + "staff_series_parent_work_created": "Übergeordnetes Werk angelegt", + "staff_series_add_books": "Bücher hinzufügen", + "staff_series_books_added": "Bücher hinzugefügt: %1$d", + "staff_series_books": "Bücher (%1$d)", + "staff_series_children": "Untergeordnete Reihen", + "staff_series_numbers": "Nummern", + "staff_series_number_value": "Nr. %1$s", + "staff_series_no_number": "Ohne Nummer", + "staff_series_number_hint": "Leer löscht die Nummer.", + "staff_series_number_saved": "Nummer gespeichert", + "staff_series_remove_book": "Aus der Reihe entfernen", + "staff_series_book_removed": "Buch aus der Reihe entfernt", + "staff_yes": "Ja", + "staff_more_actions": "Weitere Aktionen", + "staff_expand": "Aufklappen", + "staff_collapse": "Zuklappen", + "staff_merge_pick": "Einträge wählen", + "staff_merge_manual_hint": "Zwei oder mehr gleiche Einträge wählen, dann bestimmen, welcher bleibt.", + "staff_merge_review": "Zusammenführung prüfen", + "staff_merge_suggested": "Mögliche Dubletten", + "staff_merge_no_duplicates": "Keine doppelten Autoren gefunden.", + "staff_merge_author_books": "%1$s (%2$d Bücher)", + "staff_merge_primary_hint": "Der gewählte Eintrag bleibt; die anderen werden zusammengeführt und ihre Bücher verschoben.", + "staff_merge_new_name": "Neuer Name (optional)", + "staff_merge_new_name_hint": "Leer behält den Namen des verbleibenden Eintrags.", + "staff_merge_action": "Zusammenführen", + "staff_merge_done": "%1$d Einträge zusammengeführt", + "staff_merge_genre_hint": "Bücher und Untergenres des ersten Genres wandern zum zweiten, dann wird das erste entfernt.", + "staff_merge_genre_source": "Zu entfernendes Genre", + "staff_merge_genre_target": "Verbleibendes Genre", + "staff_merge_genre_pick": "Genre wählen", + "staff_merge_genre_done": "Genres zusammengeführt: %1$d Bücher, %2$d Untergenres verschoben", + "staff_merge_genre_descendant": "Ein Genre kann nicht mit einem eigenen Untergenre zusammengeführt werden." } diff --git a/i18n/en.json b/i18n/en.json index 5176a75..5948c4b 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1025,5 +1025,82 @@ "staff_reservations_empty": "No reservations", "staff_reservations_search": "Title or borrower", "staff_return_title": "Record the return", - "staff_err_idempotency_conflict": "The server saw this request with different data. Check the form and send it again." + "staff_err_idempotency_conflict": "The server saw this request with different data. Check the form and send it again.", + "staff_copy_no_code": "Copy without a code", + "staff_copy_edit": "Edit copy", + "staff_copy_add_with_code": "Add a copy with its code", + "staff_copy_code_new_hint": "Leave empty to let the library number it.", + "staff_copy_status": "State", + "staff_copy_added": "Copy added", + "staff_copy_saved": "Copy saved", + "staff_copy_deleted": "Copy deleted", + "staff_copy_delete": "Delete copy", + "staff_copy_delete_body": "Delete copy %1$s? Only a copy that never circulated can be deleted.", + "staff_copy_code_taken": "The code %1$s is already used by another copy.", + "staff_labels_book_pdf": "Book label", + "staff_labels_copies_pdf": "Copy labels", + "staff_labels_this_copy": "Label for this copy", + "staff_labels_ready": "Labels", + "staff_series_new": "New series", + "staff_series_search": "Search series", + "staff_series_empty": "No series yet", + "staff_series_book_count": "%1$d books", + "staff_series_complete": "Complete", + "staff_series_exists": "A series called “%1$s” already exists. Open it?", + "staff_series_open_existing": "Open it", + "staff_series_type": "Type", + "staff_series_parent": "Part of", + "staff_series_parent_hint": "Name of the larger series (created if missing).", + "staff_series_group": "Group", + "staff_series_cycle": "Cycle", + "staff_series_cycle_order": "Order", + "staff_series_rename_hint": "To change the name, use Rename.", + "staff_series_saved": "Series saved", + "staff_series_edit": "Edit details", + "staff_series_rename": "Rename", + "staff_series_renamed": "Series renamed", + "staff_series_rename_clash": "A series called “%1$s” already exists: rename into it and merge the two?", + "staff_series_merge": "Merge into another series", + "staff_series_merge_body": "Its books move to the series you pick, then this series is removed.", + "staff_series_merge_into": "Merge into %1$s", + "staff_series_merged": "Series merged", + "staff_series_merged_count": "Series merged: %1$d books moved", + "staff_series_delete": "Delete series", + "staff_series_delete_body": "Delete “%1$s”? Its %2$d books stay in the catalogue without a series.", + "staff_series_parent_work": "Create the parent work", + "staff_series_parent_work_body": "A catalogue record that groups the whole series, as on the web.", + "staff_series_parent_work_create": "Create", + "staff_series_parent_work_created": "Parent work created", + "staff_series_add_books": "Add books", + "staff_series_books_added": "Books added: %1$d", + "staff_series_books": "Books (%1$d)", + "staff_series_children": "Series inside", + "staff_series_numbers": "Numbers", + "staff_series_number_value": "No. %1$s", + "staff_series_no_number": "No number", + "staff_series_number_hint": "Empty clears the number.", + "staff_series_number_saved": "Number saved", + "staff_series_remove_book": "Remove from series", + "staff_series_book_removed": "Book removed from the series", + "staff_yes": "Yes", + "staff_more_actions": "More actions", + "staff_expand": "Expand", + "staff_collapse": "Collapse", + "staff_merge_pick": "Pick records", + "staff_merge_manual_hint": "Pick two or more records that are the same, then choose which one stays.", + "staff_merge_review": "Review the merge", + "staff_merge_suggested": "Possible duplicates", + "staff_merge_no_duplicates": "No duplicate authors found.", + "staff_merge_author_books": "%1$s (%2$d books)", + "staff_merge_primary_hint": "The selected record stays; the others are merged into it and their books move.", + "staff_merge_new_name": "New name (optional)", + "staff_merge_new_name_hint": "Empty keeps the name of the record that stays.", + "staff_merge_action": "Merge", + "staff_merge_done": "%1$d records merged", + "staff_merge_genre_hint": "The books and subgenres of the first genre move to the second, then the first is removed.", + "staff_merge_genre_source": "Genre to remove", + "staff_merge_genre_target": "Genre that stays", + "staff_merge_genre_pick": "Pick a genre", + "staff_merge_genre_done": "Genres merged: %1$d books, %2$d subgenres moved", + "staff_merge_genre_descendant": "A genre cannot be merged into one of its own subgenres." } diff --git a/i18n/fr.json b/i18n/fr.json index d72c477..960e930 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1025,5 +1025,82 @@ "staff_reservations_empty": "Aucune réservation", "staff_reservations_search": "Titre ou lecteur", "staff_return_title": "Enregistrer le retour", - "staff_err_idempotency_conflict": "Le serveur a déjà reçu cette demande avec d’autres données. Vérifiez le formulaire et renvoyez-le." + "staff_err_idempotency_conflict": "Le serveur a déjà reçu cette demande avec d’autres données. Vérifiez le formulaire et renvoyez-le.", + "staff_copy_no_code": "Exemplaire sans code", + "staff_copy_edit": "Modifier l’exemplaire", + "staff_copy_add_with_code": "Ajouter un exemplaire avec son code", + "staff_copy_code_new_hint": "Laissez vide pour que la bibliothèque le numérote.", + "staff_copy_status": "État", + "staff_copy_added": "Exemplaire ajouté", + "staff_copy_saved": "Exemplaire enregistré", + "staff_copy_deleted": "Exemplaire supprimé", + "staff_copy_delete": "Supprimer l’exemplaire", + "staff_copy_delete_body": "Supprimer l’exemplaire %1$s ? Seul un exemplaire jamais prêté peut être supprimé.", + "staff_copy_code_taken": "Le code %1$s est déjà utilisé par un autre exemplaire.", + "staff_labels_book_pdf": "Étiquette du livre", + "staff_labels_copies_pdf": "Étiquettes des exemplaires", + "staff_labels_this_copy": "Étiquette de cet exemplaire", + "staff_labels_ready": "Étiquettes", + "staff_series_new": "Nouvelle collection", + "staff_series_search": "Rechercher une collection", + "staff_series_empty": "Aucune collection pour l’instant", + "staff_series_book_count": "%1$d livres", + "staff_series_complete": "Complète", + "staff_series_exists": "Une collection « %1$s » existe déjà. L’ouvrir ?", + "staff_series_open_existing": "L’ouvrir", + "staff_series_type": "Type", + "staff_series_parent": "Fait partie de", + "staff_series_parent_hint": "Nom de la collection plus large (créée si elle manque).", + "staff_series_group": "Groupe", + "staff_series_cycle": "Cycle", + "staff_series_cycle_order": "Ordre", + "staff_series_rename_hint": "Pour changer le nom, utilisez Renommer.", + "staff_series_saved": "Collection enregistrée", + "staff_series_edit": "Modifier les détails", + "staff_series_rename": "Renommer", + "staff_series_renamed": "Collection renommée", + "staff_series_rename_clash": "Une collection « %1$s » existe déjà : fusionner les deux ?", + "staff_series_merge": "Fusionner avec une autre collection", + "staff_series_merge_body": "Ses livres passent dans la collection choisie, puis celle-ci est supprimée.", + "staff_series_merge_into": "Fusionner avec %1$s", + "staff_series_merged": "Collections fusionnées", + "staff_series_merged_count": "Collections fusionnées : %1$d livres déplacés", + "staff_series_delete": "Supprimer la collection", + "staff_series_delete_body": "Supprimer « %1$s » ? Ses %2$d livres restent au catalogue sans collection.", + "staff_series_parent_work": "Créer l’œuvre mère", + "staff_series_parent_work_body": "Une notice qui regroupe toute la collection, comme sur le web.", + "staff_series_parent_work_create": "Créer", + "staff_series_parent_work_created": "Œuvre mère créée", + "staff_series_add_books": "Ajouter des livres", + "staff_series_books_added": "Livres ajoutés : %1$d", + "staff_series_books": "Livres (%1$d)", + "staff_series_children": "Collections internes", + "staff_series_numbers": "Numéros", + "staff_series_number_value": "N° %1$s", + "staff_series_no_number": "Sans numéro", + "staff_series_number_hint": "Vide efface le numéro.", + "staff_series_number_saved": "Numéro enregistré", + "staff_series_remove_book": "Retirer de la collection", + "staff_series_book_removed": "Livre retiré de la collection", + "staff_yes": "Oui", + "staff_more_actions": "Autres actions", + "staff_expand": "Déplier", + "staff_collapse": "Replier", + "staff_merge_pick": "Choisir les notices", + "staff_merge_manual_hint": "Choisissez deux notices ou plus qui sont la même, puis laquelle reste.", + "staff_merge_review": "Vérifier la fusion", + "staff_merge_suggested": "Doublons possibles", + "staff_merge_no_duplicates": "Aucun auteur en double trouvé.", + "staff_merge_author_books": "%1$s (%2$d livres)", + "staff_merge_primary_hint": "La notice choisie reste ; les autres y sont fusionnées et leurs livres déplacés.", + "staff_merge_new_name": "Nouveau nom (facultatif)", + "staff_merge_new_name_hint": "Vide garde le nom de la notice qui reste.", + "staff_merge_action": "Fusionner", + "staff_merge_done": "%1$d notices fusionnées", + "staff_merge_genre_hint": "Les livres et sous-genres du premier genre passent au second, puis le premier est supprimé.", + "staff_merge_genre_source": "Genre à supprimer", + "staff_merge_genre_target": "Genre conservé", + "staff_merge_genre_pick": "Choisir un genre", + "staff_merge_genre_done": "Genres fusionnés : %1$d livres, %2$d sous-genres déplacés", + "staff_merge_genre_descendant": "Un genre ne peut pas être fusionné avec l’un de ses sous-genres." } diff --git a/i18n/it.json b/i18n/it.json index ecca522..6810276 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1025,5 +1025,82 @@ "staff_reservations_empty": "Nessuna prenotazione", "staff_reservations_search": "Titolo o utente", "staff_return_title": "Registra la restituzione", - "staff_err_idempotency_conflict": "Il server ha già ricevuto questa richiesta con dati diversi. Controlla il modulo e invialo di nuovo." + "staff_err_idempotency_conflict": "Il server ha già ricevuto questa richiesta con dati diversi. Controlla il modulo e invialo di nuovo.", + "staff_copy_no_code": "Copia senza codice", + "staff_copy_edit": "Modifica copia", + "staff_copy_add_with_code": "Aggiungi una copia con il suo codice", + "staff_copy_code_new_hint": "Lascia vuoto per farla numerare dalla biblioteca.", + "staff_copy_status": "Stato", + "staff_copy_added": "Copia aggiunta", + "staff_copy_saved": "Copia salvata", + "staff_copy_deleted": "Copia eliminata", + "staff_copy_delete": "Elimina copia", + "staff_copy_delete_body": "Eliminare la copia %1$s? Si può eliminare solo una copia mai andata in prestito.", + "staff_copy_code_taken": "Il codice %1$s è già usato da un’altra copia.", + "staff_labels_book_pdf": "Etichetta del libro", + "staff_labels_copies_pdf": "Etichette delle copie", + "staff_labels_this_copy": "Etichetta di questa copia", + "staff_labels_ready": "Etichette", + "staff_series_new": "Nuova collana", + "staff_series_search": "Cerca collane", + "staff_series_empty": "Ancora nessuna collana", + "staff_series_book_count": "%1$d libri", + "staff_series_complete": "Completa", + "staff_series_exists": "Esiste già una collana «%1$s». Aprirla?", + "staff_series_open_existing": "Aprila", + "staff_series_type": "Tipo", + "staff_series_parent": "Fa parte di", + "staff_series_parent_hint": "Nome della collana più ampia (creata se manca).", + "staff_series_group": "Gruppo", + "staff_series_cycle": "Ciclo", + "staff_series_cycle_order": "Ordine", + "staff_series_rename_hint": "Per cambiare il nome usa Rinomina.", + "staff_series_saved": "Collana salvata", + "staff_series_edit": "Modifica i dettagli", + "staff_series_rename": "Rinomina", + "staff_series_renamed": "Collana rinominata", + "staff_series_rename_clash": "Esiste già una collana «%1$s»: unire le due collane?", + "staff_series_merge": "Unisci a un’altra collana", + "staff_series_merge_body": "I suoi libri passano alla collana scelta, poi questa viene eliminata.", + "staff_series_merge_into": "Unisci a %1$s", + "staff_series_merged": "Collane unite", + "staff_series_merged_count": "Collane unite: %1$d libri spostati", + "staff_series_delete": "Elimina collana", + "staff_series_delete_body": "Eliminare «%1$s»? I suoi %2$d libri restano in catalogo senza collana.", + "staff_series_parent_work": "Crea l’opera madre", + "staff_series_parent_work_body": "Una scheda di catalogo che raccoglie l’intera collana, come sul web.", + "staff_series_parent_work_create": "Crea", + "staff_series_parent_work_created": "Opera madre creata", + "staff_series_add_books": "Aggiungi libri", + "staff_series_books_added": "Libri aggiunti: %1$d", + "staff_series_books": "Libri (%1$d)", + "staff_series_children": "Collane interne", + "staff_series_numbers": "Numeri", + "staff_series_number_value": "N. %1$s", + "staff_series_no_number": "Senza numero", + "staff_series_number_hint": "Vuoto cancella il numero.", + "staff_series_number_saved": "Numero salvato", + "staff_series_remove_book": "Togli dalla collana", + "staff_series_book_removed": "Libro tolto dalla collana", + "staff_yes": "Sì", + "staff_more_actions": "Altre azioni", + "staff_expand": "Espandi", + "staff_collapse": "Comprimi", + "staff_merge_pick": "Scegli le schede", + "staff_merge_manual_hint": "Scegli due o più schede che sono la stessa, poi quale resta.", + "staff_merge_review": "Rivedi l’unione", + "staff_merge_suggested": "Possibili duplicati", + "staff_merge_no_duplicates": "Nessun autore duplicato trovato.", + "staff_merge_author_books": "%1$s (%2$d libri)", + "staff_merge_primary_hint": "La scheda selezionata resta; le altre vengono unite e i loro libri spostati.", + "staff_merge_new_name": "Nuovo nome (facoltativo)", + "staff_merge_new_name_hint": "Vuoto mantiene il nome della scheda che resta.", + "staff_merge_action": "Unisci", + "staff_merge_done": "%1$d schede unite", + "staff_merge_genre_hint": "I libri e i sottogeneri del primo genere passano al secondo, poi il primo viene eliminato.", + "staff_merge_genre_source": "Genere da eliminare", + "staff_merge_genre_target": "Genere che resta", + "staff_merge_genre_pick": "Scegli un genere", + "staff_merge_genre_done": "Generi uniti: %1$d libri, %2$d sottogeneri spostati", + "staff_merge_genre_descendant": "Un genere non si può unire a un suo sottogenere." } From 3a44e5e8b13b5f9ae64263d8c32342402badeaa2 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 01:57:41 +0200 Subject: [PATCH 38/62] Import catalogue files and fill in covers from the staff area A CSV or LibraryThing export is picked with the system document picker, checked for size and extension, and uploaded with the scraping choice and the field families to update. The job then runs chunk by chunk from the server's own progress: an out-of-order answer continues from where the server is, a busy job is retried after a pause, and the job id lives in the route so it resumes after the process is killed; pause, resume and cancel are there, with the counters and the error lines. Past imports offer their error CSV. Missing covers are fetched one book at a time with progress, stop and the rate limit honoured; the server-side sync and an enrichment batch use the long-timeout client behind a confirmation, and automatic enrichment has its switch. A new loan or reservation is also retried once with the same key on a 409 concurrent_retry. Switch rows now toggle on the whole row. --- .../assets/fixtures/cover_fetched.json | 9 + .../assets/fixtures/enrich_cron_on.json | 7 + .../assets/fixtures/enrich_overview.json | 13 + .../assets/fixtures/import_job_chunk_10.json | 25 + .../assets/fixtures/import_job_chunk_30.json | 31 ++ .../assets/fixtures/import_job_created.json | 25 + .../fixtures/import_out_of_order_20.json | 10 + .../assets/fixtures/imports_overview.json | 47 ++ .../assets/fixtures/missing_covers.json | 20 + .../com/pinakes/app/staffe2e/AppFlowTest.kt | 12 + .../pinakes/app/staffe2e/CoversFlowTest.kt | 31 ++ .../com/pinakes/app/staffe2e/FakePinakes.kt | 9 + .../pinakes/app/staffe2e/ImportFlowTest.kt | 94 ++++ .../pinakes/app/data/staff/ImportProgress.kt | 55 ++ .../app/ui/navigation/PinakesNavHost.kt | 16 + .../app/ui/screens/staff/StaffCommon.kt | 11 +- .../circulation/StaffLoanCreateScreen.kt | 6 +- .../staff/content/StaffCoversScreen.kt | 279 ++++++++++ .../staff/content/StaffImportsScreens.kt | 480 ++++++++++++++++++ .../pinakes/app/staff/ImportProgressTest.kt | 56 ++ i18n/de.json | 77 ++- i18n/en.json | 77 ++- i18n/fr.json | 77 ++- i18n/it.json | 77 ++- 24 files changed, 1536 insertions(+), 8 deletions(-) create mode 100644 app/src/androidTest/assets/fixtures/cover_fetched.json create mode 100644 app/src/androidTest/assets/fixtures/enrich_cron_on.json create mode 100644 app/src/androidTest/assets/fixtures/enrich_overview.json create mode 100644 app/src/androidTest/assets/fixtures/import_job_chunk_10.json create mode 100644 app/src/androidTest/assets/fixtures/import_job_chunk_30.json create mode 100644 app/src/androidTest/assets/fixtures/import_job_created.json create mode 100644 app/src/androidTest/assets/fixtures/import_out_of_order_20.json create mode 100644 app/src/androidTest/assets/fixtures/imports_overview.json create mode 100644 app/src/androidTest/assets/fixtures/missing_covers.json create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/CoversFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/ImportFlowTest.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/ImportProgress.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCoversScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/ImportProgressTest.kt diff --git a/app/src/androidTest/assets/fixtures/cover_fetched.json b/app/src/androidTest/assets/fixtures/cover_fetched.json new file mode 100644 index 0000000..5317149 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/cover_fetched.json @@ -0,0 +1,9 @@ +{ + "data": { + "fetched": true, + "cover_url": "/uploads/copertine/5.jpg", + "reason": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/enrich_cron_on.json b/app/src/androidTest/assets/fixtures/enrich_cron_on.json new file mode 100644 index 0000000..7ed824f --- /dev/null +++ b/app/src/androidTest/assets/fixtures/enrich_cron_on.json @@ -0,0 +1,7 @@ +{ + "data": { + "enabled": true + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/enrich_overview.json b/app/src/androidTest/assets/fixtures/enrich_overview.json new file mode 100644 index 0000000..f706255 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/enrich_overview.json @@ -0,0 +1,13 @@ +{ + "data": { + "stats": { + "total_with_isbn": 120, + "missing_cover": 2, + "missing_description": 7, + "pending": 9 + }, + "cron_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/import_job_chunk_10.json b/app/src/androidTest/assets/fixtures/import_job_chunk_10.json new file mode 100644 index 0000000..70eb45e --- /dev/null +++ b/app/src/androidTest/assets/fixtures/import_job_chunk_10.json @@ -0,0 +1,25 @@ +{ + "data": { + "id": "6f1c2a", + "kind": "csv", + "file_name": "libri.csv", + "status": "running", + "total_rows": 30, + "processed": 10, + "chunk_size": 10, + "enable_scraping": false, + "imported": 10, + "updated": 0, + "scraped": 0, + "authors_created": 0, + "publishers_created": 0, + "errors": 0, + "error_details": [], + "complete": false, + "created_at": "2026-10-14 09:00:00", + "updated_at": "2026-10-14 09:00:00", + "expires_at": "2026-10-15 09:00:00" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/import_job_chunk_30.json b/app/src/androidTest/assets/fixtures/import_job_chunk_30.json new file mode 100644 index 0000000..fd5ba17 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/import_job_chunk_30.json @@ -0,0 +1,31 @@ +{ + "data": { + "id": "6f1c2a", + "kind": "csv", + "file_name": "libri.csv", + "status": "completed", + "total_rows": 30, + "processed": 30, + "chunk_size": 10, + "enable_scraping": false, + "imported": 28, + "updated": 0, + "scraped": 0, + "authors_created": 0, + "publishers_created": 0, + "errors": 2, + "error_details": [ + "Row 12: missing title", + { + "row": "Row 25", + "message": "bad ISBN" + } + ], + "complete": true, + "created_at": "2026-10-14 09:00:00", + "updated_at": "2026-10-14 09:00:00", + "expires_at": "2026-10-15 09:00:00" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/import_job_created.json b/app/src/androidTest/assets/fixtures/import_job_created.json new file mode 100644 index 0000000..0d1f173 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/import_job_created.json @@ -0,0 +1,25 @@ +{ + "data": { + "id": "6f1c2a", + "kind": "csv", + "file_name": "libri.csv", + "status": "ready", + "total_rows": 30, + "processed": 0, + "chunk_size": 10, + "enable_scraping": false, + "imported": 0, + "updated": 0, + "scraped": 0, + "authors_created": 0, + "publishers_created": 0, + "errors": 0, + "error_details": [], + "complete": false, + "created_at": "2026-10-14 09:00:00", + "updated_at": "2026-10-14 09:00:00", + "expires_at": "2026-10-15 09:00:00" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/import_out_of_order_20.json b/app/src/androidTest/assets/fixtures/import_out_of_order_20.json new file mode 100644 index 0000000..538ef99 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/import_out_of_order_20.json @@ -0,0 +1,10 @@ +{ + "data": null, + "meta": { + "processed": 20 + }, + "error": { + "code": "out_of_order", + "message": "Chunk out of order" + } +} diff --git a/app/src/androidTest/assets/fixtures/imports_overview.json b/app/src/androidTest/assets/fixtures/imports_overview.json new file mode 100644 index 0000000..5823216 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/imports_overview.json @@ -0,0 +1,47 @@ +{ + "data": { + "jobs": [ + { + "id": "6f1c2a", + "kind": "csv", + "file_name": "libri.csv", + "status": "ready", + "total_rows": 30, + "processed": 0, + "chunk_size": 10, + "enable_scraping": false, + "imported": 0, + "updated": 0, + "scraped": 0, + "authors_created": 0, + "publishers_created": 0, + "errors": 0, + "error_details": [], + "complete": false, + "created_at": "2026-10-14 09:00:00", + "updated_at": "2026-10-14 09:00:00", + "expires_at": "2026-10-15 09:00:00" + } + ], + "history": [ + { + "import_id": "41", + "kind": "csv", + "file_name": "old.csv", + "user": { + "id": 1, + "name": "Admin" + }, + "total_rows": 5, + "imported": 4, + "updated": 0, + "failed": 1, + "status": "completed", + "started_at": "2026-10-01 10:00:00", + "completed_at": "2026-10-01 10:01:00" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/missing_covers.json b/app/src/androidTest/assets/fixtures/missing_covers.json new file mode 100644 index 0000000..d3f5427 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/missing_covers.json @@ -0,0 +1,20 @@ +{ + "data": [ + { + "id": 5, + "title": "Orlando", + "isbn13": "9780156701600" + }, + { + "id": 6, + "title": "Il barone rampante", + "isbn13": "9788804668237" + } + ], + "meta": { + "total_count": 2, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt index d9f579f..eaf861a 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt @@ -9,6 +9,7 @@ import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performScrollToNode import androidx.test.core.app.ActivityScenario import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By @@ -144,6 +145,17 @@ abstract class AppFlowTest { clickUntil(text(R.string.profile_action_staff), text(R.string.staff_scan_subtitle)) } + /** Scroll the lazy list holding [value] until it is composed (items below the fold are not in the tree). */ + protected fun scrollListTo(value: String) { + val list = compose.onAllNodes(androidx.compose.ui.test.hasScrollToNodeAction()) + compose.waitUntil(10_000) { list.fetchSemanticsNodes().isNotEmpty() } + val count = list.fetchSemanticsNodes().size + for (i in 0 until count) { + val done = runCatching { list[i].performScrollToNode(hasText(value)) }.isSuccess + if (done) return + } + } + protected fun clickTag(tag: String) { waitFor(androidx.compose.ui.test.hasTestTag(tag)) compose.onAllNodes(androidx.compose.ui.test.hasTestTag(tag)).onFirst().performScrollToSafely().performClick() diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/CoversFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/CoversFlowTest.kt new file mode 100644 index 0000000..15b34a5 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/CoversFlowTest.kt @@ -0,0 +1,31 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Covers & enrichment: the per-book loop over the missing covers, and the automatic enrichment switch. */ +class CoversFlowTest : AppFlowTest() { + + @Test fun fetchMissingCoversOneByOneAndTurnOnEnrichment() { + launch { + json("GET staff/covers/missing", "missing_covers") + json("GET staff/enrich", "enrich_overview") + json("POST staff/books/5/cover/fetch", "cover_fetched") + json("POST staff/books/6/cover/fetch", "cover_fetched") + json("PUT staff/enrich/cron", "enrich_cron_on") + } + openStaffHub() + click(text(R.string.staff_covers)) + waitText(text(R.string.staff_covers_missing_count, 2)) + clickTag("covers-loop") + waitText(text(R.string.staff_covers_loop_progress, 2, 2, 2, 0), timeoutMs = 15_000) + assertEquals(1, api.recorded("POST", "staff/books/5/cover/fetch").size) + assertEquals(1, api.recorded("POST", "staff/books/6/cover/fetch").size) + // Never the long server-side jobs without a confirmation. + assertTrue(api.recorded("POST", "staff/covers/sync").isEmpty()) + click(text(R.string.staff_enrich_cron)) + assertEquals("""{"enabled":true}""", lastBody("PUT", "staff/enrich/cron")) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt index 80432b9..bee02aa 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/FakePinakes.kt @@ -62,6 +62,15 @@ class FakePinakes { routes[route] = { MockResponse().setResponseCode(status).setHeader("Content-Type", "application/json").setBody(fixture(fixture)) } } + /** Answer [route] with each fixture in turn (status, fixture), repeating the last one. */ + fun sequence(route: String, vararg steps: Pair) { + val calls = java.util.concurrent.atomic.AtomicInteger(0) + routes[route] = { + val (status, name) = steps[calls.getAndIncrement().coerceAtMost(steps.size - 1)] + MockResponse().setResponseCode(status).setHeader("Content-Type", "application/json").setBody(fixture(name)) + } + } + fun body(route: String, status: Int, body: String) { routes[route] = { MockResponse().setResponseCode(status).setHeader("Content-Type", "application/json").setBody(body) } } diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ImportFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ImportFlowTest.kt new file mode 100644 index 0000000..69409c0 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ImportFlowTest.kt @@ -0,0 +1,94 @@ +package com.pinakes.app.staffe2e + +import android.content.ContentValues +import android.os.Build +import android.provider.MediaStore +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Imports: chunks requested in order from the server's progress, and an upload through the system picker. */ +class ImportFlowTest : AppFlowTest() { + + private val jobRoutes: FakePinakes.() -> Unit = { + json("GET staff/imports", "imports_overview") + json("GET staff/imports/6f1c2a", "import_job_created") + // 0 → 10, then the server says it is already at 20 (another device moved on), then 20 → 30. + sequence( + "POST staff/imports/6f1c2a/chunk", + 200 to "import_job_chunk_10", + 409 to "import_out_of_order_20", + 200 to "import_job_chunk_30", + ) + } + + private fun chunkStarts(): List = api.recorded("POST", "staff/imports/6f1c2a/chunk").map { it.body.clone().readUtf8() } + + @Test fun anOpenJobResumesAndFollowsTheServersProgress() { + launch(configure = jobRoutes) + openStaffHub() + click(text(R.string.staff_imports)) + scrollListTo("libri.csv") + click("libri.csv") + waitText(text(R.string.staff_import_status_done), timeoutMs = 20_000) + assertEquals(listOf("""{"start":0}""", """{"start":10}""", """{"start":20}"""), chunkStarts()) + waitText(text(R.string.staff_import_progress, 30, 30)) + waitText("Row 12: missing title") + waitText("Row 25: bad ISBN") + } + + @Test fun uploadAPickedCsvThenRunTheChunks() { + val name = "e2e-import-${System.currentTimeMillis() % 100000}.csv" + val uri = publishDownload(name, "titolo;autore\nOrlando;Virginia Woolf\nIl barone rampante;Italo Calvino\n") + try { + launch { + jobRoutes() + json("POST staff/imports", "import_job_created", status = 201) + } + openStaffHub() + click(text(R.string.staff_imports)) + click(text(R.string.staff_import_choose_file)) + pickInDocumentsUi(name) + waitText(name, timeoutMs = 15_000) + clickTag("import-start") + waitRequest("POST", "staff/imports") + val body = api.recorded("POST", "staff/imports").last().body.clone().readUtf8() + assertTrue(body, body.contains("filename=\"$name\"")) + assertTrue(body, body.contains("Il barone rampante;Italo Calvino")) + assertTrue(body, body.contains("name=\"kind\"") && body.contains("csv")) + assertTrue(body, body.contains("name=\"enable_scraping\"")) + waitText(text(R.string.staff_import_status_done), timeoutMs = 20_000) + assertEquals(3, chunkStarts().size) + } finally { + context.contentResolver.delete(uri, null, null) + } + } + + private fun publishDownload(name: String, content: String): android.net.Uri { + check(Build.VERSION.SDK_INT >= 29) + val values = ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, name) + put(MediaStore.Downloads.MIME_TYPE, "text/csv") + } + val resolver = context.contentResolver + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: error("MediaStore refused the download") + resolver.openOutputStream(uri)!!.use { it.write(content.toByteArray()) } + return uri + } + + /** Find [name] in the system document picker (Recent, else the Downloads root) and pick it. */ + private fun pickInDocumentsUi(name: String) { + val file = device.wait(Until.findObject(By.text(name)), 6_000) ?: run { + device.wait(Until.findObject(By.desc("Show roots")), 3_000)?.click() + device.wait(Until.findObject(By.text("Downloads")), 3_000)?.click() + device.wait(Until.findObject(By.text(name)), 6_000) + } + assertNotNull("$name not shown by the document picker", file) + file!!.click() + device.waitForIdle() + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/ImportProgress.kt b/app/src/main/java/com/pinakes/app/data/staff/ImportProgress.kt new file mode 100644 index 0000000..0871010 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/ImportProgress.kt @@ -0,0 +1,55 @@ +package com.pinakes.app.data.staff + +import com.pinakes.app.data.model.ImportJob +import com.pinakes.app.data.network.ApiResult + +/** What the chunk driver does after an answer. */ +sealed interface ChunkStep { + /** Ask for the next chunk from [start]. */ + data class Next(val start: Int) : ChunkStep + /** The job is over (complete, failed or cancelled). */ + data object Done : ChunkStep + /** Another device is running this job: wait, then ask again from [start]. */ + data class Wait(val start: Int) : ChunkStep + /** Stop the loop and show the failure (offline, expired, refused). */ + data class Stop(val failure: ApiResult.Failure) : ChunkStep +} + +/** + * The pure rules of the resumable import (`POST /staff/imports/{id}/chunk`). The server owns the + * progress: every chunk starts at the job's `processed`; a 409 `out_of_order` says where the + * server really is (`meta.processed`), so the app continues from there instead of failing; a + * 409 `job_busy` means another loop is running and is retried after a pause; 410 `gone` + * (expired, cancelled, already completed) ends the loop. + */ +object ImportProgress { + private val FINAL_STATES = setOf("completed", "failed", "cancelled") + + const val BUSY_RETRIES = 5 + + fun isFinished(job: ImportJob): Boolean = job.complete || job.status in FINAL_STATES + + /** 0..100, clamped; an empty file is 100 once finished. */ + fun percent(job: ImportJob): Int = when { + job.totalRows <= 0 -> if (isFinished(job)) 100 else 0 + else -> ((job.processed.coerceIn(0, job.totalRows) * 100L) / job.totalRows).toInt() + } + + fun next(job: ImportJob): ChunkStep = if (isFinished(job)) ChunkStep.Done else ChunkStep.Next(job.processed) + + /** After a failed chunk request that started at [start]. */ + fun afterFailure(failure: ApiResult.Failure, start: Int, busyAttempts: Int): ChunkStep = when (failure.code) { + "out_of_order" -> failure.meta?.processed?.let { ChunkStep.Next(it) } ?: ChunkStep.Stop(failure) + "job_busy" -> if (busyAttempts < BUSY_RETRIES) ChunkStep.Wait(start) else ChunkStep.Stop(failure) + else -> ChunkStep.Stop(failure) + } + + /** Accepted file extensions per kind (the server re-checks). */ + fun acceptsFileName(name: String, librarything: Boolean): Boolean { + val ext = name.substringAfterLast('.', "").lowercase() + return if (librarything) ext in setOf("tsv", "csv", "txt") else ext == "csv" + } + + /** Upload limit enforced by the server (20 MB). */ + const val MAX_BYTES = 20L * 1024 * 1024 +} diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index c5d9fd3..9fd3c59 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -56,6 +56,9 @@ import com.pinakes.app.ui.screens.staff.circulation.StaffReservationsScreen import com.pinakes.app.ui.screens.staff.circulation.StaffUserScreen import com.pinakes.app.ui.screens.staff.scan.StaffCopyScanScreen import com.pinakes.app.ui.screens.staff.content.StaffMergeScreen +import com.pinakes.app.ui.screens.staff.content.StaffCoversScreen +import com.pinakes.app.ui.screens.staff.content.StaffImportJobScreen +import com.pinakes.app.ui.screens.staff.content.StaffImportsScreen import com.pinakes.app.ui.screens.staff.content.StaffSeriesDetailScreen import com.pinakes.app.ui.screens.staff.content.StaffSeriesScreen @@ -265,6 +268,19 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { composable(Routes.STAFF_MERGES, enterTransition = slideIn, popExitTransition = slideOut) { StaffMergeScreen(onNavigateUp = { navController.popBackStack() }) } + composable(Routes.STAFF_IMPORTS, enterTransition = slideIn, popExitTransition = slideOut) { + StaffImportsScreen(onNavigateUp = { navController.popBackStack() }, onOpenJob = { navController.navigate(Routes.staffImport(it)) }) + } + composable( + Routes.STAFF_IMPORT, + arguments = listOf(navArgument(Routes.ARG_JOB_ID) { type = NavType.StringType }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StaffImportJobScreen(onNavigateUp = { navController.popBackStack() }) + } + composable(Routes.STAFF_COVERS, enterTransition = slideIn, popExitTransition = slideOut) { + StaffCoversScreen(onNavigateUp = { navController.popBackStack() }) + } composable(Routes.STAFF_COPY_SCAN, enterTransition = slideIn, popExitTransition = slideOut) { StaffCopyScanScreen( onNavigateUp = { navController.popBackStack() }, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt index 73d4684..3a00e9f 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt @@ -2,6 +2,7 @@ package com.pinakes.app.ui.screens.staff import androidx.annotation.StringRes import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -141,14 +142,20 @@ internal fun StaffLoadBody( /** A labelled switch row (forms and settings sheets). */ @Composable internal fun SwitchRow(title: String, checked: Boolean, onChange: (Boolean) -> Unit, subtitle: String? = null, enabled: Boolean = true) { - Row(Modifier.fillMaxWidth().padding(vertical = Spacing.xs), verticalAlignment = Alignment.CenterVertically) { + // The whole row toggles (one touch target, one accessibility node with the switch role). + Row( + Modifier.fillMaxWidth() + .toggleable(value = checked, enabled = enabled, role = androidx.compose.ui.semantics.Role.Switch, onValueChange = onChange) + .padding(vertical = Spacing.xs), + verticalAlignment = Alignment.CenterVertically, + ) { Column(Modifier.weight(1f)) { Text(title, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface) if (!subtitle.isNullOrBlank()) { Text(subtitle, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) } } - Switch(checked = checked, onCheckedChange = onChange, enabled = enabled) + Switch(checked = checked, onCheckedChange = null, enabled = enabled) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt index 6343686..dccc753 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanCreateScreen.kt @@ -226,13 +226,15 @@ class StaffLoanCreateViewModel @Inject constructor( /** * Send [payload] under its submission key: one automatic retry with the same key when the - * connection dropped (the server may already have created it: the replay returns it), a + * connection dropped or the server asked for a retry (it may already have created it: the + * replay returns it, 200 with `meta.replayed`, handled like a 201), a * fresh key after a success or a key conflict. */ private suspend fun withIdempotency(payload: Any, call: suspend (String) -> ApiResult): ApiResult { val key = keys.keyFor(payload) var res = call(key) - if (res is ApiResult.Failure && res.code == ErrorCodes.NETWORK) res = call(key) + // A dropped connection, or the server's 409 concurrent_retry (its lock was busy): one retry, same key. + if (res is ApiResult.Failure && (res.code == ErrorCodes.NETWORK || res.code == "concurrent_retry")) res = call(key) if (res is ApiResult.Success || (res as? ApiResult.Failure)?.code == SubmissionKeys.CONFLICT) keys.reset() return res } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCoversScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCoversScreen.kt new file mode 100644 index 0000000..36ec880 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffCoversScreen.kt @@ -0,0 +1,279 @@ +package com.pinakes.app.ui.screens.staff.content + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.EnrichOverview +import com.pinakes.app.data.model.MissingCover +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.data.repository.StaffContentRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.ProgressLine +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** The per-book cover loop: how far it got and what it found. */ +data class CoverLoop(val total: Int = 0, val done: Int = 0, val fetched: Int = 0, val notFound: Int = 0, val waiting: Boolean = false) + +data class CoversUiState( + val missing: Int? = null, + val enrich: EnrichOverview? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val loop: CoverLoop? = null, + val looping: Boolean = false, + val syncing: Boolean = false, + val enriching: Boolean = false, + val message: StaffMessage? = null, + val forbidden: Boolean = false, +) + +/** + * Covers and bulk enrichment. The default action is the per-book loop over the missing covers, + * with progress, cancel and the server's rate limit honoured; the server-side sync and an + * enrichment batch run synchronously for minutes, so they sit behind a confirmation and use the + * long-timeout client. + */ +@HiltViewModel +class StaffCoversViewModel @Inject constructor(private val content: StaffContentRepository) : ViewModel() { + private val _state = MutableStateFlow(CoversUiState()) + val state: StateFlow = _state.asStateFlow() + private var loopJob: Job? = null + + init { reload() } + + fun reload() { + viewModelScope.launch { + val missing = content.missingCovers(limit = 1) + val enrich = content.enrich() + _state.update { + it.copy( + loading = false, refreshing = false, + missing = (missing as? ApiResult.Success)?.let { m -> m.data.totalCount ?: m.data.items.size } ?: it.missing, + enrich = (enrich as? ApiResult.Success)?.data ?: it.enrich, + forbidden = (missing as? ApiResult.Failure)?.isForbidden() == true || (enrich as? ApiResult.Failure)?.isForbidden() == true, + message = (missing as? ApiResult.Failure)?.toStaffMessage(R.string.staff_error_load), + ) + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + reload() + } + + fun startLoop() { + if (loopJob?.isActive == true) return + _state.update { it.copy(looping = true, loop = CoverLoop()) } + loopJob = viewModelScope.launch { + val books = ArrayList() + var cursor: String? = null + do { + when (val page = content.missingCovers(cursor, 50)) { + is ApiResult.Success -> { books += page.data.items; cursor = page.data.nextCursor } + is ApiResult.Failure -> { + _state.update { it.copy(looping = false, forbidden = page.isForbidden(), message = page.toStaffMessage(R.string.staff_error_load)) } + return@launch + } + } + } while (cursor != null && isActive && books.size < 5_000) + _state.update { it.copy(loop = CoverLoop(total = books.size)) } + for (book in books) { + if (!isActive) break + var res = content.fetchCover(book.id) + // 30 fetches a minute: wait as the server says, then retry the same book. + while (res is ApiResult.Failure && res.code == ErrorCodes.RATE_LIMITED && isActive) { + _state.update { it.copy(loop = it.loop?.copy(waiting = true)) } + delay(((res.meta?.retryAfter?.toLong() ?: res.retryAfterSeconds ?: 30L).coerceIn(1, 120)) * 1_000) + _state.update { it.copy(loop = it.loop?.copy(waiting = false)) } + res = content.fetchCover(book.id) + } + when (val outcome = res) { + is ApiResult.Success -> _state.update { s -> + val l = s.loop ?: CoverLoop() + val got = outcome.data.fetched + s.copy(loop = l.copy(done = l.done + 1, fetched = l.fetched + if (got) 1 else 0, notFound = l.notFound + if (got) 0 else 1)) + } + is ApiResult.Failure -> { + if (outcome.isForbidden() || outcome.code == ErrorCodes.NETWORK) { + _state.update { it.copy(forbidden = outcome.isForbidden(), message = outcome.toStaffMessage(R.string.staff_error_load)) } + break + } + _state.update { s -> val l = s.loop ?: CoverLoop(); s.copy(loop = l.copy(done = l.done + 1, notFound = l.notFound + 1)) } + } + } + } + _state.update { it.copy(looping = false) } + reload() + } + } + + fun cancelLoop() { + loopJob?.cancel() + _state.update { it.copy(looping = false) } + reload() + } + + fun sync() { + if (_state.value.syncing) return + _state.update { it.copy(syncing = true) } + viewModelScope.launch { + val res = content.syncCovers() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(syncing = false, message = StaffMessage(R.string.staff_covers_sync_done, args = listOf(res.data.synced, res.data.total))) + is ApiResult.Failure -> it.copy(syncing = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + reload() + } + } + + fun enrichBatch() { + if (_state.value.enriching) return + _state.update { it.copy(enriching = true) } + viewModelScope.launch { + val res = content.enrichBatch() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(enriching = false, message = StaffMessage(R.string.staff_enrich_done, args = listOf(res.data.enriched, res.data.processed))) + is ApiResult.Failure -> it.copy(enriching = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + reload() + } + } + + fun setCron(enabled: Boolean) { + viewModelScope.launch { + val res = content.setEnrichCron(enabled) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(enrich = it.enrich?.copy(cronEnabled = res.data.enabled)) + is ApiResult.Failure -> it.copy(forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffCoversScreen(onNavigateUp: () -> Unit) { + val vm: StaffCoversViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + var confirm by rememberSaveable { mutableStateOf(null) } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.covers, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_covers), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize().padding(padding)) { + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + FormSectionTitle(stringResource(R.string.staff_covers_missing_title)) + Text( + state.missing?.let { stringResource(R.string.staff_covers_missing_count, it) } ?: stringResource(R.string.staff_covers_missing_unknown), + style = MaterialTheme.typography.bodyLarge, modifier = Modifier.testTag("missing-count"), + ) + state.loop?.let { loop -> + ProgressLine(loop.done, loop.total, stringResource(R.string.staff_covers_loop_progress, loop.done, loop.total, loop.fetched, loop.notFound)) + if (loop.waiting) Text(stringResource(R.string.staff_covers_rate_wait), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (state.looping) { + SecondaryButton(stringResource(R.string.staff_covers_stop), onClick = vm::cancelLoop, modifier = Modifier.fillMaxWidth()) + } else { + PrimaryButton(stringResource(R.string.staff_covers_fetch_missing), onClick = vm::startLoop, enabled = state.missing != 0, + modifier = Modifier.fillMaxWidth().testTag("covers-loop")) + } + SecondaryButton(stringResource(R.string.staff_covers_server_sync), onClick = { confirm = "sync" }, enabled = !state.syncing && !state.looping, + modifier = Modifier.fillMaxWidth()) + if (state.syncing) Text(stringResource(R.string.staff_long_job_running), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + + FormSectionTitle(stringResource(R.string.staff_enrich_title)) + state.enrich?.let { e -> + InfoCard { + InfoLine(stringResource(R.string.staff_enrich_with_isbn), e.stats.totalWithIsbn.toString()) + InfoLine(stringResource(R.string.staff_enrich_missing_cover), e.stats.missingCover.toString()) + InfoLine(stringResource(R.string.staff_enrich_missing_description), e.stats.missingDescription.toString()) + InfoLine(stringResource(R.string.staff_enrich_pending), e.stats.pending.toString()) + } + SwitchRow(stringResource(R.string.staff_enrich_cron), e.cronEnabled, vm::setCron, subtitle = stringResource(R.string.staff_enrich_cron_hint)) + } + SecondaryButton(stringResource(R.string.staff_enrich_run), onClick = { confirm = "enrich" }, enabled = !state.enriching, modifier = Modifier.fillMaxWidth()) + if (state.enriching) Text(stringResource(R.string.staff_long_job_running), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.height(Spacing.xxl)) + } + } + } + + when (confirm) { + "sync" -> ConfirmDialog( + title = stringResource(R.string.staff_covers_server_sync), body = stringResource(R.string.staff_covers_server_sync_body), + onConfirm = { confirm = null; vm.sync() }, onDismiss = { confirm = null }, + ) + "enrich" -> ConfirmDialog( + title = stringResource(R.string.staff_enrich_run), body = stringResource(R.string.staff_enrich_run_body), + onConfirm = { confirm = null; vm.enrichBatch() }, onDismiss = { confirm = null }, + ) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt new file mode 100644 index 0000000..1d0bd4a --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt @@ -0,0 +1,480 @@ +package com.pinakes.app.ui.screens.staff.content + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.UploadFile +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.ImportHistoryRow +import com.pinakes.app.data.model.ImportJob +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.ImportKind +import com.pinakes.app.data.repository.StaffContentRepository +import com.pinakes.app.data.repository.StaffFilesRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.ChunkStep +import com.pinakes.app.data.staff.ImportProgress +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.ExportFileDialog +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.PickerRow +import com.pinakes.app.ui.screens.staff.ProgressLine +import com.pinakes.app.ui.screens.staff.StaffActionRow +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** The field families a CSV import may overwrite on existing records (`CsvImportController::UPDATE_FIELD_FAMILIES`). */ +internal val UPDATE_FAMILIES = listOf( + "authors" to R.string.staff_import_field_authors, "contributors" to R.string.staff_import_field_contributors, + "publisher" to R.string.staff_import_field_publisher, "genre" to R.string.staff_import_field_genre, + "keywords" to R.string.staff_import_field_keywords, "description" to R.string.staff_import_field_description, + "isbn" to R.string.staff_import_field_isbn, "ean" to R.string.staff_import_field_ean, + "titolo" to R.string.staff_import_field_title, "sottotitolo" to R.string.staff_import_field_subtitle, + "anno" to R.string.staff_import_field_year, "lingua" to R.string.staff_import_field_language, + "edizione" to R.string.staff_import_field_edition, "pagine" to R.string.staff_import_field_pages, + "formato" to R.string.staff_import_field_format, "prezzo" to R.string.staff_import_field_price, + "collana" to R.string.staff_import_field_series, "dewey" to R.string.staff_import_field_dewey, +) + +/** A picked document, read into memory (at most 20 MB). */ +data class PickedFile(val name: String, val mime: String?, val bytes: ByteArray) { + override fun equals(other: Any?): Boolean = other is PickedFile && other.name == name && other.bytes.contentEquals(bytes) + override fun hashCode(): Int = name.hashCode() * 31 + bytes.contentHashCode() +} + +data class ImportsUiState( + val kind: ImportKind = ImportKind.CSV, + val file: PickedFile? = null, + val scraping: Boolean = false, + val families: Set = UPDATE_FAMILIES.map { it.first }.toSet(), + val jobs: List = emptyList(), + val history: List = emptyList(), + val loading: Boolean = true, + val uploading: Boolean = false, + val error: StaffMessage? = null, + val message: StaffMessage? = null, + val forbidden: Boolean = false, + val startedJob: String? = null, + val export: File? = null, + val exportMime: String = "text/csv", +) + +@HiltViewModel +class StaffImportsViewModel @Inject constructor( + private val content: StaffContentRepository, + private val files: StaffFilesRepository, + @ApplicationContext private val context: Context, +) : ViewModel() { + private val _state = MutableStateFlow(ImportsUiState()) + val state: StateFlow = _state.asStateFlow() + + fun reload() { + viewModelScope.launch { + val res = content.imports() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(jobs = res.data.jobs.filterNot(ImportProgress::isFinished), history = res.data.history, loading = false) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun setKind(kind: ImportKind) = _state.update { it.copy(kind = kind, error = null) } + fun setScraping(value: Boolean) = _state.update { it.copy(scraping = value) } + fun toggleFamily(family: String) = _state.update { s -> s.copy(families = if (family in s.families) s.families - family else s.families + family) } + + /** Read the picked document; refuse what the server would refuse anyway (size, extension). */ + fun onPicked(uri: Uri?) { + uri ?: return + viewModelScope.launch { + val read = withContext(Dispatchers.IO) { runCatching { read(uri) }.getOrNull() } + _state.update { + when { + read == null -> it.copy(error = StaffMessage(R.string.staff_import_read_failed), file = null) + read.tooBig -> it.copy(error = StaffMessage(R.string.staff_import_too_big), file = null) + else -> it.copy(file = read.file, error = null) + } + } + } + } + + private class Read(val file: PickedFile?, val tooBig: Boolean) + + private fun read(uri: Uri): Read? { + val resolver = context.contentResolver + var name = uri.lastPathSegment?.substringAfterLast('/') ?: "import.csv" + var size = -1L + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> + if (c.moveToFirst()) { + c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { i -> c.getString(i)?.let { name = it } } + c.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }?.let { i -> if (!c.isNull(i)) size = c.getLong(i) } + } + } + if (size > ImportProgress.MAX_BYTES) return Read(null, tooBig = true) + val out = java.io.ByteArrayOutputStream() + resolver.openInputStream(uri)?.use { input -> + val buffer = ByteArray(64 * 1024) + var total = 0L + while (true) { + val n = input.read(buffer) + if (n < 0) break + total += n + // The provider may not report a size: stop reading as soon as the limit is passed. + if (total > ImportProgress.MAX_BYTES) return Read(null, tooBig = true) + out.write(buffer, 0, n) + } + } ?: return null + return Read(PickedFile(name, resolver.getType(uri), out.toByteArray()), tooBig = false) + } + + fun start() { + val s = _state.value + val file = s.file ?: return + if (s.uploading) return + if (!ImportProgress.acceptsFileName(file.name, s.kind == ImportKind.LIBRARYTHING)) { + _state.update { it.copy(error = StaffMessage(if (s.kind == ImportKind.CSV) R.string.staff_import_wrong_ext_csv else R.string.staff_import_wrong_ext_lt)) } + return + } + _state.update { it.copy(uploading = true, error = null) } + viewModelScope.launch { + val families = s.families.takeIf { it.size < UPDATE_FAMILIES.size }?.let { set -> UPDATE_FAMILIES.map { it.first }.filter { it in set } } + val res = content.createImport(s.kind, file.name, file.bytes, file.mime, s.scraping, families) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(uploading = false, file = null, startedJob = res.data.id) + is ApiResult.Failure -> it.copy(uploading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_import_failed)) + } + } + } + } + + fun consumeStarted() = _state.update { it.copy(startedJob = null) } + + fun downloadExample() = download("staff/imports/example.csv", "esempio_import.csv") + + fun downloadErrors(row: ImportHistoryRow) = download("staff/imports/history/${row.importId}/errors.csv", "errori_${row.importId}.csv") + + private fun download(path: String, name: String) { + viewModelScope.launch { + val res = files.download(path, name) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(export = res.data) + is ApiResult.Failure -> it.copy(forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_download_failed)) + } + } + } + } + + fun closeExport() = _state.update { it.copy(export = null) } + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** Upload a CSV (or LibraryThing export) and follow it; open jobs resume; past imports with their error file. */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun StaffImportsScreen(onNavigateUp: () -> Unit, onOpenJob: (String) -> Unit) { + val vm: StaffImportsViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.imports, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + LaunchedEffect(state.startedJob) { state.startedJob?.let { vm.consumeStarted(); onOpenJob(it) } } + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) vm.reload() } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + val picker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument(), vm::onPicked) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_imports), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + item(key = "form") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.sm)) { + if (areas.libraryThing) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + listOf(ImportKind.CSV to R.string.staff_import_kind_csv, ImportKind.LIBRARYTHING to R.string.staff_import_kind_lt).forEach { (kind, label) -> + FilterChip(selected = state.kind == kind, onClick = { vm.setKind(kind) }, label = { Text(stringResource(label)) }, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer)) + } + } + } + state.error?.let { InlineErrorBanner(message = it.resolve()) } + PickerRow( + label = stringResource(R.string.staff_import_file), value = state.file?.name, placeholder = stringResource(R.string.staff_import_choose_file), + onClick = { picker.launch(MIME_TYPES) }, + ) + SwitchRow(stringResource(R.string.staff_import_scraping), state.scraping, vm::setScraping, subtitle = stringResource(R.string.staff_import_scraping_hint)) + if (state.kind == ImportKind.CSV) { + Text(stringResource(R.string.staff_import_update_fields), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + UPDATE_FAMILIES.forEach { (family, label) -> + FilterChip(selected = family in state.families, onClick = { vm.toggleFamily(family) }, label = { Text(stringResource(label)) }, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer)) + } + } + } + PrimaryButton( + stringResource(R.string.staff_import_start), onClick = vm::start, loading = state.uploading, enabled = state.file != null, + leadingIcon = Icons.Outlined.UploadFile, modifier = Modifier.fillMaxWidth().testTag("import-start"), + ) + SecondaryButton(stringResource(R.string.staff_import_example), onClick = vm::downloadExample, leadingIcon = Icons.Outlined.Download, modifier = Modifier.fillMaxWidth()) + } + } + if (state.jobs.isNotEmpty()) { + item(key = "jobs-title") { FormSectionTitle(stringResource(R.string.staff_import_open_jobs)) } + items(state.jobs, key = { "j${it.id}" }) { job -> + StaffActionRow(Icons.Outlined.UploadFile, job.fileName, stringResource(R.string.staff_import_progress, job.processed, job.totalRows)) { onOpenJob(job.id) } + } + } + if (state.history.isNotEmpty()) { + item(key = "history-title") { FormSectionTitle(stringResource(R.string.staff_import_history)) } + items(state.history, key = { "h${it.importId}" }) { row -> + InfoCard(Modifier.padding(vertical = Spacing.xs)) { + Text(row.fileName ?: row.importId, style = MaterialTheme.typography.titleSmall) + InfoLine(stringResource(R.string.staff_import_when), row.startedAt?.let(DateFormat::dateTime)) + InfoLine(stringResource(R.string.staff_import_by), row.user?.name) + InfoLine(stringResource(R.string.staff_import_result), stringResource(R.string.staff_import_counts, row.imported, row.updated, row.failed)) + if (row.failed > 0) { + SecondaryButton(stringResource(R.string.staff_import_errors_csv), onClick = { vm.downloadErrors(row) }, leadingIcon = Icons.Outlined.Download) + } + } + } + } + } + } + state.export?.let { ExportFileDialog(it, "text/csv", stringResource(R.string.staff_imports), onDismiss = vm::closeExport) } +} + +private val MIME_TYPES = arrayOf("text/csv", "text/comma-separated-values", "text/plain", "text/tab-separated-values", "application/vnd.ms-excel") + +// ============================================================================================= +// One job: the chunk driver +// ============================================================================================= + +data class ImportJobUiState( + val job: ImportJob? = null, + val loading: Boolean = true, + val running: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + val cancelled: Boolean = false, +) + +/** + * Drives one import chunk by chunk, always from the server's `processed`. The job id comes from + * the route (kept in the saved state), so the job survives process death and resumes where the + * server is. Pause stops after the chunk in flight; the server's `job_busy` keeps two loops + * (two devices) from running one job at once. + */ +@HiltViewModel +class StaffImportJobViewModel @Inject constructor( + private val content: StaffContentRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val jobId: String = savedState.get(Routes.ARG_JOB_ID).orEmpty() + private val _state = MutableStateFlow(ImportJobUiState()) + val state: StateFlow = _state.asStateFlow() + private var loop: Job? = null + + init { + viewModelScope.launch { + when (val res = content.importJob(jobId)) { + is ApiResult.Success -> { + _state.update { it.copy(job = res.data, loading = false) } + if (!ImportProgress.isFinished(res.data)) resume() + } + is ApiResult.Failure -> _state.update { it.copy(loading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) } + } + } + } + + fun resume() { + val job = _state.value.job ?: return + if (loop?.isActive == true || ImportProgress.isFinished(job)) return + _state.update { it.copy(running = true, error = null) } + loop = viewModelScope.launch { + var step: ChunkStep = ImportProgress.next(job) + var busy = 0 + while (isActive) { + val start = when (val s = step) { + is ChunkStep.Next -> s.start + is ChunkStep.Wait -> { delay(3_000); s.start } + ChunkStep.Done -> break + is ChunkStep.Stop -> { + _state.update { it.copy(forbidden = s.failure.isForbidden(), error = s.failure.toStaffMessage(R.string.staff_import_failed)) } + break + } + } + val scraping = _state.value.job?.enableScraping == true + when (val res = content.importChunk(jobId, start, scraping)) { + is ApiResult.Success -> { + busy = 0 + _state.update { it.copy(job = res.data) } + step = ImportProgress.next(res.data) + } + is ApiResult.Failure -> { + if (res.code == "job_busy") busy++ + step = ImportProgress.afterFailure(res, start, busy) + res.meta?.processed?.let { p -> _state.update { s -> s.copy(job = s.job?.copy(processed = p)) } } + } + } + } + _state.update { it.copy(running = false) } + } + } + + fun pause() { + loop?.cancel() + _state.update { it.copy(running = false) } + } + + fun cancelJob() { + pause() + viewModelScope.launch { + when (val res = content.cancelImport(jobId)) { + is ApiResult.Success -> _state.update { it.copy(cancelled = true) } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_save)) } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffImportJobScreen(onNavigateUp: () -> Unit) { + val vm: StaffImportJobViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.imports || state.cancelled, onNavigateUp) + + Scaffold(topBar = { PinakesTopBar(title = state.job?.fileName ?: stringResource(R.string.staff_imports), onNavigateUp = onNavigateUp) }) { padding -> + val job = state.job + when { + state.loading -> LoadingState(modifier = Modifier.padding(padding)) + job == null -> ErrorState(message = state.error?.resolve() ?: stringResource(R.string.staff_error_load), modifier = Modifier.padding(padding)) + else -> LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(Spacing.lg)) { + item(key = "progress") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + val finished = ImportProgress.isFinished(job) + Text( + stringResource( + when { + job.status == "cancelled" -> R.string.staff_import_status_cancelled + job.status == "failed" -> R.string.staff_import_status_failed + finished -> R.string.staff_import_status_done + state.running -> R.string.staff_import_status_running + else -> R.string.staff_import_status_paused + }, + ), + style = MaterialTheme.typography.titleMedium, modifier = Modifier.testTag("import-status"), + ) + ProgressLine(job.processed, job.totalRows, stringResource(R.string.staff_import_progress, job.processed, job.totalRows)) + state.error?.let { InlineErrorBanner(message = it.resolve()) } + InfoCard { + InfoLine(stringResource(R.string.staff_import_imported), job.imported.toString()) + InfoLine(stringResource(R.string.staff_import_updated), job.updated.toString()) + InfoLine(stringResource(R.string.staff_import_scraped), job.scraped.toString().takeIf { job.enableScraping }) + InfoLine(stringResource(R.string.staff_import_authors_created), job.authorsCreated.toString()) + InfoLine(stringResource(R.string.staff_import_publishers_created), job.publishersCreated.toString()) + InfoLine(stringResource(R.string.staff_import_errors), job.errors.toString()) + } + if (!finished) { + if (state.running) SecondaryButton(stringResource(R.string.staff_import_pause), onClick = vm::pause, modifier = Modifier.fillMaxWidth()) + else PrimaryButton(stringResource(R.string.staff_import_resume), onClick = vm::resume, modifier = Modifier.fillMaxWidth()) + SecondaryButton(stringResource(R.string.staff_import_cancel), onClick = vm::cancelJob, modifier = Modifier.fillMaxWidth()) + } + if (job.errorLines.isNotEmpty()) FormSectionTitle(stringResource(R.string.staff_import_errors)) + } + } + items(job.errorLines.take(200)) { line -> + Text(line, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(vertical = Spacing.xxs)) + } + item(key = "bottom") { Spacer(Modifier.height(Spacing.xxl)) } + } + } + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/ImportProgressTest.kt b/app/src/test/java/com/pinakes/app/staff/ImportProgressTest.kt new file mode 100644 index 0000000..59e4fce --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/ImportProgressTest.kt @@ -0,0 +1,56 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.ImportJob +import com.pinakes.app.data.model.Meta +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.staff.ChunkStep +import com.pinakes.app.data.staff.ImportProgress +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The resumable import: chunks start where the server is, out-of-order and busy answers recover. */ +class ImportProgressTest { + + private fun failure(code: String, processed: Int? = null) = + ApiResult.Failure(code, "x", 409, meta = Meta(processed = processed), fromEnvelope = true) + + @Test fun chunksStartAtTheServersProcessedCount() { + assertEquals(ChunkStep.Next(0), ImportProgress.next(ImportJob(totalRows = 30))) + assertEquals(ChunkStep.Next(20), ImportProgress.next(ImportJob(totalRows = 30, processed = 20, status = "running"))) + assertEquals(ChunkStep.Done, ImportProgress.next(ImportJob(totalRows = 30, processed = 30, complete = true))) + listOf("completed", "failed", "cancelled").forEach { assertEquals(ChunkStep.Done, ImportProgress.next(ImportJob(status = it))) } + } + + @Test fun outOfOrderContinuesFromTheServer() { + assertEquals(ChunkStep.Next(40), ImportProgress.afterFailure(failure("out_of_order", 40), start = 30, busyAttempts = 0)) + assertTrue(ImportProgress.afterFailure(failure("out_of_order"), 30, 0) is ChunkStep.Stop) + } + + @Test fun busyWaitsAFewTimesThenStops() { + assertEquals(ChunkStep.Wait(10), ImportProgress.afterFailure(failure("job_busy"), 10, busyAttempts = 1)) + assertTrue(ImportProgress.afterFailure(failure("job_busy"), 10, busyAttempts = ImportProgress.BUSY_RETRIES) is ChunkStep.Stop) + } + + @Test fun goneOfflineOrRefusedStops() { + listOf("gone", "network", "forbidden", "not_found").forEach { + assertTrue(it, ImportProgress.afterFailure(failure(it), 0, 0) is ChunkStep.Stop) + } + } + + @Test fun percentIsClamped() { + assertEquals(0, ImportProgress.percent(ImportJob(totalRows = 0))) + assertEquals(100, ImportProgress.percent(ImportJob(totalRows = 0, complete = true))) + assertEquals(33, ImportProgress.percent(ImportJob(totalRows = 30, processed = 10))) + assertEquals(100, ImportProgress.percent(ImportJob(totalRows = 30, processed = 45))) + } + + @Test fun fileExtensions() { + assertTrue(ImportProgress.acceptsFileName("libri.CSV", librarything = false)) + assertFalse(ImportProgress.acceptsFileName("libri.tsv", librarything = false)) + assertTrue(ImportProgress.acceptsFileName("export.tsv", librarything = true)) + assertTrue(ImportProgress.acceptsFileName("export.txt", librarything = true)) + assertFalse(ImportProgress.acceptsFileName("export.xlsx", librarything = true)) + } +} diff --git a/i18n/de.json b/i18n/de.json index 603debe..c4fe548 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1102,5 +1102,80 @@ "staff_merge_genre_target": "Verbleibendes Genre", "staff_merge_genre_pick": "Genre wählen", "staff_merge_genre_done": "Genres zusammengeführt: %1$d Bücher, %2$d Untergenres verschoben", - "staff_merge_genre_descendant": "Ein Genre kann nicht mit einem eigenen Untergenre zusammengeführt werden." + "staff_merge_genre_descendant": "Ein Genre kann nicht mit einem eigenen Untergenre zusammengeführt werden.", + "staff_import_field_authors": "Autoren", + "staff_import_field_contributors": "Weitere Mitwirkende", + "staff_import_field_publisher": "Verlag", + "staff_import_field_genre": "Genre", + "staff_import_field_keywords": "Schlagwörter", + "staff_import_field_description": "Beschreibung", + "staff_import_field_isbn": "ISBN", + "staff_import_field_ean": "EAN", + "staff_import_field_title": "Titel", + "staff_import_field_subtitle": "Untertitel", + "staff_import_field_year": "Jahr", + "staff_import_field_language": "Sprache", + "staff_import_field_edition": "Ausgabe", + "staff_import_field_pages": "Seiten", + "staff_import_field_format": "Format", + "staff_import_field_price": "Preis", + "staff_import_field_series": "Reihe", + "staff_import_field_dewey": "Dewey", + "staff_import_kind_csv": "CSV", + "staff_import_kind_lt": "LibraryThing", + "staff_import_file": "Datei", + "staff_import_choose_file": "Datei wählen", + "staff_import_scraping": "Fehlende Daten online suchen", + "staff_import_scraping_hint": "Langsamer: eine Zeile nach der anderen, jede kann dauern.", + "staff_import_update_fields": "Felder, die bei vorhandenen Büchern aktualisiert werden", + "staff_import_start": "Hochladen und importieren", + "staff_import_example": "Beispiel-CSV", + "staff_import_open_jobs": "Abzuschließende Importe", + "staff_import_history": "Frühere Importe", + "staff_import_progress": "%1$d von %2$d Zeilen", + "staff_import_when": "Gestartet", + "staff_import_by": "Von", + "staff_import_result": "Ergebnis", + "staff_import_counts": "%1$d neu, %2$d aktualisiert, %3$d fehlgeschlagen", + "staff_import_errors_csv": "Fehler herunterladen", + "staff_import_read_failed": "Die Datei konnte nicht gelesen werden.", + "staff_import_too_big": "Die Datei ist größer als 20 MB.", + "staff_import_wrong_ext_csv": "Eine .csv-Datei wählen.", + "staff_import_wrong_ext_lt": "Einen LibraryThing-Export wählen (.tsv, .csv oder .txt).", + "staff_import_failed": "Der Import konnte nicht fortgesetzt werden.", + "staff_import_status_running": "Import läuft…", + "staff_import_status_paused": "Pausiert", + "staff_import_status_done": "Import abgeschlossen", + "staff_import_status_failed": "Import fehlgeschlagen", + "staff_import_status_cancelled": "Import abgebrochen", + "staff_import_imported": "Neue Bücher", + "staff_import_updated": "Aktualisierte Bücher", + "staff_import_scraped": "Online ergänzt", + "staff_import_authors_created": "Neue Autoren", + "staff_import_publishers_created": "Neue Verlage", + "staff_import_errors": "Fehler", + "staff_import_pause": "Pausieren", + "staff_import_resume": "Fortsetzen", + "staff_import_cancel": "Import abbrechen", + "staff_covers_missing_title": "Fehlende Cover", + "staff_covers_missing_count": "%1$d Bücher mit ISBN haben kein Cover.", + "staff_covers_missing_unknown": "Bücher ohne Cover werden gezählt…", + "staff_covers_fetch_missing": "Fehlende Cover holen", + "staff_covers_loop_progress": "%1$d von %2$d · gefunden %3$d · nicht gefunden %4$d", + "staff_covers_rate_wait": "Kurz warten: der Server begrenzt die Cover pro Minute.", + "staff_covers_stop": "Anhalten", + "staff_covers_server_sync": "Alle Cover auf dem Server abgleichen", + "staff_covers_server_sync_body": "Der Server holt alle fehlenden Cover auf einmal. Das kann bis zu zehn Minuten dauern; die App offen lassen.", + "staff_covers_sync_done": "Cover abgeglichen: %1$d von %2$d", + "staff_long_job_running": "Der Server arbeitet, das kann einige Minuten dauern…", + "staff_enrich_title": "Anreicherung", + "staff_enrich_with_isbn": "Bücher mit ISBN", + "staff_enrich_missing_cover": "Ohne Cover", + "staff_enrich_missing_description": "Ohne Beschreibung", + "staff_enrich_pending": "Noch anzureichern", + "staff_enrich_cron": "Automatisch anreichern", + "staff_enrich_cron_hint": "Der Server ergänzt planmäßig einen Stapel Bücher.", + "staff_enrich_run": "Jetzt einen Stapel ausführen", + "staff_enrich_run_body": "Der Server reichert die nächsten 20 Bücher an. Das kann ein paar Minuten dauern.", + "staff_enrich_done": "%1$d von %2$d Büchern angereichert" } diff --git a/i18n/en.json b/i18n/en.json index 5948c4b..252aeb8 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1102,5 +1102,80 @@ "staff_merge_genre_target": "Genre that stays", "staff_merge_genre_pick": "Pick a genre", "staff_merge_genre_done": "Genres merged: %1$d books, %2$d subgenres moved", - "staff_merge_genre_descendant": "A genre cannot be merged into one of its own subgenres." + "staff_merge_genre_descendant": "A genre cannot be merged into one of its own subgenres.", + "staff_import_field_authors": "Authors", + "staff_import_field_contributors": "Other contributors", + "staff_import_field_publisher": "Publisher", + "staff_import_field_genre": "Genre", + "staff_import_field_keywords": "Keywords", + "staff_import_field_description": "Description", + "staff_import_field_isbn": "ISBN", + "staff_import_field_ean": "EAN", + "staff_import_field_title": "Title", + "staff_import_field_subtitle": "Subtitle", + "staff_import_field_year": "Year", + "staff_import_field_language": "Language", + "staff_import_field_edition": "Edition", + "staff_import_field_pages": "Pages", + "staff_import_field_format": "Format", + "staff_import_field_price": "Price", + "staff_import_field_series": "Series", + "staff_import_field_dewey": "Dewey", + "staff_import_kind_csv": "CSV", + "staff_import_kind_lt": "LibraryThing", + "staff_import_file": "File", + "staff_import_choose_file": "Choose a file", + "staff_import_scraping": "Look up missing data online", + "staff_import_scraping_hint": "Slower: one row at a time, and each row may take a while.", + "staff_import_update_fields": "Fields to update on books already in the catalogue", + "staff_import_start": "Upload and import", + "staff_import_example": "Example CSV", + "staff_import_open_jobs": "Imports to finish", + "staff_import_history": "Past imports", + "staff_import_progress": "%1$d of %2$d rows", + "staff_import_when": "Started", + "staff_import_by": "By", + "staff_import_result": "Result", + "staff_import_counts": "%1$d new, %2$d updated, %3$d failed", + "staff_import_errors_csv": "Download the errors", + "staff_import_read_failed": "The file could not be read.", + "staff_import_too_big": "The file is larger than 20 MB.", + "staff_import_wrong_ext_csv": "Choose a .csv file.", + "staff_import_wrong_ext_lt": "Choose a LibraryThing export (.tsv, .csv or .txt).", + "staff_import_failed": "The import could not continue.", + "staff_import_status_running": "Importing…", + "staff_import_status_paused": "Paused", + "staff_import_status_done": "Import complete", + "staff_import_status_failed": "Import failed", + "staff_import_status_cancelled": "Import cancelled", + "staff_import_imported": "New books", + "staff_import_updated": "Books updated", + "staff_import_scraped": "Completed online", + "staff_import_authors_created": "New authors", + "staff_import_publishers_created": "New publishers", + "staff_import_errors": "Errors", + "staff_import_pause": "Pause", + "staff_import_resume": "Resume", + "staff_import_cancel": "Cancel the import", + "staff_covers_missing_title": "Missing covers", + "staff_covers_missing_count": "%1$d books with an ISBN have no cover.", + "staff_covers_missing_unknown": "Counting the books without a cover…", + "staff_covers_fetch_missing": "Fetch the missing covers", + "staff_covers_loop_progress": "%1$d of %2$d · found %3$d · not found %4$d", + "staff_covers_rate_wait": "Waiting a moment: the server limits how many covers are fetched per minute.", + "staff_covers_stop": "Stop", + "staff_covers_server_sync": "Sync all covers on the server", + "staff_covers_server_sync_body": "The server fetches every missing cover in one go. It can take up to ten minutes; keep the app open.", + "staff_covers_sync_done": "Covers synced: %1$d of %2$d", + "staff_long_job_running": "Working on the server, this can take a few minutes…", + "staff_enrich_title": "Enrichment", + "staff_enrich_with_isbn": "Books with an ISBN", + "staff_enrich_missing_cover": "Without a cover", + "staff_enrich_missing_description": "Without a description", + "staff_enrich_pending": "Still to enrich", + "staff_enrich_cron": "Enrich automatically", + "staff_enrich_cron_hint": "The server completes a batch of books on its schedule.", + "staff_enrich_run": "Run a batch now", + "staff_enrich_run_body": "The server enriches the next 20 books. It can take a couple of minutes.", + "staff_enrich_done": "Enriched %1$d of %2$d books" } diff --git a/i18n/fr.json b/i18n/fr.json index 960e930..6d5a6b7 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1102,5 +1102,80 @@ "staff_merge_genre_target": "Genre conservé", "staff_merge_genre_pick": "Choisir un genre", "staff_merge_genre_done": "Genres fusionnés : %1$d livres, %2$d sous-genres déplacés", - "staff_merge_genre_descendant": "Un genre ne peut pas être fusionné avec l’un de ses sous-genres." + "staff_merge_genre_descendant": "Un genre ne peut pas être fusionné avec l’un de ses sous-genres.", + "staff_import_field_authors": "Auteurs", + "staff_import_field_contributors": "Autres contributeurs", + "staff_import_field_publisher": "Éditeur", + "staff_import_field_genre": "Genre", + "staff_import_field_keywords": "Mots-clés", + "staff_import_field_description": "Description", + "staff_import_field_isbn": "ISBN", + "staff_import_field_ean": "EAN", + "staff_import_field_title": "Titre", + "staff_import_field_subtitle": "Sous-titre", + "staff_import_field_year": "Année", + "staff_import_field_language": "Langue", + "staff_import_field_edition": "Édition", + "staff_import_field_pages": "Pages", + "staff_import_field_format": "Format", + "staff_import_field_price": "Prix", + "staff_import_field_series": "Collection", + "staff_import_field_dewey": "Dewey", + "staff_import_kind_csv": "CSV", + "staff_import_kind_lt": "LibraryThing", + "staff_import_file": "Fichier", + "staff_import_choose_file": "Choisir un fichier", + "staff_import_scraping": "Chercher en ligne les données manquantes", + "staff_import_scraping_hint": "Plus lent : une ligne à la fois, et chaque ligne peut prendre du temps.", + "staff_import_update_fields": "Champs à mettre à jour sur les livres déjà au catalogue", + "staff_import_start": "Envoyer et importer", + "staff_import_example": "CSV d’exemple", + "staff_import_open_jobs": "Imports à terminer", + "staff_import_history": "Imports précédents", + "staff_import_progress": "%1$d sur %2$d lignes", + "staff_import_when": "Démarré", + "staff_import_by": "Par", + "staff_import_result": "Résultat", + "staff_import_counts": "%1$d nouveaux, %2$d mis à jour, %3$d en échec", + "staff_import_errors_csv": "Télécharger les erreurs", + "staff_import_read_failed": "Le fichier n’a pas pu être lu.", + "staff_import_too_big": "Le fichier dépasse 20 Mo.", + "staff_import_wrong_ext_csv": "Choisissez un fichier .csv.", + "staff_import_wrong_ext_lt": "Choisissez un export LibraryThing (.tsv, .csv ou .txt).", + "staff_import_failed": "L’import n’a pas pu continuer.", + "staff_import_status_running": "Import en cours…", + "staff_import_status_paused": "En pause", + "staff_import_status_done": "Import terminé", + "staff_import_status_failed": "Import échoué", + "staff_import_status_cancelled": "Import annulé", + "staff_import_imported": "Nouveaux livres", + "staff_import_updated": "Livres mis à jour", + "staff_import_scraped": "Complétés en ligne", + "staff_import_authors_created": "Nouveaux auteurs", + "staff_import_publishers_created": "Nouveaux éditeurs", + "staff_import_errors": "Erreurs", + "staff_import_pause": "Mettre en pause", + "staff_import_resume": "Reprendre", + "staff_import_cancel": "Annuler l’import", + "staff_covers_missing_title": "Couvertures manquantes", + "staff_covers_missing_count": "%1$d livres avec ISBN n’ont pas de couverture.", + "staff_covers_missing_unknown": "Comptage des livres sans couverture…", + "staff_covers_fetch_missing": "Récupérer les couvertures manquantes", + "staff_covers_loop_progress": "%1$d sur %2$d · trouvées %3$d · introuvables %4$d", + "staff_covers_rate_wait": "Petite attente : le serveur limite le nombre de couvertures par minute.", + "staff_covers_stop": "Arrêter", + "staff_covers_server_sync": "Synchroniser toutes les couvertures sur le serveur", + "staff_covers_server_sync_body": "Le serveur récupère toutes les couvertures manquantes d’un coup. Cela peut prendre dix minutes ; gardez l’app ouverte.", + "staff_covers_sync_done": "Couvertures synchronisées : %1$d sur %2$d", + "staff_long_job_running": "Traitement sur le serveur, cela peut prendre quelques minutes…", + "staff_enrich_title": "Enrichissement", + "staff_enrich_with_isbn": "Livres avec ISBN", + "staff_enrich_missing_cover": "Sans couverture", + "staff_enrich_missing_description": "Sans description", + "staff_enrich_pending": "Encore à enrichir", + "staff_enrich_cron": "Enrichir automatiquement", + "staff_enrich_cron_hint": "Le serveur complète un lot de livres selon son planning.", + "staff_enrich_run": "Lancer un lot maintenant", + "staff_enrich_run_body": "Le serveur enrichit les 20 livres suivants. Cela peut prendre quelques minutes.", + "staff_enrich_done": "Enrichis %1$d sur %2$d livres" } diff --git a/i18n/it.json b/i18n/it.json index 6810276..8530df9 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1102,5 +1102,80 @@ "staff_merge_genre_target": "Genere che resta", "staff_merge_genre_pick": "Scegli un genere", "staff_merge_genre_done": "Generi uniti: %1$d libri, %2$d sottogeneri spostati", - "staff_merge_genre_descendant": "Un genere non si può unire a un suo sottogenere." + "staff_merge_genre_descendant": "Un genere non si può unire a un suo sottogenere.", + "staff_import_field_authors": "Autori", + "staff_import_field_contributors": "Altri contributori", + "staff_import_field_publisher": "Editore", + "staff_import_field_genre": "Genere", + "staff_import_field_keywords": "Parole chiave", + "staff_import_field_description": "Descrizione", + "staff_import_field_isbn": "ISBN", + "staff_import_field_ean": "EAN", + "staff_import_field_title": "Titolo", + "staff_import_field_subtitle": "Sottotitolo", + "staff_import_field_year": "Anno", + "staff_import_field_language": "Lingua", + "staff_import_field_edition": "Edizione", + "staff_import_field_pages": "Pagine", + "staff_import_field_format": "Formato", + "staff_import_field_price": "Prezzo", + "staff_import_field_series": "Collana", + "staff_import_field_dewey": "Dewey", + "staff_import_kind_csv": "CSV", + "staff_import_kind_lt": "LibraryThing", + "staff_import_file": "File", + "staff_import_choose_file": "Scegli un file", + "staff_import_scraping": "Cerca online i dati mancanti", + "staff_import_scraping_hint": "Più lento: una riga alla volta, e ogni riga può richiedere tempo.", + "staff_import_update_fields": "Campi da aggiornare sui libri già in catalogo", + "staff_import_start": "Carica e importa", + "staff_import_example": "CSV di esempio", + "staff_import_open_jobs": "Importazioni da completare", + "staff_import_history": "Importazioni passate", + "staff_import_progress": "%1$d di %2$d righe", + "staff_import_when": "Avviata", + "staff_import_by": "Da", + "staff_import_result": "Esito", + "staff_import_counts": "%1$d nuovi, %2$d aggiornati, %3$d falliti", + "staff_import_errors_csv": "Scarica gli errori", + "staff_import_read_failed": "Non è stato possibile leggere il file.", + "staff_import_too_big": "Il file supera i 20 MB.", + "staff_import_wrong_ext_csv": "Scegli un file .csv.", + "staff_import_wrong_ext_lt": "Scegli un export di LibraryThing (.tsv, .csv o .txt).", + "staff_import_failed": "L’importazione non è potuta proseguire.", + "staff_import_status_running": "Importazione in corso…", + "staff_import_status_paused": "In pausa", + "staff_import_status_done": "Importazione completata", + "staff_import_status_failed": "Importazione non riuscita", + "staff_import_status_cancelled": "Importazione annullata", + "staff_import_imported": "Libri nuovi", + "staff_import_updated": "Libri aggiornati", + "staff_import_scraped": "Completati online", + "staff_import_authors_created": "Nuovi autori", + "staff_import_publishers_created": "Nuovi editori", + "staff_import_errors": "Errori", + "staff_import_pause": "Metti in pausa", + "staff_import_resume": "Riprendi", + "staff_import_cancel": "Annulla l’importazione", + "staff_covers_missing_title": "Copertine mancanti", + "staff_covers_missing_count": "%1$d libri con ISBN non hanno copertina.", + "staff_covers_missing_unknown": "Conto i libri senza copertina…", + "staff_covers_fetch_missing": "Recupera le copertine mancanti", + "staff_covers_loop_progress": "%1$d di %2$d · trovate %3$d · non trovate %4$d", + "staff_covers_rate_wait": "Attendo un momento: il server limita le copertine recuperate al minuto.", + "staff_covers_stop": "Interrompi", + "staff_covers_server_sync": "Sincronizza tutte le copertine sul server", + "staff_covers_server_sync_body": "Il server recupera tutte le copertine mancanti in un colpo solo. Può richiedere fino a dieci minuti; tieni aperta l’app.", + "staff_covers_sync_done": "Copertine sincronizzate: %1$d di %2$d", + "staff_long_job_running": "Lavoro in corso sul server, può richiedere qualche minuto…", + "staff_enrich_title": "Arricchimento", + "staff_enrich_with_isbn": "Libri con ISBN", + "staff_enrich_missing_cover": "Senza copertina", + "staff_enrich_missing_description": "Senza descrizione", + "staff_enrich_pending": "Ancora da arricchire", + "staff_enrich_cron": "Arricchisci automaticamente", + "staff_enrich_cron_hint": "Il server completa un gruppo di libri secondo la sua pianificazione.", + "staff_enrich_run": "Esegui ora un gruppo", + "staff_enrich_run_body": "Il server arricchisce i prossimi 20 libri. Può richiedere un paio di minuti.", + "staff_enrich_done": "Arricchiti %1$d di %2$d libri" } From 16a3ca9e733158ccd58d65db12874da3b0b55d0d Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 02:06:17 +0200 Subject: [PATCH 39/62] Manage donation offers and archives natively Donation offers are reviewed by status: accept, reject, receive (into the wanted title, or into a catalogue record picked when the offer names none) and delete, with the closed-offer refusal explained; wanted titles can be marked as received when the copy arrived outside an offer. Archival units, published or not, are listed with search and a publish filter and edited across identity, dates, description, material and links, with the plugin's required fields and year rules checked before sending and the server's field or duplicate reference shown on the field. A saved unit takes authorities with a role, a cover and documents. Authority records have their own list and editor. --- .../assets/fixtures/archive_10.json | 40 + .../assets/fixtures/archive_3.json | 54 ++ .../assets/fixtures/archive_3_published.json | 54 ++ .../assets/fixtures/archive_lookups.json | 64 ++ .../assets/fixtures/archives_staff.json | 31 + .../assets/fixtures/offer_41_accepted.json | 25 + .../assets/fixtures/offer_41_received.json | 28 + .../assets/fixtures/offer_42_received.json | 25 + .../assets/fixtures/offers_pending.json | 49 ++ .../app/staffe2e/ArchivesStaffFlowTest.kt | 53 ++ .../app/staffe2e/DesiderataStaffFlowTest.kt | 46 + .../pinakes/app/data/staff/ArchiveForms.kt | 104 +++ .../app/ui/navigation/PinakesNavHost.kt | 33 + .../app/ui/screens/staff/StaffCommon.kt | 2 +- .../staff/plugins/StaffArchivesScreens.kt | 827 ++++++++++++++++++ .../staff/plugins/StaffDesiderataScreen.kt | 376 ++++++++ .../pinakes/app/staff/ArchiveFormStateTest.kt | 87 ++ i18n/de.json | 118 ++- i18n/en.json | 118 ++- i18n/fr.json | 118 ++- i18n/it.json | 118 ++- 21 files changed, 2365 insertions(+), 5 deletions(-) create mode 100644 app/src/androidTest/assets/fixtures/archive_10.json create mode 100644 app/src/androidTest/assets/fixtures/archive_3.json create mode 100644 app/src/androidTest/assets/fixtures/archive_3_published.json create mode 100644 app/src/androidTest/assets/fixtures/archive_lookups.json create mode 100644 app/src/androidTest/assets/fixtures/archives_staff.json create mode 100644 app/src/androidTest/assets/fixtures/offer_41_accepted.json create mode 100644 app/src/androidTest/assets/fixtures/offer_41_received.json create mode 100644 app/src/androidTest/assets/fixtures/offer_42_received.json create mode 100644 app/src/androidTest/assets/fixtures/offers_pending.json create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/ArchivesStaffFlowTest.kt create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/DesiderataStaffFlowTest.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/ArchiveForms.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffArchivesScreens.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffDesiderataScreen.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/ArchiveFormStateTest.kt diff --git a/app/src/androidTest/assets/fixtures/archive_10.json b/app/src/androidTest/assets/fixtures/archive_10.json new file mode 100644 index 0000000..f92fd81 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/archive_10.json @@ -0,0 +1,40 @@ +{ + "data": { + "id": 10, + "parent_id": null, + "title": "Diari", + "reference_code": "IT-PIN-9", + "level": "fonds", + "cover_url": null, + "published": true, + "fields": { + "reference_code": "IT-PIN-9", + "institution_code": "PINAKES", + "level": "fonds", + "formal_title": "", + "constructed_title": "Diari", + "date_start": null, + "date_end": null, + "extent": "", + "scope_content": "", + "language_codes": "ita", + "parent_id": null, + "specific_material": "text", + "dimensions": "", + "color_mode": "", + "photographer": "", + "publisher": "", + "collection_name": "", + "local_classification": "", + "iiif_manifest_url": "", + "rights_statement_url": "", + "ark_identifier": "", + "version_note": "", + "published": 0 + }, + "files": [], + "authorities": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/archive_3.json b/app/src/androidTest/assets/fixtures/archive_3.json new file mode 100644 index 0000000..be6c01a --- /dev/null +++ b/app/src/androidTest/assets/fixtures/archive_3.json @@ -0,0 +1,54 @@ +{ + "data": { + "id": 3, + "parent_id": null, + "title": "Fondo Rossi", + "reference_code": "IT-PIN-1", + "level": "fonds", + "cover_url": null, + "published": false, + "fields": { + "reference_code": "IT-PIN-1", + "institution_code": "PINAKES", + "level": "fonds", + "formal_title": "", + "constructed_title": "Fondo Rossi", + "date_start": 1920, + "date_end": 1950, + "extent": "3 buste", + "scope_content": "Carte di famiglia", + "language_codes": "ita", + "parent_id": null, + "specific_material": "text", + "dimensions": "", + "color_mode": "", + "photographer": "", + "publisher": "", + "collection_name": "", + "local_classification": "", + "iiif_manifest_url": "", + "rights_statement_url": "", + "ark_identifier": "", + "version_note": "", + "published": 0 + }, + "files": [ + { + "id": 9, + "url": "/uploads/archives/9.pdf", + "mime": "application/pdf", + "label": "inventario.pdf" + } + ], + "authorities": [ + { + "id": 2, + "type": "person", + "name": "Rossi, Mario", + "role": "creator" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/archive_3_published.json b/app/src/androidTest/assets/fixtures/archive_3_published.json new file mode 100644 index 0000000..8be5ed8 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/archive_3_published.json @@ -0,0 +1,54 @@ +{ + "data": { + "id": 3, + "parent_id": null, + "title": "Fondo Rossi", + "reference_code": "IT-PIN-1", + "level": "fonds", + "cover_url": null, + "published": true, + "fields": { + "reference_code": "IT-PIN-1", + "institution_code": "PINAKES", + "level": "fonds", + "formal_title": "", + "constructed_title": "Fondo Rossi", + "date_start": 1920, + "date_end": 1950, + "extent": "3 buste", + "scope_content": "Carte di famiglia", + "language_codes": "ita", + "parent_id": null, + "specific_material": "text", + "dimensions": "", + "color_mode": "", + "photographer": "", + "publisher": "", + "collection_name": "", + "local_classification": "", + "iiif_manifest_url": "", + "rights_statement_url": "", + "ark_identifier": "", + "version_note": "", + "published": 1 + }, + "files": [ + { + "id": 9, + "url": "/uploads/archives/9.pdf", + "mime": "application/pdf", + "label": "inventario.pdf" + } + ], + "authorities": [ + { + "id": 2, + "type": "person", + "name": "Rossi, Mario", + "role": "creator" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/archive_lookups.json b/app/src/androidTest/assets/fixtures/archive_lookups.json new file mode 100644 index 0000000..481ffef --- /dev/null +++ b/app/src/androidTest/assets/fixtures/archive_lookups.json @@ -0,0 +1,64 @@ +{ + "data": { + "levels": [ + { + "value": "fonds", + "label": "Fondo" + }, + { + "value": "series", + "label": "Serie" + }, + { + "value": "file", + "label": "Fascicolo" + }, + { + "value": "item", + "label": "Unità" + } + ], + "specific_materials": [ + { + "value": "text", + "label": "Testo" + }, + { + "value": "photograph", + "label": "Fotografia" + } + ], + "color_modes": [ + { + "value": "bw", + "label": "Bianco e nero" + } + ], + "authority_types": [ + { + "value": "person", + "label": "Persona" + }, + { + "value": "corporate", + "label": "Ente" + }, + { + "value": "family", + "label": "Famiglia" + } + ], + "authority_roles": [ + { + "value": "creator", + "label": "Produttore" + }, + { + "value": "subject", + "label": "Soggetto" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/archives_staff.json b/app/src/androidTest/assets/fixtures/archives_staff.json new file mode 100644 index 0000000..c6bbfc4 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/archives_staff.json @@ -0,0 +1,31 @@ +{ + "data": [ + { + "id": 3, + "parent_id": null, + "title": "Fondo Rossi", + "reference_code": "IT-PIN-1", + "level": "fonds", + "date_start": 1920, + "date_end": 1950, + "cover_url": null, + "published": false + }, + { + "id": 4, + "parent_id": 3, + "title": "Lettere", + "reference_code": "IT-PIN-2", + "level": "series", + "date_start": 1920, + "date_end": 1950, + "cover_url": null, + "published": true + } + ], + "meta": { + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/offer_41_accepted.json b/app/src/androidTest/assets/fixtures/offer_41_accepted.json new file mode 100644 index 0000000..e2262be --- /dev/null +++ b/app/src/androidTest/assets/fixtures/offer_41_accepted.json @@ -0,0 +1,25 @@ +{ + "data": { + "id": 41, + "status": "accepted", + "status_label": "In attesa", + "donor_name": "Carla Bianchi", + "donor_email": "carla@example.org", + "title": "Orlando", + "author": "Virginia Woolf", + "publisher": null, + "isbn": null, + "notes": "Good condition", + "book": { + "id": 5, + "title": "Orlando" + }, + "received_book": null, + "copy_id": null, + "source": "mobile", + "created_at": "2026-10-12 10:00:00", + "received_at": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/offer_41_received.json b/app/src/androidTest/assets/fixtures/offer_41_received.json new file mode 100644 index 0000000..c36f438 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/offer_41_received.json @@ -0,0 +1,28 @@ +{ + "data": { + "id": 41, + "status": "received", + "status_label": "In attesa", + "donor_name": "Carla Bianchi", + "donor_email": "carla@example.org", + "title": "Orlando", + "author": "Virginia Woolf", + "publisher": null, + "isbn": null, + "notes": "Good condition", + "book": { + "id": 5, + "title": "Orlando" + }, + "received_book": { + "id": 5, + "title": "Orlando" + }, + "copy_id": 77, + "source": "mobile", + "created_at": "2026-10-12 10:00:00", + "received_at": "2026-10-14 11:00:00" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/offer_42_received.json b/app/src/androidTest/assets/fixtures/offer_42_received.json new file mode 100644 index 0000000..fe5b1ef --- /dev/null +++ b/app/src/androidTest/assets/fixtures/offer_42_received.json @@ -0,0 +1,25 @@ +{ + "data": { + "id": 42, + "status": "received", + "status_label": "In attesa", + "donor_name": "Carla Bianchi", + "donor_email": "carla@example.org", + "title": "Un libro qualunque", + "author": "Anonimo", + "publisher": null, + "isbn": null, + "notes": "Good condition", + "book": null, + "received_book": { + "id": 5, + "title": "Orlando" + }, + "copy_id": 78, + "source": "mobile", + "created_at": "2026-10-12 10:00:00", + "received_at": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/offers_pending.json b/app/src/androidTest/assets/fixtures/offers_pending.json new file mode 100644 index 0000000..1665f20 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/offers_pending.json @@ -0,0 +1,49 @@ +{ + "data": [ + { + "id": 41, + "status": "pending", + "status_label": "In attesa", + "donor_name": "Carla Bianchi", + "donor_email": "carla@example.org", + "title": "Orlando", + "author": "Virginia Woolf", + "publisher": null, + "isbn": null, + "notes": "Good condition", + "book": { + "id": 5, + "title": "Orlando" + }, + "received_book": null, + "copy_id": null, + "source": "mobile", + "created_at": "2026-10-12 10:00:00", + "received_at": null + }, + { + "id": 42, + "status": "pending", + "status_label": "In attesa", + "donor_name": "Carla Bianchi", + "donor_email": "carla@example.org", + "title": "Un libro qualunque", + "author": "Anonimo", + "publisher": null, + "isbn": null, + "notes": "Good condition", + "book": null, + "received_book": null, + "copy_id": null, + "source": "mobile", + "created_at": "2026-10-12 10:00:00", + "received_at": null + } + ], + "meta": { + "count": 2, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ArchivesStaffFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ArchivesStaffFlowTest.kt new file mode 100644 index 0000000..3c2ccfb --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ArchivesStaffFlowTest.kt @@ -0,0 +1,53 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Hub → Archives: publish an unpublished unit, and create a new one (validated before sending). */ +class ArchivesStaffFlowTest : AppFlowTest() { + + private fun open(configure: FakePinakes.() -> Unit) { + launch { + json("GET staff/archives", "archives_staff") + json("GET staff/archives/lookups", "archive_lookups") + json("GET staff/archives/3", "archive_3") + configure() + } + openStaffHub() + click(text(R.string.staff_archives)) + waitText("Fondo Rossi") + } + + @Test fun publishAnUnpublishedUnitKeepsEveryOtherField() { + open { json("PUT staff/archives/3", "archive_3_published") } + click("Fondo Rossi") + waitText("IT-PIN-1") + click(text(R.string.staff_archive_published)) + clickTag("archive-save") + val body = lastBody("PUT", "staff/archives/3") + assertTrue(body, body.contains("\"published\":true")) + assertTrue(body, body.contains("\"reference_code\":\"IT-PIN-1\"")) + assertTrue(body, body.contains("\"date_start\":1920")) + assertTrue(body, body.contains("\"extent\":\"3 buste\"")) + waitText(text(R.string.staff_archive_saved)) + } + + @Test fun createAUnitAfterTheRequiredFields() { + open { json("POST staff/archives", "archive_10", status = 201) } + clickDescription(text(R.string.staff_archive_new)) + clickTag("archive-save") + waitText(text(R.string.staff_form_fix_errors)) + assertTrue(api.recorded("POST", "staff/archives").isEmpty()) + typeInto(text(R.string.staff_archive_reference_code), "IT-PIN-9") + typeInto(text(R.string.staff_archive_constructed_title), "Diari") + clickTag("archive-save") + val body = lastBody("POST", "staff/archives") + assertTrue(body, body.contains("\"reference_code\":\"IT-PIN-9\"")) + assertTrue(body, body.contains("\"institution_code\":\"PINAKES\"")) + assertTrue(body, body.contains("\"date_start\":null")) + // Saved: the unit now takes authorities and files. + waitText(text(R.string.staff_archive_created)) + waitText(text(R.string.staff_archive_attach_authority)) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/DesiderataStaffFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/DesiderataStaffFlowTest.kt new file mode 100644 index 0000000..d9b92b5 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/DesiderataStaffFlowTest.kt @@ -0,0 +1,46 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Hub → Donation offers: receive an offer for a wanted title, receive one into a picked record, a closed offer. */ +class DesiderataStaffFlowTest : AppFlowTest() { + + private fun open(configure: FakePinakes.() -> Unit) { + launch { + json("GET staff/desiderata/offers", "offers_pending") + configure() + } + openStaffHub() + click(text(R.string.staff_desiderata)) + waitText("Un libro qualunque") + } + + @Test fun receiveAnOfferForAWantedTitle() { + open { json("POST staff/desiderata/offers/41/receive", "offer_41_received") } + clickTag("offer-41") + clickTag("offer-receive") + assertEquals("{}", lastBody("POST", "staff/desiderata/offers/41/receive")) + waitText(text(R.string.staff_offer_received)) + } + + @Test fun anOfferWithoutATitleIsReceivedIntoAPickedRecord() { + open { json("POST staff/desiderata/offers/42/receive", "offer_42_received") } + clickTag("offer-42") + clickTag("offer-receive") + waitText(text(R.string.staff_offer_receive_into)) + clickTag("pick-5") + assertEquals("""{"received_book_id":5}""", lastBody("POST", "staff/desiderata/offers/42/receive")) + waitText(text(R.string.staff_offer_received)) + } + + @Test fun aClosedOfferSaysSo() { + open { + body("POST staff/desiderata/offers/41/accept", 409, """{"data":null,"meta":{},"error":{"code":"offer_closed","message":"x"}}""") + } + clickTag("offer-41") + click(text(R.string.staff_offer_accept)) + waitText(text(R.string.staff_err_offer_closed)) + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/ArchiveForms.kt b/app/src/main/java/com/pinakes/app/data/staff/ArchiveForms.kt new file mode 100644 index 0000000..d769d99 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/ArchiveForms.kt @@ -0,0 +1,104 @@ +package com.pinakes.app.data.staff + +import com.pinakes.app.data.model.AuthorityDetail +import com.pinakes.app.data.model.StaffArchiveDetail +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** Fields an archive form can flag (client checks, or `meta.field` of a 422). */ +enum class ArchiveField(val wire: String) { + REFERENCE_CODE("reference_code"), LEVEL("level"), CONSTRUCTED_TITLE("constructed_title"), + DATE_START("date_start"), DATE_END("date_end"), PARENT_ID("parent_id"); + + companion object { + fun fromWire(value: String?): ArchiveField? = entries.firstOrNull { it.wire == value } + } +} + +/** + * An archival unit being edited: the 23 payload keys of the Archives plugin + * (`extractArchivalUnitPayload`), as text, plus the publish flag. [toJson] sends every key so a + * cleared field empties the column; numbers go as numbers or JSON null. Pure. + */ +data class ArchiveForm( + val values: Map = emptyMap(), + val published: Boolean = true, +) { + operator fun get(key: String): String = values[key].orEmpty() + + fun with(key: String, value: String): ArchiveForm = copy(values = values + (key to value)) + + /** Client checks that mirror the plugin's validation (required fields, numeric years, order). */ + fun errors(): Set { + val out = mutableSetOf() + if (this["reference_code"].isBlank()) out += ArchiveField.REFERENCE_CODE + if (this["level"].isBlank()) out += ArchiveField.LEVEL + if (this["constructed_title"].isBlank()) out += ArchiveField.CONSTRUCTED_TITLE + val start = this["date_start"].trim() + val end = this["date_end"].trim() + val startN = start.toIntOrNull() + val endN = end.toIntOrNull() + if (start.isNotEmpty() && (startN == null || startN !in -9999..9999)) out += ArchiveField.DATE_START + if (end.isNotEmpty() && (endN == null || endN !in -9999..9999)) out += ArchiveField.DATE_END + if (startN != null && endN != null && endN < startN) out += ArchiveField.DATE_END + if (this["parent_id"].isNotBlank() && this["parent_id"].trim().toIntOrNull()?.let { it > 0 } != true) out += ArchiveField.PARENT_ID + return out + } + + fun toJson(): JsonObject = buildJsonObject { + for (key in TEXT_KEYS) put(key, JsonPrimitive(this@ArchiveForm[key].trim())) + for (key in INT_KEYS) put(key, this@ArchiveForm[key].trim().toIntOrNull()?.let { JsonPrimitive(it) } ?: JsonNull) + put("published", JsonPrimitive(published)) + } + + companion object { + val INT_KEYS = listOf("date_start", "date_end", "parent_id") + val TEXT_KEYS = listOf( + "reference_code", "institution_code", "level", "formal_title", "constructed_title", "extent", "scope_content", + "language_codes", "specific_material", "dimensions", "color_mode", "photographer", "publisher", "collection_name", + "local_classification", "iiif_manifest_url", "rights_statement_url", "ark_identifier", "version_note", + ) + + /** A new unit with the plugin's defaults. */ + fun new(parentId: Int? = null) = ArchiveForm( + values = mapOf("institution_code" to "PINAKES", "language_codes" to "ita", "specific_material" to "text", "level" to "fonds") + + (parentId?.takeIf { it > 0 }?.let { mapOf("parent_id" to it.toString()) } ?: emptyMap()), + published = true, + ) + + fun from(detail: StaffArchiveDetail): ArchiveForm = ArchiveForm( + values = (TEXT_KEYS + INT_KEYS).associateWith { detail.field(it) } + .let { v -> if (v["reference_code"].isNullOrBlank()) v + ("reference_code" to detail.referenceCode) else v } + .let { v -> if (v["level"].isNullOrBlank()) v + ("level" to detail.level) else v }, + published = detail.published, + ) + } +} + +/** An ISAAR(CPF) authority being edited (`extractAuthorityPayload` keys). Pure. */ +data class AuthorityForm(val values: Map = mapOf("type" to "person")) { + operator fun get(key: String): String = values[key].orEmpty() + fun with(key: String, value: String) = copy(values = values + (key to value)) + val valid: Boolean get() = this["type"].isNotBlank() && this["authorised_form"].isNotBlank() + + fun toJson(): JsonObject = buildJsonObject { KEYS.forEach { put(it, JsonPrimitive(this@AuthorityForm[it].trim())) } } + + companion object { + val KEYS = listOf( + "type", "authorised_form", "parallel_forms", "other_forms", "identifiers", "dates_of_existence", "history", + "places", "legal_status", "functions", "mandates", "internal_structure", "general_context", + ) + + fun from(d: AuthorityDetail) = AuthorityForm( + mapOf( + "type" to d.type.orEmpty(), "authorised_form" to d.authorisedForm.orEmpty(), "parallel_forms" to d.parallelForms.orEmpty(), + "other_forms" to d.otherForms.orEmpty(), "identifiers" to d.identifiers.orEmpty(), "dates_of_existence" to d.datesOfExistence.orEmpty(), + "history" to d.history.orEmpty(), "places" to d.places.orEmpty(), "legal_status" to d.legalStatus.orEmpty(), + "functions" to d.functions.orEmpty(), "mandates" to d.mandates.orEmpty(), "internal_structure" to d.internalStructure.orEmpty(), + "general_context" to d.generalContext.orEmpty(), + ), + ) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index 9fd3c59..8f00670 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -56,6 +56,11 @@ import com.pinakes.app.ui.screens.staff.circulation.StaffReservationsScreen import com.pinakes.app.ui.screens.staff.circulation.StaffUserScreen import com.pinakes.app.ui.screens.staff.scan.StaffCopyScanScreen import com.pinakes.app.ui.screens.staff.content.StaffMergeScreen +import com.pinakes.app.ui.screens.staff.plugins.StaffArchiveEditScreen +import com.pinakes.app.ui.screens.staff.plugins.StaffArchivesScreen +import com.pinakes.app.ui.screens.staff.plugins.StaffAuthoritiesScreen +import com.pinakes.app.ui.screens.staff.plugins.StaffAuthorityEditScreen +import com.pinakes.app.ui.screens.staff.plugins.StaffDesiderataScreen import com.pinakes.app.ui.screens.staff.content.StaffCoversScreen import com.pinakes.app.ui.screens.staff.content.StaffImportJobScreen import com.pinakes.app.ui.screens.staff.content.StaffImportsScreen @@ -281,6 +286,34 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { composable(Routes.STAFF_COVERS, enterTransition = slideIn, popExitTransition = slideOut) { StaffCoversScreen(onNavigateUp = { navController.popBackStack() }) } + // ---- Staff plugin content (Mobile API 1.7.0) ---- + composable(Routes.STAFF_DESIDERATA, enterTransition = slideIn, popExitTransition = slideOut) { + StaffDesiderataScreen(onNavigateUp = { navController.popBackStack() }, onOpenBook = { navController.navigate(Routes.staffBookEdit(it)) }) + } + composable(Routes.STAFF_ARCHIVES, enterTransition = slideIn, popExitTransition = slideOut) { + StaffArchivesScreen( + onNavigateUp = { navController.popBackStack() }, + onOpen = { navController.navigate(Routes.staffArchiveEdit(it)) }, + onNew = { navController.navigate(Routes.staffArchiveEdit(0)) }, + ) + } + composable( + Routes.STAFF_ARCHIVE_EDIT, + arguments = listOf(navArgument(Routes.ARG_ARCHIVE_ID) { type = NavType.IntType; defaultValue = 0 }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StaffArchiveEditScreen(onNavigateUp = { navController.popBackStack() }) + } + composable(Routes.STAFF_AUTHORITIES, enterTransition = slideIn, popExitTransition = slideOut) { + StaffAuthoritiesScreen(onNavigateUp = { navController.popBackStack() }, onOpen = { navController.navigate(Routes.staffAuthorityEdit(it)) }) + } + composable( + Routes.STAFF_AUTHORITY_EDIT, + arguments = listOf(navArgument(Routes.ARG_AUTHORITY_ID) { type = NavType.IntType; defaultValue = 0 }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + StaffAuthorityEditScreen(onNavigateUp = { navController.popBackStack() }) + } composable(Routes.STAFF_COPY_SCAN, enterTransition = slideIn, popExitTransition = slideOut) { StaffCopyScanScreen( onNavigateUp = { navController.popBackStack() }, diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt index 3a00e9f..855644d 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffCommon.kt @@ -373,7 +373,7 @@ internal fun SearchPickerSheet( enabled = item.enabled, shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerLow, - modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs), + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("pick-${item.id}"), ) { Row(Modifier.padding(Spacing.md), verticalAlignment = Alignment.CenterVertically) { if (selected != null) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffArchivesScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffArchivesScreens.kt new file mode 100644 index 0000000..da586ab --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffArchivesScreens.kt @@ -0,0 +1,827 @@ +package com.pinakes.app.ui.screens.staff.plugins + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.AttachFile +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Image +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.ArchiveLookups +import com.pinakes.app.data.model.AuthorityRow +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.StaffArchiveDetail +import com.pinakes.app.data.model.StaffArchiveRow +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffPluginsRepository +import com.pinakes.app.data.repository.isDuplicate +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.ArchiveField +import com.pinakes.app.data.staff.ArchiveForm +import com.pinakes.app.data.staff.AuthorityForm +import com.pinakes.app.data.staff.CoverImageEncoder +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.MediaRow +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SearchField +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.SearchController +import com.pinakes.app.ui.screens.staff.SearchPickerSheet +import com.pinakes.app.ui.screens.staff.StaffActionRow +import com.pinakes.app.ui.screens.staff.StaffFab +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** Mime types the Archives plugin accepts as documents (`uploadDocumentAction`). */ +private val DOCUMENT_MIMES = arrayOf( + "application/pdf", "image/jpeg", "image/png", "image/tiff", "image/webp", "audio/mpeg", "audio/wav", "audio/x-wav", + "video/mp4", "text/plain", "application/xml", "text/xml", +) + +/** The plugin's maximum document size (200 MB): larger files are refused before uploading. */ +private const val MAX_DOCUMENT_BYTES = 200L * 1024 * 1024 + +// ============================================================================================= +// Archive list +// ============================================================================================= + +enum class PublishedFilter(val wire: String, val label: Int) { + ALL("all", R.string.staff_archives_filter_all), PUBLISHED("1", R.string.staff_archives_filter_published), DRAFT("0", R.string.staff_archives_filter_draft), +} + +data class ArchivesUiState( + val query: String = "", + val filter: PublishedFilter = PublishedFilter.ALL, + val items: List = emptyList(), + val nextCursor: String? = null, + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffArchivesViewModel @Inject constructor(private val plugins: StaffPluginsRepository) : ViewModel() { + private val _state = MutableStateFlow(ArchivesUiState()) + val state: StateFlow = _state.asStateFlow() + private var searchJob: Job? = null + private var generation = 0 + + fun reload() { + val gen = ++generation + _state.update { it.copy(loading = it.items.isEmpty() && !it.refreshing, error = null) } + viewModelScope.launch { + val s = _state.value + val res = plugins.archives(s.query, s.filter.wire, null) + if (gen != generation) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy(items = res.data.items, nextCursor = res.data.nextCursor, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun loadMore() { + val cursor = _state.value.nextCursor ?: return + val gen = generation + viewModelScope.launch { + val s = _state.value + val res = plugins.archives(s.query, s.filter.wire, cursor) + if (gen != generation) return@launch + if (res is ApiResult.Success) _state.update { it.copy(items = (it.items + res.data.items).distinctBy(StaffArchiveRow::id), nextCursor = res.data.nextCursor) } + } + } + + fun refresh() { _state.update { it.copy(refreshing = true) }; reload() } + fun setFilter(f: PublishedFilter) { _state.update { it.copy(filter = f, items = emptyList()) }; reload() } + + fun onQuery(value: String) { + _state.update { it.copy(query = value) } + searchJob?.cancel() + searchJob = viewModelScope.launch { delay(350); reload() } + } +} + +/** Every archival unit, published or not, with search and a publish filter. */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun StaffArchivesScreen(onNavigateUp: () -> Unit, onOpen: (Int) -> Unit, onNew: () -> Unit) { + val vm: StaffArchivesViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.archives, onNavigateUp) + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) vm.reload() } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_archives), onNavigateUp = onNavigateUp) }, + floatingActionButton = { StaffFab(Icons.Outlined.Add, stringResource(R.string.staff_archive_new), onNew) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + SearchField(query = state.query, onQueryChange = vm::onQuery, placeholder = stringResource(R.string.staff_archives_search), + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg, vertical = Spacing.sm)) + FlowRow(Modifier.padding(horizontal = Spacing.lg), horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + PublishedFilter.entries.forEach { f -> + FilterChip(selected = state.filter == f, onClick = { vm.setFilter(f) }, label = { Text(stringResource(f.label)) }, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer)) + } + } + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize()) { + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = state.items.isEmpty(), + emptyTitle = stringResource(R.string.staff_archives_empty), emptyIcon = Icons.Outlined.Archive, onRetry = vm::refresh, + ) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp)) { + items(state.items, key = { it.id }) { row -> + MediaRow( + title = row.title, coverUrl = row.coverUrl, + line1 = listOfNotNull(row.referenceCode.takeIf { it.isNotBlank() }, row.level.takeIf { it.isNotBlank() }, row.datesLabel).joinToString(" · "), + line2 = stringResource(if (row.published) R.string.staff_archive_published else R.string.staff_archive_draft), + onClick = { onOpen(row.id) }, + modifier = Modifier.padding(vertical = Spacing.xs).testTag("archive-${row.id}"), + ) + } + if (state.nextCursor != null) item(key = "more") { + LaunchedEffect(state.items.size) { vm.loadMore() } + } + } + } + } + } + } +} + +// ============================================================================================= +// Archive editor +// ============================================================================================= + +data class ArchiveEditUiState( + val id: Int = 0, + val detail: StaffArchiveDetail? = null, + val form: ArchiveForm = ArchiveForm.new(), + val lookups: ArchiveLookups = ArchiveLookups(), + val loading: Boolean = true, + val loadError: StaffMessage? = null, + val saving: Boolean = false, + val showErrors: Boolean = false, + val serverField: ArchiveField? = null, + val formError: StaffMessage? = null, + val uploading: Boolean = false, + val attaching: Boolean = false, + val attachRole: String = "creator", + val confirmDelete: Boolean = false, + val message: StaffMessage? = null, + val forbidden: Boolean = false, + val deleted: Boolean = false, +) { + val errors: Set get() = (if (showErrors) form.errors() else emptySet()) + setOfNotNull(serverField) +} + +@HiltViewModel +class StaffArchiveEditViewModel @Inject constructor( + private val plugins: StaffPluginsRepository, + @ApplicationContext private val context: Context, + savedState: SavedStateHandle, +) : ViewModel() { + private val _state = MutableStateFlow(ArchiveEditUiState(id = savedState.get(Routes.ARG_ARCHIVE_ID) ?: 0)) + val state: StateFlow = _state.asStateFlow() + + val authorities = SearchController(viewModelScope, minLength = 1) { q -> + when (val res = plugins.authorities(q)) { + is ApiResult.Success -> ApiResult.Success(res.data.items.map { PickItem(it.id, it.name, listOfNotNull(it.type, it.dates).joinToString(" · ")) }) + is ApiResult.Failure -> res + } + }.also { c -> c.onFailure = { f -> if (f.isForbidden()) _state.update { it.copy(forbidden = true) } } } + + init { + viewModelScope.launch { (plugins.archiveLookups() as? ApiResult.Success)?.data?.let { l -> _state.update { it.copy(lookups = l) } } } + load() + } + + fun load() { + val id = _state.value.id + if (id <= 0) { + _state.update { it.copy(loading = false) } + return + } + viewModelScope.launch { + val res = plugins.archive(id) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(detail = res.data, form = ArchiveForm.from(res.data), loading = false, loadError = null) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), loadError = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun edit(key: String, value: String) = _state.update { + it.copy(form = it.form.with(key, value), formError = null, serverField = it.serverField?.takeIf { f -> f.wire != key }) + } + + fun setPublished(value: Boolean) = _state.update { it.copy(form = it.form.copy(published = value)) } + + fun save() { + val s = _state.value + if (s.saving) return + if (s.form.errors().isNotEmpty()) { + _state.update { it.copy(showErrors = true, formError = StaffMessage(R.string.staff_form_fix_errors)) } + return + } + _state.update { it.copy(saving = true, showErrors = true, formError = null, serverField = null) } + viewModelScope.launch { + val res = plugins.saveArchive(s.id, s.form.toJson()) + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + saving = false, id = res.data.id, detail = res.data, form = ArchiveForm.from(res.data), + message = StaffMessage(if (s.id <= 0) R.string.staff_archive_created else R.string.staff_archive_saved), + ) + is ApiResult.Failure -> it.copy( + saving = false, forbidden = res.isForbidden(), serverField = ArchiveField.fromWire(res.meta?.field) + ?: if (res.isDuplicate()) ArchiveField.REFERENCE_CODE else null, + formError = if (res.isDuplicate()) StaffMessage(R.string.staff_archive_duplicate) else res.toStaffMessage(R.string.staff_error_save), + ) + } + } + } + } + + private fun upload(call: suspend () -> ApiResult?, success: Int) { + if (_state.value.uploading) return + _state.update { it.copy(uploading = true) } + viewModelScope.launch { + val res = call() + _state.update { + when (res) { + null -> it.copy(uploading = false, message = StaffMessage(R.string.staff_import_read_failed)) + is ApiResult.Success -> it.copy(uploading = false, detail = res.data, message = StaffMessage(success)) + is ApiResult.Failure -> it.copy(uploading = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + + fun uploadCover(uri: Uri?) { + val id = _state.value.id + if (uri == null || id <= 0) return + upload({ + val bytes = withContext(Dispatchers.IO) { CoverImageEncoder.encode(context, uri) } + bytes?.let { plugins.uploadArchiveCover(id, "cover.jpg", it, "image/jpeg") } + }, R.string.staff_archive_cover_uploaded) + } + + fun removeCover() { + val id = _state.value.id + if (id > 0) upload({ plugins.deleteArchiveCover(id) }, R.string.staff_archive_cover_removed) + } + + fun uploadDocument(uri: Uri?) { + val id = _state.value.id + if (uri == null || id <= 0) return + upload({ + withContext(Dispatchers.IO) { readDocument(uri) }?.let { (name, mime, bytes) -> plugins.uploadArchiveDocument(id, name, bytes, mime) } + }, R.string.staff_archive_document_uploaded) + } + + private fun readDocument(uri: Uri): Triple? = runCatching { + val resolver = context.contentResolver + var name = "document" + var size = -1L + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> + if (c.moveToFirst()) { + c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { i -> c.getString(i)?.let { name = it } } + c.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }?.let { i -> if (!c.isNull(i)) size = c.getLong(i) } + } + } + if (size > MAX_DOCUMENT_BYTES) return null + val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: return null + Triple(name, resolver.getType(uri), bytes) + }.getOrNull() + + fun removeDocument(fileId: Int) { + val id = _state.value.id + if (id > 0) upload({ plugins.deleteArchiveDocument(id, fileId) }, R.string.staff_archive_document_removed) + } + + fun openAttach() = _state.update { it.copy(attaching = true) } + fun closeAttach() { _state.update { it.copy(attaching = false) }; authorities.clear() } + fun setRole(role: String) = _state.update { it.copy(attachRole = role) } + + fun attach(item: PickItem) { + val id = _state.value.id + val role = _state.value.attachRole + closeAttach() + if (id > 0) upload({ plugins.attachAuthority(id, item.id, role) }, R.string.staff_archive_authority_attached) + } + + fun detach(authorityId: Int) { + val id = _state.value.id + if (id > 0) upload({ plugins.detachAuthority(id, authorityId) }, R.string.staff_archive_authority_detached) + } + + fun askDelete() = _state.update { it.copy(confirmDelete = true) } + fun dismissDelete() = _state.update { it.copy(confirmDelete = false) } + + fun delete() { + val id = _state.value.id + _state.update { it.copy(confirmDelete = false) } + if (id <= 0) return + viewModelScope.launch { + when (val res = plugins.deleteArchive(id)) { + is ApiResult.Success -> _state.update { it.copy(deleted = true) } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) } + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** + * Create or edit an archival unit (ISAD(G) identity, dates, description, material, links), + * publish it, attach authorities with a role, and upload a cover or documents. Uploads and + * authorities need a saved unit, so they appear after the first save. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffArchiveEditScreen(onNavigateUp: () -> Unit) { + val vm: StaffArchiveEditViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.archives || state.deleted, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + val coverPicker = rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia(), vm::uploadCover) + val documentPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument(), vm::uploadDocument) + + Scaffold( + topBar = { + PinakesTopBar( + title = stringResource(if (state.id > 0) R.string.staff_archive_edit else R.string.staff_archive_new), + onNavigateUp = onNavigateUp, + actions = { + if (state.id > 0) IconButton(onClick = vm::askDelete) { Icon(Icons.Outlined.DeleteOutline, contentDescription = stringResource(R.string.staff_archive_delete)) } + }, + ) + }, + bottomBar = { + if (!state.loading && state.loadError == null) { + Surface(color = MaterialTheme.colorScheme.background) { + PrimaryButton( + stringResource(R.string.action_save), onClick = vm::save, loading = state.saving, + modifier = Modifier.fillMaxWidth().navigationBarsPadding().imePadding().padding(horizontal = Spacing.lg, vertical = Spacing.sm).testTag("archive-save"), + ) + } + } + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + when { + state.loading -> LoadingState(modifier = Modifier.padding(padding)) + state.loadError != null -> ErrorState(message = state.loadError?.resolve().orEmpty(), onRetry = vm::load, modifier = Modifier.padding(padding)) + else -> ArchiveForm(state, vm, Modifier.padding(padding), onPickCover = { + coverPicker.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)) + }, onPickDocument = { documentPicker.launch(DOCUMENT_MIMES) }) + } + } + + if (state.attaching) { + val s by vm.authorities.state.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { vm.authorities.start("") } + SearchPickerSheet( + title = stringResource(R.string.staff_archive_attach_authority), placeholder = stringResource(R.string.staff_authorities_search), + state = s, onQuery = vm.authorities::onQuery, onPick = vm::attach, onDismiss = vm::closeAttach, + hint = stringResource(R.string.staff_archive_attach_role_hint, state.lookups.authorityRoleOptions.firstOrNull { it.value == state.attachRole }?.label ?: state.attachRole), + ) + } + if (state.confirmDelete) { + ConfirmDialog( + title = stringResource(R.string.staff_archive_delete), body = stringResource(R.string.staff_archive_delete_body, state.form["constructed_title"]), + confirmLabel = stringResource(R.string.staff_archive_delete), onConfirm = vm::delete, onDismiss = vm::dismissDelete, + ) + } +} + +@Composable +private fun ArchiveForm( + state: ArchiveEditUiState, + vm: StaffArchiveEditViewModel, + modifier: Modifier, + onPickCover: () -> Unit, + onPickDocument: () -> Unit, +) { + val f = state.form + val errors = state.errors + @Composable + fun field(key: String, label: Int, error: ArchiveField? = null, number: Boolean = false, multiline: Boolean = false) { + PinakesTextField( + value = f[key], onValueChange = { vm.edit(key, it) }, label = stringResource(label), + isError = error != null && error in errors, errorText = stringResource(R.string.staff_field_invalid), + singleLine = !multiline, keyboardOptions = KeyboardOptions(keyboardType = if (number) KeyboardType.Number else KeyboardType.Text), + modifier = Modifier.fillMaxWidth().then(if (multiline) Modifier.heightIn(min = 96.dp) else Modifier).testTag("archive-$key"), + ) + } + Column(modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + state.formError?.let { InlineErrorBanner(message = it.resolve(), modifier = Modifier.padding(top = Spacing.sm)) } + SwitchRow(stringResource(R.string.staff_archive_published), f.published, vm::setPublished, subtitle = stringResource(R.string.staff_archive_published_hint)) + + FormSectionTitle(stringResource(R.string.staff_archive_section_identity)) + field("reference_code", R.string.staff_archive_reference_code, ArchiveField.REFERENCE_CODE) + field("institution_code", R.string.staff_archive_institution_code) + LookupField( + label = stringResource(R.string.staff_archive_level), value = f["level"], + displayValue = state.lookups.levelOptions.firstOrNull { it.value == f["level"] }?.label ?: f["level"], + options = state.lookups.levelOptions.ifEmpty { listOf("fonds", "series", "file", "item").map { LookupOption(it, it) } }, + editable = false, allowClear = false, onValueChange = { vm.edit("level", it) }, modifier = Modifier.fillMaxWidth(), + ) + field("constructed_title", R.string.staff_archive_constructed_title, ArchiveField.CONSTRUCTED_TITLE) + field("formal_title", R.string.staff_archive_formal_title) + field("parent_id", R.string.staff_archive_parent_id, ArchiveField.PARENT_ID, number = true) + + FormSectionTitle(stringResource(R.string.staff_archive_section_dates)) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Column(Modifier.weight(1f)) { field("date_start", R.string.staff_archive_date_start, ArchiveField.DATE_START, number = true) } + Column(Modifier.weight(1f)) { field("date_end", R.string.staff_archive_date_end, ArchiveField.DATE_END, number = true) } + } + + FormSectionTitle(stringResource(R.string.staff_archive_section_description)) + field("extent", R.string.staff_archive_extent) + field("scope_content", R.string.staff_archive_scope_content, multiline = true) + field("language_codes", R.string.staff_archive_language_codes) + + FormSectionTitle(stringResource(R.string.staff_archive_section_material)) + LookupField( + label = stringResource(R.string.staff_archive_material), value = f["specific_material"], + displayValue = state.lookups.materialOptions.firstOrNull { it.value == f["specific_material"] }?.label ?: f["specific_material"], + options = state.lookups.materialOptions, editable = false, allowClear = false, + onValueChange = { vm.edit("specific_material", it) }, modifier = Modifier.fillMaxWidth(), + ) + LookupField( + label = stringResource(R.string.staff_archive_color_mode), value = f["color_mode"], + displayValue = state.lookups.colorModeOptions.firstOrNull { it.value == f["color_mode"] }?.label ?: f["color_mode"], + options = state.lookups.colorModeOptions, editable = false, onValueChange = { vm.edit("color_mode", it) }, modifier = Modifier.fillMaxWidth(), + ) + field("dimensions", R.string.staff_archive_dimensions) + field("photographer", R.string.staff_archive_photographer) + field("publisher", R.string.staff_archive_publisher) + field("collection_name", R.string.staff_archive_collection_name) + field("local_classification", R.string.staff_archive_local_classification) + + FormSectionTitle(stringResource(R.string.staff_archive_section_links)) + field("iiif_manifest_url", R.string.staff_archive_iiif) + field("rights_statement_url", R.string.staff_archive_rights) + field("ark_identifier", R.string.staff_archive_ark) + field("version_note", R.string.staff_archive_version_note) + + val detail = state.detail + if (state.id > 0 && detail != null) { + FormSectionTitle(stringResource(R.string.staff_archive_section_authorities)) + detail.authorities.forEach { a -> + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(a.name, style = MaterialTheme.typography.bodyLarge) + Text(state.lookups.authorityRoleOptions.firstOrNull { it.value == a.role }?.label ?: a.role.orEmpty(), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + IconButton(onClick = { vm.detach(a.id) }, enabled = !state.uploading) { + Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.staff_archive_detach, a.name)) + } + } + } + LookupField( + label = stringResource(R.string.staff_archive_role), value = state.attachRole, + displayValue = state.lookups.authorityRoleOptions.firstOrNull { it.value == state.attachRole }?.label ?: state.attachRole, + options = state.lookups.authorityRoleOptions, editable = false, allowClear = false, onValueChange = vm::setRole, modifier = Modifier.fillMaxWidth(), + ) + SecondaryButton(stringResource(R.string.staff_archive_attach_authority), onClick = vm::openAttach, leadingIcon = Icons.Outlined.Person, + enabled = !state.uploading, modifier = Modifier.fillMaxWidth()) + + FormSectionTitle(stringResource(R.string.staff_archive_section_files)) + if (state.uploading) LoadingState(modifier = Modifier.heightIn(max = 64.dp)) + Text( + stringResource(if (detail.coverUrl.isNullOrBlank()) R.string.staff_archive_no_cover else R.string.staff_archive_has_cover), + style = MaterialTheme.typography.bodyMedium, + ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + SecondaryButton(stringResource(R.string.staff_archive_upload_cover), onClick = onPickCover, leadingIcon = Icons.Outlined.Image, enabled = !state.uploading) + if (!detail.coverUrl.isNullOrBlank()) SecondaryButton(stringResource(R.string.staff_archive_remove_cover), onClick = vm::removeCover, enabled = !state.uploading) + } + detail.files.forEach { file -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text(file.label?.takeIf { it.isNotBlank() } ?: file.url.substringAfterLast('/'), style = MaterialTheme.typography.bodyMedium, + maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f)) + IconButton(onClick = { vm.removeDocument(file.id) }, enabled = !state.uploading) { + Icon(Icons.Outlined.DeleteOutline, contentDescription = stringResource(R.string.staff_archive_remove_document)) + } + } + } + SecondaryButton(stringResource(R.string.staff_archive_upload_document), onClick = onPickDocument, leadingIcon = Icons.Outlined.AttachFile, + enabled = !state.uploading, modifier = Modifier.fillMaxWidth()) + } else { + Text(stringResource(R.string.staff_archive_save_first), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.height(Spacing.xxl)) + } +} + +// ============================================================================================= +// Authorities +// ============================================================================================= + +data class AuthoritiesUiState( + val query: String = "", + val items: List = emptyList(), + val loading: Boolean = true, + val error: StaffMessage? = null, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffAuthoritiesViewModel @Inject constructor(private val plugins: StaffPluginsRepository) : ViewModel() { + private val _state = MutableStateFlow(AuthoritiesUiState()) + val state: StateFlow = _state.asStateFlow() + private var job: Job? = null + + fun reload() { + job?.cancel() + job = viewModelScope.launch { + delay(if (_state.value.items.isEmpty()) 0 else 300) + val res = plugins.authorities(_state.value.query) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(items = res.data.items, loading = false, error = null) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun onQuery(value: String) { _state.update { it.copy(query = value) }; reload() } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffAuthoritiesScreen(onNavigateUp: () -> Unit, onOpen: (Int) -> Unit) { + val vm: StaffAuthoritiesViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.archives, onNavigateUp) + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) vm.reload() } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_authorities), onNavigateUp = onNavigateUp) }, + floatingActionButton = { StaffFab(Icons.Outlined.Add, stringResource(R.string.staff_authority_new)) { onOpen(0) } }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + SearchField(query = state.query, onQueryChange = vm::onQuery, placeholder = stringResource(R.string.staff_authorities_search), + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg, vertical = Spacing.sm)) + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = state.items.isEmpty(), + emptyTitle = stringResource(R.string.staff_authorities_empty), emptyIcon = Icons.Outlined.Person, onRetry = vm::reload, + ) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp)) { + items(state.items, key = { it.id }) { a -> + StaffActionRow(Icons.Outlined.Person, a.name, + listOfNotNull(a.type, a.dates, stringResource(R.string.staff_authority_units, a.unitsCount)).joinToString(" · ")) { onOpen(a.id) } + } + } + } + } + } +} + +data class AuthorityEditUiState( + val id: Int = 0, + val form: AuthorityForm = AuthorityForm(), + val units: List = emptyList(), + val lookups: ArchiveLookups = ArchiveLookups(), + val loading: Boolean = true, + val saving: Boolean = false, + val showErrors: Boolean = false, + val error: StaffMessage? = null, + val confirmDelete: Boolean = false, + val done: Boolean = false, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffAuthorityEditViewModel @Inject constructor( + private val plugins: StaffPluginsRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val _state = MutableStateFlow(AuthorityEditUiState(id = savedState.get(Routes.ARG_AUTHORITY_ID) ?: 0)) + val state: StateFlow = _state.asStateFlow() + + init { + viewModelScope.launch { (plugins.archiveLookups() as? ApiResult.Success)?.data?.let { l -> _state.update { it.copy(lookups = l) } } } + val id = _state.value.id + if (id <= 0) _state.update { it.copy(loading = false) } else viewModelScope.launch { + val res = plugins.authority(id) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(form = AuthorityForm.from(res.data), units = res.data.units, loading = false) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun edit(key: String, value: String) = _state.update { it.copy(form = it.form.with(key, value), error = null) } + + fun save() { + val s = _state.value + if (!s.form.valid) { _state.update { it.copy(showErrors = true) }; return } + if (s.saving) return + _state.update { it.copy(saving = true, error = null) } + viewModelScope.launch { + val res = plugins.saveAuthority(s.id, s.form.toJson()) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(saving = false, done = true) + is ApiResult.Failure -> it.copy(saving = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + + fun askDelete() = _state.update { it.copy(confirmDelete = true) } + fun dismissDelete() = _state.update { it.copy(confirmDelete = false) } + + fun delete() { + _state.update { it.copy(confirmDelete = false) } + viewModelScope.launch { + when (val res = plugins.deleteAuthority(_state.value.id)) { + is ApiResult.Success -> _state.update { it.copy(done = true) } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_save)) } + } + } + } +} + +/** An ISAAR(CPF) authority record: type, authorised form and the descriptive areas. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffAuthorityEditScreen(onNavigateUp: () -> Unit) { + val vm: StaffAuthorityEditViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.archives || state.done, onNavigateUp) + + Scaffold( + topBar = { + PinakesTopBar( + title = stringResource(if (state.id > 0) R.string.staff_authority_edit else R.string.staff_authority_new), onNavigateUp = onNavigateUp, + actions = { if (state.id > 0) IconButton(onClick = vm::askDelete) { Icon(Icons.Outlined.DeleteOutline, contentDescription = stringResource(R.string.staff_authority_delete)) } }, + ) + }, + ) { padding -> + if (state.loading) { LoadingState(modifier = Modifier.padding(padding)); return@Scaffold } + val f = state.form + Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + state.error?.let { InlineErrorBanner(message = it.resolve(), modifier = Modifier.padding(top = Spacing.sm)) } + LookupField( + label = stringResource(R.string.staff_authority_type), value = f["type"], + displayValue = state.lookups.authorityTypeOptions.firstOrNull { it.value == f["type"] }?.label ?: f["type"], + options = state.lookups.authorityTypeOptions.ifEmpty { listOf("person", "corporate", "family").map { LookupOption(it, it) } }, + editable = false, allowClear = false, onValueChange = { vm.edit("type", it) }, modifier = Modifier.fillMaxWidth(), + ) + val labels = listOf( + "authorised_form" to R.string.staff_authority_authorised_form, "parallel_forms" to R.string.staff_authority_parallel_forms, + "other_forms" to R.string.staff_authority_other_forms, "identifiers" to R.string.staff_authority_identifiers, + "dates_of_existence" to R.string.staff_authority_dates, "history" to R.string.staff_authority_history, + "places" to R.string.staff_authority_places, "legal_status" to R.string.staff_authority_legal_status, + "functions" to R.string.staff_authority_functions, "mandates" to R.string.staff_authority_mandates, + "internal_structure" to R.string.staff_authority_internal_structure, "general_context" to R.string.staff_authority_general_context, + ) + labels.forEach { (key, label) -> + val long = key in setOf("history", "functions", "mandates", "internal_structure", "general_context") + PinakesTextField( + value = f[key], onValueChange = { vm.edit(key, it) }, label = stringResource(label), singleLine = !long, + isError = key == "authorised_form" && state.showErrors && f[key].isBlank(), errorText = stringResource(R.string.staff_field_required), + modifier = Modifier.fillMaxWidth().then(if (long) Modifier.heightIn(min = 96.dp) else Modifier).testTag("authority-$key"), + ) + } + if (state.units.isNotEmpty()) { + FormSectionTitle(stringResource(R.string.staff_authority_linked_units)) + state.units.forEach { u -> Text(listOfNotNull(u.title, u.role).joinToString(" · "), style = MaterialTheme.typography.bodyMedium) } + } + PrimaryButton(stringResource(R.string.action_save), onClick = vm::save, loading = state.saving, modifier = Modifier.fillMaxWidth().testTag("authority-save")) + Spacer(Modifier.height(Spacing.xxl)) + } + } + if (state.confirmDelete) { + ConfirmDialog( + title = stringResource(R.string.staff_authority_delete), body = stringResource(R.string.staff_authority_delete_body, state.form["authorised_form"]), + confirmLabel = stringResource(R.string.staff_authority_delete), onConfirm = vm::delete, onDismiss = vm::dismissDelete, + ) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffDesiderataScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffDesiderataScreen.kt new file mode 100644 index 0000000..b247558 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffDesiderataScreen.kt @@ -0,0 +1,376 @@ +package com.pinakes.app.ui.screens.staff.plugins + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.VolunteerActivism +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.StaffOffer +import com.pinakes.app.data.model.StaffWantedBook +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffPluginsRepository +import com.pinakes.app.data.repository.StaffRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.MediaRow +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SearchField +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.SearchController +import com.pinakes.app.ui.screens.staff.SearchPickerSheet +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** Tabs: the offer statuses, then the library's wanted titles. */ +enum class DesiderataTab(val wire: String, val label: Int) { + PENDING("pending", R.string.staff_offers_pending), + ACCEPTED("accepted", R.string.staff_offers_accepted), + RECEIVED("received", R.string.staff_offers_received), + REJECTED("rejected", R.string.staff_offers_rejected), + ALL("all", R.string.staff_offers_all), + WANTED("wanted", R.string.staff_offers_wanted), +} + +/** Which offer actions make sense for a status; the server answers 409 `offer_closed` otherwise. */ +object OfferActions { + fun canAccept(o: StaffOffer) = o.status == "pending" + fun canReject(o: StaffOffer) = o.status == "pending" || o.status == "accepted" + fun canReceive(o: StaffOffer) = o.status == "pending" || o.status == "accepted" + /** Without a linked wanted title the server needs the catalogue record the copy goes to. */ + fun needsBook(o: StaffOffer) = o.book == null || o.book.id <= 0 +} + +data class DesiderataUiState( + val tab: DesiderataTab = DesiderataTab.PENDING, + val query: String = "", + val offers: List = emptyList(), + val wanted: List = emptyList(), + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val forbidden: Boolean = false, + val selected: StaffOffer? = null, + val confirmDelete: StaffOffer? = null, + val pickingBookFor: StaffOffer? = null, + val busy: Boolean = false, + val message: StaffMessage? = null, +) + +@HiltViewModel +class StaffDesiderataViewModel @Inject constructor( + private val plugins: StaffPluginsRepository, + private val staff: StaffRepository, +) : ViewModel() { + private val _state = MutableStateFlow(DesiderataUiState()) + val state: StateFlow = _state.asStateFlow() + private var searchJob: Job? = null + private var generation = 0 + + val books = SearchController(viewModelScope, minLength = 2) { q -> + when (val res = staff.books(q, cursor = null, limit = 20)) { + is ApiResult.Success -> ApiResult.Success(res.data.items.map { PickItem(it.id, it.title, it.author) }) + is ApiResult.Failure -> res + } + }.also { c -> c.onFailure = { f -> if (f.isForbidden()) _state.update { it.copy(forbidden = true) } } } + + init { reload() } + + fun reload() { + val gen = ++generation + _state.update { it.copy(loading = it.offers.isEmpty() && it.wanted.isEmpty() && !it.refreshing, error = null) } + viewModelScope.launch { + val s = _state.value + if (s.tab == DesiderataTab.WANTED) { + val res = plugins.wanted(s.query) + if (gen != generation) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy(wanted = res.data.items, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } else { + val res = plugins.offers(s.tab.wire, s.query) + if (gen != generation) return@launch + _state.update { + when (res) { + is ApiResult.Success -> it.copy(offers = res.data.items, loading = false, refreshing = false) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + reload() + } + + fun setTab(tab: DesiderataTab) { + if (tab == _state.value.tab) return + _state.update { it.copy(tab = tab, offers = emptyList(), wanted = emptyList()) } + reload() + } + + fun onQuery(value: String) { + _state.update { it.copy(query = value) } + searchJob?.cancel() + searchJob = viewModelScope.launch { delay(350); reload() } + } + + fun select(offer: StaffOffer?) = _state.update { it.copy(selected = offer) } + + fun accept() = act(R.string.staff_offer_accepted) { plugins.acceptOffer(it.id) } + fun reject() = act(R.string.staff_offer_rejected) { plugins.rejectOffer(it.id) } + + fun receive() { + val offer = _state.value.selected ?: return + if (OfferActions.needsBook(offer)) _state.update { it.copy(pickingBookFor = offer, selected = null) } + else act(R.string.staff_offer_received) { plugins.receiveOffer(it.id, null) } + } + + fun receiveInto(book: PickItem) { + val offer = _state.value.pickingBookFor ?: return + _state.update { it.copy(pickingBookFor = null, selected = offer) } + books.clear() + act(R.string.staff_offer_received) { plugins.receiveOffer(offer.id, book.id) } + } + + fun closeBookPicker() { + _state.update { it.copy(pickingBookFor = null) } + books.clear() + } + + private fun act(success: Int, call: suspend (StaffOffer) -> ApiResult) { + val offer = _state.value.selected ?: return + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = call(offer) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, selected = null, message = StaffMessage(success)) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + reload() + } + } + + fun askDelete() = _state.update { it.copy(confirmDelete = it.selected, selected = null) } + fun dismissDelete() = _state.update { it.copy(confirmDelete = null) } + + fun delete() { + val offer = _state.value.confirmDelete ?: return + _state.update { it.copy(confirmDelete = null) } + viewModelScope.launch { + val res = plugins.deleteOffer(offer.id) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(message = StaffMessage(R.string.staff_offer_deleted)) + is ApiResult.Failure -> it.copy(forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + reload() + } + } + + /** "Received" on a wanted title: the copy arrived outside an offer (bought, given at the desk). */ + fun receiveWanted(book: StaffWantedBook) { + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = plugins.receiveWanted(book.id) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, message = StaffMessage(R.string.staff_wanted_received, args = listOf(book.title))) + is ApiResult.Failure -> it.copy( + busy = false, forbidden = res.isForbidden(), + message = if (res.meta?.reason == "open_offers") StaffMessage(R.string.staff_wanted_open_offers) else res.toStaffMessage(R.string.staff_error_save), + ) + } + } + reload() + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +internal fun offerStatusLabel(status: String): Int? = when (status) { + "pending" -> R.string.staff_offer_status_pending + "accepted" -> R.string.staff_offer_status_accepted + "received" -> R.string.staff_offer_status_received + "rejected" -> R.string.staff_offer_status_rejected + else -> null +} + +/** Donation offers (accept, reject, receive into the catalogue, delete) and the wanted titles. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffDesiderataScreen(onNavigateUp: () -> Unit, onOpenBook: (Int) -> Unit) { + val vm: StaffDesiderataViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.desiderata, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_desiderata), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + PrimaryScrollableTabRow(selectedTabIndex = state.tab.ordinal, edgePadding = Spacing.lg) { + DesiderataTab.entries.forEach { tab -> Tab(selected = tab == state.tab, onClick = { vm.setTab(tab) }, text = { Text(stringResource(tab.label)) }) } + } + SearchField( + query = state.query, onQueryChange = vm::onQuery, placeholder = stringResource(R.string.staff_offers_search), + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg, vertical = Spacing.sm), + ) + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize()) { + val wantedTab = state.tab == DesiderataTab.WANTED + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = if (wantedTab) state.wanted.isEmpty() else state.offers.isEmpty(), + emptyTitle = stringResource(if (wantedTab) R.string.staff_wanted_empty else R.string.staff_offers_empty), + emptyIcon = Icons.Outlined.VolunteerActivism, onRetry = vm::refresh, + ) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + if (wantedTab) { + items(state.wanted, key = { "w${it.id}" }) { w -> + MediaRow( + title = w.title, coverUrl = w.coverUrl, line1 = w.author, + line2 = stringResource(R.string.staff_wanted_open_offers_count, w.openOffers), + onClick = { onOpenBook(w.id) }, + trailing = { PinakesTextButton(stringResource(R.string.staff_wanted_mark_received), onClick = { vm.receiveWanted(w) }, enabled = !state.busy) }, + modifier = Modifier.padding(vertical = Spacing.xs).testTag("wanted-${w.id}"), + ) + } + } else { + items(state.offers, key = { "o${it.id}" }) { offer -> + Surface( + onClick = { vm.select(offer) }, shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("offer-${offer.id}"), + ) { + Column(Modifier.padding(Spacing.md)) { + Text(offer.title, style = MaterialTheme.typography.titleSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text( + listOfNotNull(offer.author, offer.donorName?.let { stringResource(R.string.staff_offer_from, it) }).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + listOfNotNull(offerStatusLabel(offer.status)?.let { stringResource(it) } ?: offer.statusLabel ?: offer.status, + offer.createdAt?.let(DateFormat::date)).joinToString(" · "), + style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } + } + } + + state.selected?.let { offer -> OfferSheet(offer, state.busy, vm, onOpenBook) } + state.confirmDelete?.let { offer -> + ConfirmDialog( + title = stringResource(R.string.staff_offer_delete), body = stringResource(R.string.staff_offer_delete_body, offer.title), + confirmLabel = stringResource(R.string.staff_offer_delete), onConfirm = vm::delete, onDismiss = vm::dismissDelete, + ) + } + if (state.pickingBookFor != null) { + val s by vm.books.state.collectAsStateWithLifecycle() + LaunchedEffect(Unit) { vm.books.start(state.pickingBookFor?.title.orEmpty()) } + SearchPickerSheet( + title = stringResource(R.string.staff_offer_receive_into), placeholder = stringResource(R.string.staff_books_search), state = s, + onQuery = vm.books::onQuery, onPick = vm::receiveInto, onDismiss = vm::closeBookPicker, hint = stringResource(R.string.staff_offer_receive_into_hint), + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun OfferSheet(offer: StaffOffer, busy: Boolean, vm: StaffDesiderataViewModel, onOpenBook: (Int) -> Unit) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { if (!busy) vm.select(null) }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(offer.title, style = MaterialTheme.typography.headlineSmall) + InfoLine(stringResource(R.string.staff_offer_author), offer.author) + InfoLine(stringResource(R.string.staff_offer_publisher), offer.publisher) + InfoLine("ISBN", offer.isbn) + InfoLine(stringResource(R.string.staff_offer_donor), listOfNotNull(offer.donorName, offer.donorEmail).joinToString(" · ").ifBlank { null }) + InfoLine(stringResource(R.string.staff_field_notes), offer.notes) + InfoLine(stringResource(R.string.staff_offer_for_title), offer.book?.title) + InfoLine(stringResource(R.string.staff_offer_received_into), offer.receivedBook?.title) + if (OfferActions.canReceive(offer)) { + PrimaryButton(stringResource(R.string.staff_offer_receive), onClick = vm::receive, loading = busy, modifier = Modifier.fillMaxWidth().testTag("offer-receive")) + } + if (OfferActions.canAccept(offer)) SecondaryButton(stringResource(R.string.staff_offer_accept), onClick = vm::accept, enabled = !busy, modifier = Modifier.fillMaxWidth()) + if (OfferActions.canReject(offer)) SecondaryButton(stringResource(R.string.staff_offer_reject), onClick = vm::reject, enabled = !busy, modifier = Modifier.fillMaxWidth()) + (offer.receivedBook ?: offer.book)?.takeIf { it.id > 0 }?.let { b -> + SecondaryButton(stringResource(R.string.staff_open_book), onClick = { vm.select(null); onOpenBook(b.id) }, modifier = Modifier.fillMaxWidth()) + } + PinakesTextButton(stringResource(R.string.staff_offer_delete), onClick = vm::askDelete, enabled = !busy) + Spacer(Modifier.height(Spacing.lg)) + } + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/ArchiveFormStateTest.kt b/app/src/test/java/com/pinakes/app/staff/ArchiveFormStateTest.kt new file mode 100644 index 0000000..7504e21 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/ArchiveFormStateTest.kt @@ -0,0 +1,87 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.IdTitle +import com.pinakes.app.data.model.StaffArchiveDetail +import com.pinakes.app.data.model.StaffOffer +import com.pinakes.app.data.staff.ArchiveField +import com.pinakes.app.data.staff.ArchiveForm +import com.pinakes.app.data.staff.AuthorityForm +import com.pinakes.app.ui.screens.staff.plugins.OfferActions +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The archive and authority forms mirror the plugin's payload and rules; offer actions per status. */ +class ArchiveFormStateTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun newFormUsesThePluginDefaultsAndNeedsTheRequiredFields() { + val form = ArchiveForm.new() + assertEquals("PINAKES", form["institution_code"]) + assertEquals("ita", form["language_codes"]) + assertEquals(setOf(ArchiveField.REFERENCE_CODE, ArchiveField.CONSTRUCTED_TITLE), form.errors()) + val ok = form.with("reference_code", "IT-1").with("constructed_title", "Fondo Rossi") + assertTrue(ok.errors().isEmpty()) + } + + @Test fun yearsMustBeNumbersInOrder() { + val base = ArchiveForm.new().with("reference_code", "R").with("constructed_title", "T") + assertEquals(setOf(ArchiveField.DATE_START), base.with("date_start", "1920s").errors()) + assertEquals(setOf(ArchiveField.DATE_END), base.with("date_start", "1950").with("date_end", "1940").errors()) + assertTrue(base.with("date_start", "1940").with("date_end", "1950").errors().isEmpty()) + assertEquals(setOf(ArchiveField.PARENT_ID), base.with("parent_id", "abc").errors()) + } + + @Test fun jsonSendsEveryKeyWithNumbersAndNulls() { + val body = ArchiveForm.new().with("reference_code", " IT-1 ").with("constructed_title", "T").with("date_start", "1920").copy(published = false).toJson() + assertEquals(ArchiveForm.TEXT_KEYS.size + ArchiveForm.INT_KEYS.size + 1, body.size) + assertEquals("IT-1", body["reference_code"]!!.jsonPrimitive.content) + assertEquals(1920, body["date_start"]!!.jsonPrimitive.int) + assertEquals(JsonNull, body["date_end"]) + assertEquals(JsonPrimitive(""), body["extent"]) + assertFalse(body["published"]!!.jsonPrimitive.boolean) + } + + @Test fun formReadsTheDetailColumns() { + val detail = json.decodeFromString( + """{"id":3,"reference_code":"IT-1","level":"series","published":false, + "fields":{"reference_code":"IT-1","level":"series","constructed_title":"Lettere","date_start":1920,"date_end":null,"extent":"2 buste"}}""" + ) + val form = ArchiveForm.from(detail) + assertEquals("Lettere", form["constructed_title"]) + assertEquals("1920", form["date_start"]) + assertEquals("", form["date_end"]) + assertFalse(form.published) + assertTrue(form.errors().isEmpty()) + } + + @Test fun authorityFormNeedsTypeAndName() { + assertFalse(AuthorityForm().valid) + val a = AuthorityForm().with("authorised_form", "Rossi, Mario") + assertTrue(a.valid) + assertEquals(AuthorityForm.KEYS.size, a.toJson().size) + assertEquals("person", a.toJson()["type"]!!.jsonPrimitive.content) + } + + @Test fun offerActionsByStatus() { + val pending = StaffOffer(id = 1, status = "pending") + assertTrue(OfferActions.canAccept(pending) && OfferActions.canReject(pending) && OfferActions.canReceive(pending)) + val accepted = pending.copy(status = "accepted") + assertFalse(OfferActions.canAccept(accepted)) + assertTrue(OfferActions.canReceive(accepted)) + listOf("received", "rejected").forEach { s -> + val o = pending.copy(status = s) + assertFalse(OfferActions.canAccept(o) || OfferActions.canReject(o) || OfferActions.canReceive(o)) + } + assertTrue(OfferActions.needsBook(pending)) + assertFalse(OfferActions.needsBook(pending.copy(book = IdTitle(5, "Orlando")))) + } +} diff --git a/i18n/de.json b/i18n/de.json index c4fe548..2d33954 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1177,5 +1177,121 @@ "staff_enrich_cron_hint": "Der Server ergänzt planmäßig einen Stapel Bücher.", "staff_enrich_run": "Jetzt einen Stapel ausführen", "staff_enrich_run_body": "Der Server reichert die nächsten 20 Bücher an. Das kann ein paar Minuten dauern.", - "staff_enrich_done": "%1$d von %2$d Büchern angereichert" + "staff_enrich_done": "%1$d von %2$d Büchern angereichert", + "staff_offers_pending": "Zu prüfen", + "staff_offers_accepted": "Angenommen", + "staff_offers_received": "Erhalten", + "staff_offers_rejected": "Abgelehnt", + "staff_offers_all": "Alle", + "staff_offers_wanted": "Gesuchte Titel", + "staff_offers_search": "Titel, Autor oder Spender", + "staff_offers_empty": "Keine Angebote hier", + "staff_wanted_empty": "Die Bibliothek sucht keinen Titel", + "staff_offer_from": "von %1$s", + "staff_offer_status_pending": "Zu prüfen", + "staff_offer_status_accepted": "Angenommen", + "staff_offer_status_received": "Erhalten", + "staff_offer_status_rejected": "Abgelehnt", + "staff_offer_author": "Autor", + "staff_offer_publisher": "Verlag", + "staff_offer_donor": "Spender", + "staff_offer_for_title": "Für den gesuchten Titel", + "staff_offer_received_into": "Erfasst in", + "staff_offer_accept": "Annehmen", + "staff_offer_reject": "Ablehnen", + "staff_offer_receive": "Erhalten: Exemplar hinzufügen", + "staff_offer_receive_into": "Welcher Datensatz erhält das Exemplar?", + "staff_offer_receive_into_hint": "Dieses Angebot betrifft keinen gesuchten Titel: den Katalogeintrag des Exemplars wählen.", + "staff_offer_accepted": "Angebot angenommen", + "staff_offer_rejected": "Angebot abgelehnt", + "staff_offer_received": "Exemplar in den Katalog aufgenommen", + "staff_offer_delete": "Angebot löschen", + "staff_offer_delete_body": "Das Angebot für „%1$s“ löschen? Das lässt sich nicht rückgängig machen.", + "staff_offer_deleted": "Angebot gelöscht", + "staff_wanted_open_offers_count": "Offene Angebote: %1$d", + "staff_wanted_mark_received": "Erhalten", + "staff_wanted_received": "„%1$s“ erhalten: Exemplar hinzugefügt", + "staff_wanted_open_offers": "Für diesen Titel gibt es offene Angebote: über das Angebot erfassen.", + "staff_archives_search": "Titel oder Signatur", + "staff_archives_filter_all": "Alle", + "staff_archives_filter_published": "Veröffentlicht", + "staff_archives_filter_draft": "Nicht veröffentlicht", + "staff_archives_empty": "Keine Archiveinheiten", + "staff_archive_new": "Neue Archiveinheit", + "staff_archive_edit": "Archiveinheit bearbeiten", + "staff_archive_published": "Veröffentlicht", + "staff_archive_draft": "Nicht veröffentlicht", + "staff_archive_published_hint": "Im öffentlichen Archiv und in der App sichtbar.", + "staff_archive_section_identity": "Identifikation", + "staff_archive_section_dates": "Daten", + "staff_archive_section_description": "Beschreibung", + "staff_archive_section_material": "Material", + "staff_archive_section_links": "Links und Kennungen", + "staff_archive_section_authorities": "Normdaten", + "staff_archive_section_files": "Cover und Dokumente", + "staff_archive_reference_code": "Signatur *", + "staff_archive_institution_code": "Institutionscode", + "staff_archive_level": "Ebene *", + "staff_archive_constructed_title": "Titel *", + "staff_archive_formal_title": "Förmlicher Titel", + "staff_archive_parent_id": "Nummer der übergeordneten Einheit", + "staff_archive_date_start": "Von (Jahr)", + "staff_archive_date_end": "Bis (Jahr)", + "staff_archive_extent": "Umfang", + "staff_archive_scope_content": "Inhalt und Umfang", + "staff_archive_language_codes": "Sprachen (Codes)", + "staff_archive_material": "Spezifisches Material", + "staff_archive_color_mode": "Farbe", + "staff_archive_dimensions": "Maße", + "staff_archive_photographer": "Fotograf", + "staff_archive_publisher": "Verlag", + "staff_archive_collection_name": "Sammlung", + "staff_archive_local_classification": "Lokale Klassifikation", + "staff_archive_iiif": "URL des IIIF-Manifests", + "staff_archive_rights": "URL der Rechteerklärung", + "staff_archive_ark": "ARK-Kennung", + "staff_archive_version_note": "Versionshinweis", + "staff_archive_role": "Rolle der nächsten Normdatei", + "staff_archive_attach_authority": "Normdatensatz verknüpfen", + "staff_archive_attach_role_hint": "Wird verknüpft als: %1$s", + "staff_archive_detach": "%1$s lösen", + "staff_archive_no_cover": "Noch kein Cover.", + "staff_archive_has_cover": "Ein Cover ist gesetzt.", + "staff_archive_upload_cover": "Cover hochladen", + "staff_archive_remove_cover": "Cover entfernen", + "staff_archive_upload_document": "Dokument anhängen", + "staff_archive_remove_document": "Dokument entfernen", + "staff_archive_save_first": "Die Einheit zuerst speichern, um Normdaten, Cover oder Dokumente anzuhängen.", + "staff_archive_created": "Archiveinheit angelegt", + "staff_archive_saved": "Archiveinheit gespeichert", + "staff_archive_duplicate": "Diese Signatur wird in dieser Institution bereits verwendet.", + "staff_archive_cover_uploaded": "Cover hochgeladen", + "staff_archive_cover_removed": "Cover entfernt", + "staff_archive_document_uploaded": "Dokument angehängt", + "staff_archive_document_removed": "Dokument entfernt", + "staff_archive_authority_attached": "Normdatensatz verknüpft", + "staff_archive_authority_detached": "Normdatensatz gelöst", + "staff_archive_delete": "Einheit löschen", + "staff_archive_delete_body": "„%1$s“ und die Anhänge löschen?", + "staff_authorities_search": "Normdaten suchen", + "staff_authorities_empty": "Keine Normdatensätze", + "staff_authority_units": "%1$d Einheiten", + "staff_authority_new": "Neuer Normdatensatz", + "staff_authority_edit": "Normdatensatz bearbeiten", + "staff_authority_delete": "Normdatensatz löschen", + "staff_authority_delete_body": "„%1$s“ löschen? Er wird von allen Einheiten gelöst.", + "staff_authority_type": "Typ *", + "staff_authority_authorised_form": "Ansetzungsform des Namens *", + "staff_authority_parallel_forms": "Parallelformen", + "staff_authority_other_forms": "Andere Formen", + "staff_authority_identifiers": "Kennungen", + "staff_authority_dates": "Lebens- oder Bestandsdaten", + "staff_authority_history": "Geschichte", + "staff_authority_places": "Orte", + "staff_authority_legal_status": "Rechtsstatus", + "staff_authority_functions": "Funktionen und Tätigkeiten", + "staff_authority_mandates": "Mandate", + "staff_authority_internal_structure": "Interne Struktur", + "staff_authority_general_context": "Allgemeiner Kontext", + "staff_authority_linked_units": "Verknüpfte Einheiten" } diff --git a/i18n/en.json b/i18n/en.json index 252aeb8..61fc3fd 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1177,5 +1177,121 @@ "staff_enrich_cron_hint": "The server completes a batch of books on its schedule.", "staff_enrich_run": "Run a batch now", "staff_enrich_run_body": "The server enriches the next 20 books. It can take a couple of minutes.", - "staff_enrich_done": "Enriched %1$d of %2$d books" + "staff_enrich_done": "Enriched %1$d of %2$d books", + "staff_offers_pending": "To review", + "staff_offers_accepted": "Accepted", + "staff_offers_received": "Received", + "staff_offers_rejected": "Rejected", + "staff_offers_all": "All", + "staff_offers_wanted": "Wanted titles", + "staff_offers_search": "Title, author or donor", + "staff_offers_empty": "No offers here", + "staff_wanted_empty": "The library is not looking for any title", + "staff_offer_from": "from %1$s", + "staff_offer_status_pending": "To review", + "staff_offer_status_accepted": "Accepted", + "staff_offer_status_received": "Received", + "staff_offer_status_rejected": "Rejected", + "staff_offer_author": "Author", + "staff_offer_publisher": "Publisher", + "staff_offer_donor": "Donor", + "staff_offer_for_title": "For the wanted title", + "staff_offer_received_into": "Received into", + "staff_offer_accept": "Accept", + "staff_offer_reject": "Reject", + "staff_offer_receive": "Received: add the copy", + "staff_offer_receive_into": "Which record gets the copy?", + "staff_offer_receive_into_hint": "This offer is not for a wanted title: pick the catalogue record the copy belongs to.", + "staff_offer_accepted": "Offer accepted", + "staff_offer_rejected": "Offer rejected", + "staff_offer_received": "Copy added to the catalogue", + "staff_offer_delete": "Delete offer", + "staff_offer_delete_body": "Delete the offer of “%1$s”? This cannot be undone.", + "staff_offer_deleted": "Offer deleted", + "staff_wanted_open_offers_count": "Open offers: %1$d", + "staff_wanted_mark_received": "Received", + "staff_wanted_received": "“%1$s” received: copy added", + "staff_wanted_open_offers": "This title has open offers: receive it from the offer instead.", + "staff_archives_search": "Title or reference code", + "staff_archives_filter_all": "All", + "staff_archives_filter_published": "Published", + "staff_archives_filter_draft": "Not published", + "staff_archives_empty": "No archival units", + "staff_archive_new": "New archival unit", + "staff_archive_edit": "Edit archival unit", + "staff_archive_published": "Published", + "staff_archive_draft": "Not published", + "staff_archive_published_hint": "Visible in the public archive and in the app.", + "staff_archive_section_identity": "Identity", + "staff_archive_section_dates": "Dates", + "staff_archive_section_description": "Description", + "staff_archive_section_material": "Material", + "staff_archive_section_links": "Links and identifiers", + "staff_archive_section_authorities": "Authorities", + "staff_archive_section_files": "Cover and documents", + "staff_archive_reference_code": "Reference code *", + "staff_archive_institution_code": "Institution code", + "staff_archive_level": "Level *", + "staff_archive_constructed_title": "Title *", + "staff_archive_formal_title": "Formal title", + "staff_archive_parent_id": "Parent unit number", + "staff_archive_date_start": "From (year)", + "staff_archive_date_end": "To (year)", + "staff_archive_extent": "Extent", + "staff_archive_scope_content": "Scope and content", + "staff_archive_language_codes": "Languages (codes)", + "staff_archive_material": "Specific material", + "staff_archive_color_mode": "Colour", + "staff_archive_dimensions": "Dimensions", + "staff_archive_photographer": "Photographer", + "staff_archive_publisher": "Publisher", + "staff_archive_collection_name": "Collection", + "staff_archive_local_classification": "Local classification", + "staff_archive_iiif": "IIIF manifest URL", + "staff_archive_rights": "Rights statement URL", + "staff_archive_ark": "ARK identifier", + "staff_archive_version_note": "Version note", + "staff_archive_role": "Role of the next authority", + "staff_archive_attach_authority": "Attach an authority", + "staff_archive_attach_role_hint": "It will be attached as: %1$s", + "staff_archive_detach": "Detach %1$s", + "staff_archive_no_cover": "No cover yet.", + "staff_archive_has_cover": "A cover is set.", + "staff_archive_upload_cover": "Upload a cover", + "staff_archive_remove_cover": "Remove the cover", + "staff_archive_upload_document": "Attach a document", + "staff_archive_remove_document": "Remove the document", + "staff_archive_save_first": "Save the unit first to attach authorities, a cover or documents.", + "staff_archive_created": "Archival unit created", + "staff_archive_saved": "Archival unit saved", + "staff_archive_duplicate": "This reference code is already used in this institution.", + "staff_archive_cover_uploaded": "Cover uploaded", + "staff_archive_cover_removed": "Cover removed", + "staff_archive_document_uploaded": "Document attached", + "staff_archive_document_removed": "Document removed", + "staff_archive_authority_attached": "Authority attached", + "staff_archive_authority_detached": "Authority detached", + "staff_archive_delete": "Delete unit", + "staff_archive_delete_body": "Delete “%1$s” and its attachments?", + "staff_authorities_search": "Search authorities", + "staff_authorities_empty": "No authority records", + "staff_authority_units": "%1$d units", + "staff_authority_new": "New authority", + "staff_authority_edit": "Edit authority", + "staff_authority_delete": "Delete authority", + "staff_authority_delete_body": "Delete “%1$s”? It is detached from every unit.", + "staff_authority_type": "Type *", + "staff_authority_authorised_form": "Authorised form of name *", + "staff_authority_parallel_forms": "Parallel forms", + "staff_authority_other_forms": "Other forms", + "staff_authority_identifiers": "Identifiers", + "staff_authority_dates": "Dates of existence", + "staff_authority_history": "History", + "staff_authority_places": "Places", + "staff_authority_legal_status": "Legal status", + "staff_authority_functions": "Functions and activities", + "staff_authority_mandates": "Mandates", + "staff_authority_internal_structure": "Internal structure", + "staff_authority_general_context": "General context", + "staff_authority_linked_units": "Linked units" } diff --git a/i18n/fr.json b/i18n/fr.json index 6d5a6b7..4b65367 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1177,5 +1177,121 @@ "staff_enrich_cron_hint": "Le serveur complète un lot de livres selon son planning.", "staff_enrich_run": "Lancer un lot maintenant", "staff_enrich_run_body": "Le serveur enrichit les 20 livres suivants. Cela peut prendre quelques minutes.", - "staff_enrich_done": "Enrichis %1$d sur %2$d livres" + "staff_enrich_done": "Enrichis %1$d sur %2$d livres", + "staff_offers_pending": "À examiner", + "staff_offers_accepted": "Acceptées", + "staff_offers_received": "Reçues", + "staff_offers_rejected": "Refusées", + "staff_offers_all": "Toutes", + "staff_offers_wanted": "Titres recherchés", + "staff_offers_search": "Titre, auteur ou donateur", + "staff_offers_empty": "Aucune proposition ici", + "staff_wanted_empty": "La bibliothèque ne recherche aucun titre", + "staff_offer_from": "de %1$s", + "staff_offer_status_pending": "À examiner", + "staff_offer_status_accepted": "Acceptée", + "staff_offer_status_received": "Reçue", + "staff_offer_status_rejected": "Refusée", + "staff_offer_author": "Auteur", + "staff_offer_publisher": "Éditeur", + "staff_offer_donor": "Donateur", + "staff_offer_for_title": "Pour le titre recherché", + "staff_offer_received_into": "Reçue dans", + "staff_offer_accept": "Accepter", + "staff_offer_reject": "Refuser", + "staff_offer_receive": "Reçue : ajouter l’exemplaire", + "staff_offer_receive_into": "Quelle notice reçoit l’exemplaire ?", + "staff_offer_receive_into_hint": "Cette proposition ne concerne pas un titre recherché : choisissez la notice de l’exemplaire.", + "staff_offer_accepted": "Proposition acceptée", + "staff_offer_rejected": "Proposition refusée", + "staff_offer_received": "Exemplaire ajouté au catalogue", + "staff_offer_delete": "Supprimer la proposition", + "staff_offer_delete_body": "Supprimer la proposition « %1$s » ? C’est irréversible.", + "staff_offer_deleted": "Proposition supprimée", + "staff_wanted_open_offers_count": "Propositions ouvertes : %1$d", + "staff_wanted_mark_received": "Reçu", + "staff_wanted_received": "« %1$s » reçu : exemplaire ajouté", + "staff_wanted_open_offers": "Ce titre a des propositions ouvertes : recevez-le depuis la proposition.", + "staff_archives_search": "Titre ou cote", + "staff_archives_filter_all": "Toutes", + "staff_archives_filter_published": "Publiées", + "staff_archives_filter_draft": "Non publiées", + "staff_archives_empty": "Aucune unité d’archives", + "staff_archive_new": "Nouvelle unité d’archives", + "staff_archive_edit": "Modifier l’unité d’archives", + "staff_archive_published": "Publiée", + "staff_archive_draft": "Non publiée", + "staff_archive_published_hint": "Visible dans les archives publiques et dans l’app.", + "staff_archive_section_identity": "Identification", + "staff_archive_section_dates": "Dates", + "staff_archive_section_description": "Description", + "staff_archive_section_material": "Matériel", + "staff_archive_section_links": "Liens et identifiants", + "staff_archive_section_authorities": "Autorités", + "staff_archive_section_files": "Couverture et documents", + "staff_archive_reference_code": "Cote *", + "staff_archive_institution_code": "Code de l’institution", + "staff_archive_level": "Niveau *", + "staff_archive_constructed_title": "Titre *", + "staff_archive_formal_title": "Titre formel", + "staff_archive_parent_id": "Numéro de l’unité parente", + "staff_archive_date_start": "De (année)", + "staff_archive_date_end": "À (année)", + "staff_archive_extent": "Importance matérielle", + "staff_archive_scope_content": "Portée et contenu", + "staff_archive_language_codes": "Langues (codes)", + "staff_archive_material": "Matériel spécifique", + "staff_archive_color_mode": "Couleur", + "staff_archive_dimensions": "Dimensions", + "staff_archive_photographer": "Photographe", + "staff_archive_publisher": "Éditeur", + "staff_archive_collection_name": "Collection", + "staff_archive_local_classification": "Classification locale", + "staff_archive_iiif": "URL du manifeste IIIF", + "staff_archive_rights": "URL de la déclaration de droits", + "staff_archive_ark": "Identifiant ARK", + "staff_archive_version_note": "Note de version", + "staff_archive_role": "Rôle de la prochaine autorité", + "staff_archive_attach_authority": "Rattacher une autorité", + "staff_archive_attach_role_hint": "Elle sera rattachée comme : %1$s", + "staff_archive_detach": "Détacher %1$s", + "staff_archive_no_cover": "Pas encore de couverture.", + "staff_archive_has_cover": "Une couverture est définie.", + "staff_archive_upload_cover": "Envoyer une couverture", + "staff_archive_remove_cover": "Retirer la couverture", + "staff_archive_upload_document": "Joindre un document", + "staff_archive_remove_document": "Retirer le document", + "staff_archive_save_first": "Enregistrez d’abord l’unité pour rattacher autorités, couverture ou documents.", + "staff_archive_created": "Unité d’archives créée", + "staff_archive_saved": "Unité d’archives enregistrée", + "staff_archive_duplicate": "Cette cote est déjà utilisée dans cette institution.", + "staff_archive_cover_uploaded": "Couverture envoyée", + "staff_archive_cover_removed": "Couverture retirée", + "staff_archive_document_uploaded": "Document joint", + "staff_archive_document_removed": "Document retiré", + "staff_archive_authority_attached": "Autorité rattachée", + "staff_archive_authority_detached": "Autorité détachée", + "staff_archive_delete": "Supprimer l’unité", + "staff_archive_delete_body": "Supprimer « %1$s » et ses pièces jointes ?", + "staff_authorities_search": "Rechercher une autorité", + "staff_authorities_empty": "Aucune notice d’autorité", + "staff_authority_units": "%1$d unités", + "staff_authority_new": "Nouvelle autorité", + "staff_authority_edit": "Modifier l’autorité", + "staff_authority_delete": "Supprimer l’autorité", + "staff_authority_delete_body": "Supprimer « %1$s » ? Elle est détachée de toutes les unités.", + "staff_authority_type": "Type *", + "staff_authority_authorised_form": "Forme autorisée du nom *", + "staff_authority_parallel_forms": "Formes parallèles", + "staff_authority_other_forms": "Autres formes", + "staff_authority_identifiers": "Identifiants", + "staff_authority_dates": "Dates d’existence", + "staff_authority_history": "Histoire", + "staff_authority_places": "Lieux", + "staff_authority_legal_status": "Statut juridique", + "staff_authority_functions": "Fonctions et activités", + "staff_authority_mandates": "Textes de référence", + "staff_authority_internal_structure": "Organisation interne", + "staff_authority_general_context": "Contexte général", + "staff_authority_linked_units": "Unités liées" } diff --git a/i18n/it.json b/i18n/it.json index 8530df9..0835ccf 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1177,5 +1177,121 @@ "staff_enrich_cron_hint": "Il server completa un gruppo di libri secondo la sua pianificazione.", "staff_enrich_run": "Esegui ora un gruppo", "staff_enrich_run_body": "Il server arricchisce i prossimi 20 libri. Può richiedere un paio di minuti.", - "staff_enrich_done": "Arricchiti %1$d di %2$d libri" + "staff_enrich_done": "Arricchiti %1$d di %2$d libri", + "staff_offers_pending": "Da valutare", + "staff_offers_accepted": "Accettate", + "staff_offers_received": "Ricevute", + "staff_offers_rejected": "Rifiutate", + "staff_offers_all": "Tutte", + "staff_offers_wanted": "Titoli cercati", + "staff_offers_search": "Titolo, autore o donatore", + "staff_offers_empty": "Nessuna proposta qui", + "staff_wanted_empty": "La biblioteca non cerca nessun titolo", + "staff_offer_from": "da %1$s", + "staff_offer_status_pending": "Da valutare", + "staff_offer_status_accepted": "Accettata", + "staff_offer_status_received": "Ricevuta", + "staff_offer_status_rejected": "Rifiutata", + "staff_offer_author": "Autore", + "staff_offer_publisher": "Editore", + "staff_offer_donor": "Donatore", + "staff_offer_for_title": "Per il titolo cercato", + "staff_offer_received_into": "Ricevuta in", + "staff_offer_accept": "Accetta", + "staff_offer_reject": "Rifiuta", + "staff_offer_receive": "Ricevuta: aggiungi la copia", + "staff_offer_receive_into": "Quale scheda riceve la copia?", + "staff_offer_receive_into_hint": "Questa proposta non riguarda un titolo cercato: scegli la scheda a cui appartiene la copia.", + "staff_offer_accepted": "Proposta accettata", + "staff_offer_rejected": "Proposta rifiutata", + "staff_offer_received": "Copia aggiunta al catalogo", + "staff_offer_delete": "Elimina proposta", + "staff_offer_delete_body": "Eliminare la proposta per «%1$s»? Non si può annullare.", + "staff_offer_deleted": "Proposta eliminata", + "staff_wanted_open_offers_count": "Proposte aperte: %1$d", + "staff_wanted_mark_received": "Ricevuto", + "staff_wanted_received": "«%1$s» ricevuto: copia aggiunta", + "staff_wanted_open_offers": "Questo titolo ha proposte aperte: ricevilo dalla proposta.", + "staff_archives_search": "Titolo o segnatura", + "staff_archives_filter_all": "Tutte", + "staff_archives_filter_published": "Pubblicate", + "staff_archives_filter_draft": "Non pubblicate", + "staff_archives_empty": "Nessuna unità archivistica", + "staff_archive_new": "Nuova unità archivistica", + "staff_archive_edit": "Modifica unità archivistica", + "staff_archive_published": "Pubblicata", + "staff_archive_draft": "Non pubblicata", + "staff_archive_published_hint": "Visibile nell’archivio pubblico e nell’app.", + "staff_archive_section_identity": "Identificazione", + "staff_archive_section_dates": "Date", + "staff_archive_section_description": "Descrizione", + "staff_archive_section_material": "Materiale", + "staff_archive_section_links": "Collegamenti e identificativi", + "staff_archive_section_authorities": "Autorità", + "staff_archive_section_files": "Copertina e documenti", + "staff_archive_reference_code": "Segnatura *", + "staff_archive_institution_code": "Codice istituto", + "staff_archive_level": "Livello *", + "staff_archive_constructed_title": "Titolo *", + "staff_archive_formal_title": "Titolo formale", + "staff_archive_parent_id": "Numero dell’unità madre", + "staff_archive_date_start": "Dal (anno)", + "staff_archive_date_end": "Al (anno)", + "staff_archive_extent": "Consistenza", + "staff_archive_scope_content": "Ambito e contenuto", + "staff_archive_language_codes": "Lingue (codici)", + "staff_archive_material": "Materiale specifico", + "staff_archive_color_mode": "Colore", + "staff_archive_dimensions": "Dimensioni", + "staff_archive_photographer": "Fotografo", + "staff_archive_publisher": "Editore", + "staff_archive_collection_name": "Raccolta", + "staff_archive_local_classification": "Classificazione locale", + "staff_archive_iiif": "URL del manifest IIIF", + "staff_archive_rights": "URL della dichiarazione dei diritti", + "staff_archive_ark": "Identificativo ARK", + "staff_archive_version_note": "Nota di versione", + "staff_archive_role": "Ruolo della prossima autorità", + "staff_archive_attach_authority": "Collega un’autorità", + "staff_archive_attach_role_hint": "Verrà collegata come: %1$s", + "staff_archive_detach": "Scollega %1$s", + "staff_archive_no_cover": "Ancora nessuna copertina.", + "staff_archive_has_cover": "La copertina è impostata.", + "staff_archive_upload_cover": "Carica una copertina", + "staff_archive_remove_cover": "Rimuovi la copertina", + "staff_archive_upload_document": "Allega un documento", + "staff_archive_remove_document": "Rimuovi il documento", + "staff_archive_save_first": "Salva prima l’unità per collegare autorità, copertina o documenti.", + "staff_archive_created": "Unità archivistica creata", + "staff_archive_saved": "Unità archivistica salvata", + "staff_archive_duplicate": "Questa segnatura è già usata in questo istituto.", + "staff_archive_cover_uploaded": "Copertina caricata", + "staff_archive_cover_removed": "Copertina rimossa", + "staff_archive_document_uploaded": "Documento allegato", + "staff_archive_document_removed": "Documento rimosso", + "staff_archive_authority_attached": "Autorità collegata", + "staff_archive_authority_detached": "Autorità scollegata", + "staff_archive_delete": "Elimina unità", + "staff_archive_delete_body": "Eliminare «%1$s» e i suoi allegati?", + "staff_authorities_search": "Cerca autorità", + "staff_authorities_empty": "Nessuna scheda di autorità", + "staff_authority_units": "%1$d unità", + "staff_authority_new": "Nuova autorità", + "staff_authority_edit": "Modifica autorità", + "staff_authority_delete": "Elimina autorità", + "staff_authority_delete_body": "Eliminare «%1$s»? Viene scollegata da tutte le unità.", + "staff_authority_type": "Tipo *", + "staff_authority_authorised_form": "Forma autorizzata del nome *", + "staff_authority_parallel_forms": "Forme parallele", + "staff_authority_other_forms": "Altre forme", + "staff_authority_identifiers": "Identificativi", + "staff_authority_dates": "Date di esistenza", + "staff_authority_history": "Storia", + "staff_authority_places": "Luoghi", + "staff_authority_legal_status": "Condizione giuridica", + "staff_authority_functions": "Funzioni e attività", + "staff_authority_mandates": "Mandati", + "staff_authority_internal_structure": "Struttura interna", + "staff_authority_general_context": "Contesto generale", + "staff_authority_linked_units": "Unità collegate" } From 98704f1884f2ff69b3ccd33a41d2e71cf78884ba Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 02:42:07 +0200 Subject: [PATCH 40/62] Document 1.8.0 and extend the live smoke to the staff phase 2 areas STATUS and the Uwe parity notes describe the circulation desk, the book tools and the plugin content now handled natively. The live smoke creates a loan through the API with an Idempotency-Key (and checks its replay), returns it in the app by typing the copy code, adds a copy with an explicit code, creates a series, follows a two-row CSV import, receives a Desiderata offer and creates an unpublished archive unit; each step is skipped while its health flag is off, and cleanup returns open loans and removes only what the run created. Hub navigation now scrolls to its rows, since the hub grew, and the location step waits for the editor to close instead of the save label, which a spinner replaces while saving. --- STATUS.md | 13 ++ docs/UWE-PARITY.md | 1 + tools/e2e/live_smoke.py | 271 ++++++++++++++++++++++++++++++++++++++-- 3 files changed, 276 insertions(+), 9 deletions(-) diff --git a/STATUS.md b/STATUS.md index 4f2d30d..d2c8c66 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,18 @@ # Pinakes Android — Build Status +## 1.8.0 (versionCode 21): circulation desk, book tools and plugin content for staff + +Requires Mobile API 1.7.0. Every area is gated on its own `/health` flag (`staff_circulation`, `staff_copies`, `staff_series`, `staff_merges`, `staff_labels`, `staff_imports`, `staff_covers`, `staff_desiderata`, `staff_archives`) plus the staff role and `staff_catalog`; a server that answers `false` or omits a flag keeps the 1.7.0 staff area for that part. The 403 `forbidden` contract is unchanged: every new call goes through the shared `staffCall`, which drops the stored role. + +- **Circulation desk** (hidden, never disabled, while the device is offline; an open desk screen closes when the connection drops): counters of the day, loans by bucket (requests, to pick up, scheduled, on loan, overdue, history) with search and multi-select bulk extend/recall, loan detail with the server's actions (approve, reject with reason, confirm/cancel pickup, return with the copy's outcome and a validated penalty, renew, edit user and dates, reminder, receipt PDF read in the app or shared through the FileProvider, receipt by email), new loan and new reservation (borrower search with eligibility, book search or a scanned copy code, per-user availability calendar, hand over now), reservations grouped by title (edit dates, cancel with reason, reactivate), borrower page. Creates carry an `Idempotency-Key` (one per submission, kept across the automatic retry after a dropped connection or a 409 `concurrent_retry`, discarded after success or `idempotency_conflict`). +- **Copy scanner**: the same CameraX + ZXing pipeline reading CODE 128 and CODE 39 inventory labels (and EAN-13); manual code entry always visible; the sheet shows the copy and its open loan, then returns it with an outcome, lends it, or opens the book or the loan. +- **Book tools**: per-copy list in the book editor (state, notes, delete with the server's refusal reasons, a copy with its own inventory code), book and copy label PDFs and the label settings sheet; series tree with search, details, rename (an existing name asks before merging), merge, delete, parent work, book numbers, removal and multi-select assignment; duplicate merges (suggested author groups or manual pick, publishers, genres); CSV and LibraryThing imports picked with the system document picker and run chunk by chunk from the server's progress (out-of-order recovery, busy retry, resumable after process death, pause/resume/cancel, error lines, past imports with their error CSV); missing covers fetched one book at a time with progress and the rate limit honoured; server-side cover sync and enrichment batches on the long-timeout client behind a confirmation; automatic enrichment switch. +- **Plugin content**: Desiderata offers by status (accept, reject, receive into the wanted title or a picked record, delete) and wanted titles marked received; Archives units (published or not) created and edited with the plugin's rules checked client-side, publish switch, authorities with roles, cover and document uploads, delete; authority records list and editor. +- Local verification: 369 debug unit tests (new: tolerant 1.7.0 parsing, loan actions, penalty validation, copy codes and export file names, staff areas × flags × connectivity, idempotency keys, series tree and series/label forms, import chunk rules, archive/authority forms and offer actions), zero lint errors (111 warnings, all pre-existing), debug and R8 release builds. +- Device tests: 77 instrumented tests on Android 15 (`staffe2e` with `FakePinakes`). New flows: return as damaged with penalty, invalid penalty not sent, borrower page, 409 message, new loan with user search and Idempotency-Key, ineligible borrower, return by typed copy code, unknown code, reservation cancel and create, circulation hidden offline and restored, desk screen closing offline, no section without flags; copy edit, refused delete, copy with code, taken code; series number and rename clash merge; suggested author merge; import resume with out-of-order recovery and an upload through the real system document picker; covers loop and enrichment switch; desiderata receive (linked and into a picked record) and closed offer; archive publish keeping every field and create after validation. +- Live smoke (`tools/e2e/live_smoke.py`) gains: loan created by API with an Idempotency-Key and its replay then returned in the app by typing the copy code, a copy with an explicit code from the book editor, a series created in the app, a two-row CSV import followed to completion, a Desiderata offer received, an unpublished archive unit absent from the public list. Each phase 2 step is skipped while its flag is off; cleanup returns loans the run left open and deletes only the books, series, units and offers the run created. +- Tier 2 (periodicals desk, book clubs, FRBR works, digital uploads) is not in this build: the flags exist and stay hidden. + ## 1.7.0 (versionCode 20): staff catalogue, offline catalogue, loans calendar Requires Mobile API 1.6.0 (Pinakes 0.8.5). Every new entry point is gated on the matching `/health` feature, so an older server keeps showing exactly the 1.6.2 app. diff --git a/docs/UWE-PARITY.md b/docs/UWE-PARITY.md index f12733f..ce6db23 100644 --- a/docs/UWE-PARITY.md +++ b/docs/UWE-PARITY.md @@ -27,6 +27,7 @@ I filtri di disponibilità si applicano ai libri con copie. La ricerca per gener - Backend richiesto: Mobile API 1.5.0 e plugin aggiornati (Desiderata 1.2.0, Emeroteca 1.13.0, Archives 1.5.1). Le nuove sezioni dipendono dalle capability del server. Un server precedente continua a funzionare, ma non offre la nuova API delle raccolte; per i filtri analitici non supportati l’app richiede l’aggiornamento. - La gestione amministrativa e gli upload degli articoli/archivi aprono il PHP protetto nel browser. Non sono nuovi editor CRUD nativi. - Aggiornamento 1.7.0 (Mobile API 1.6.0): per libri, autori, editori, generi e scaffali la gestione staff è ora nativa (lettore ISBN con modalità in serie, scheda manuale per materiale senza ISBN, copie, copertina). Articoli, archivi, utenti, prestiti e impostazioni restano sulle pagine PHP protette. Il catalogo è consultabile offline e i prestiti si possono sottoscrivere come calendario personale. +- Aggiornamento 1.8.0 (Mobile API 1.7.0): diventano native anche la circolazione (banco prestiti con contatori, richieste, ritiri, prestiti attivi e scaduti, storico, restituzione con esito e sanzione anche da codice copia scansionato, rinnovo, proroga e solleciti multipli, ricevuta PDF, nuovi prestiti e prenotazioni con calendario per utente), la gestione per singola copia con etichette, le collane, l’unione dei duplicati, l’importazione CSV e LibraryThing, copertine e arricchimento, le proposte Desiderata e le schede d’archivio con autorità e allegati. La circolazione sparisce senza connessione. Restano sul web: utenti, impostazioni generali, emeroteca e gruppi di lettura lato staff, opere FRBR. - [#52](https://github.com/fabiodalez-dev/Pinakes/issues/52), import danese FBI/DBC, resta una richiesta separata non implementata: token e documentazione completa richiedono accesso DBC, che non è disponibile in questa sessione. Non è una funzionalità già presente nel server da sincronizzare. - [#57](https://github.com/fabiodalez-dev/Pinakes/issues/57) descrive l’installazione dell’estensione YAZ nel container QNAP; Uwe precisa che non è un difetto Pinakes. Non viene dichiarata risolta modificando il client Android. - Nessuna merge, pubblicazione sugli store o distribuzione in produzione effettuata. Il caso ANR sul vecchio emulatore GUI resta distinto dai test riusciti sull’AVD headless; non si dichiara risolto un ANR su dispositivi reali. diff --git a/tools/e2e/live_smoke.py b/tools/e2e/live_smoke.py index 19e5fbc..4faf6ec 100755 --- a/tools/e2e/live_smoke.py +++ b/tools/e2e/live_smoke.py @@ -20,7 +20,12 @@ Steps: onboarding URL, login, staff hub, manual ISBN add (scraped preview), duplicate add copies, manual record without ISBN, shelf/level location on edit (skipped when the instance -has no shelf with a level), airplane-mode offline search and detail, calendar link + feed. +has no shelf with a level), phase 2 (each skipped while its /health staff_* flag is off): +loan created by API with an Idempotency-Key and its replay, then returned in the app by +typing the copy code; a copy with an explicit code from the book editor; a series created in +the app; a two-row CSV import job followed to completion; a Desiderata offer received; an +unpublished archive unit created and checked absent from the public list; then airplane-mode +offline search and detail, calendar link + feed. Cleanup (only what this run created): created books deleted, the calendar link revoked when it did not exist before, device tokens issued during the run revoked, app data cleared, airplane mode off. Exit code 0 only when every step passed. @@ -95,13 +100,18 @@ class Api: def __init__(self): self.token = None - def call(self, method, path, body=None, raw=False, auth=True, base=None): + def call(self, method, path, body=None, raw=False, auth=True, base=None, headers=None, multipart=None): url = (base or HOST_URL) + "/api/v1/" + path.lstrip("/") if not path.startswith("http") else path data = json.dumps(body).encode() if body is not None else None + content_type = "application/json" + if multipart is not None: + data, content_type = encode_multipart(multipart) req = urllib.request.Request(url, data=data, method=method) req.add_header("Accept", "application/json") + for k, v in (headers or {}).items(): + req.add_header(k, v) if data is not None: - req.add_header("Content-Type", "application/json") + req.add_header("Content-Type", content_type) if auth and self.token: req.add_header("Authorization", "Bearer " + self.token) try: @@ -138,8 +148,31 @@ def book(self, book_id): return body["data"] +def encode_multipart(fields): + """fields: {name: str | (filename, bytes, mime)} → (body, content type).""" + boundary = "e2e" + RUN + str(random.randint(1000, 9999)) + out = b"" + for name, value in fields.items(): + out += f"--{boundary}\r\n".encode() + if isinstance(value, tuple): + filename, payload, mime = value + out += f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\nContent-Type: {mime}\r\n\r\n'.encode() + out += payload + b"\r\n" + else: + out += f'Content-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode() + out += f"--{boundary}--\r\n".encode() + return out, f"multipart/form-data; boundary={boundary}" + + API = Api() CREATED = [] # book ids to delete +CREATED_SERIES = [] # series ids to delete +CREATED_ARCHIVES = [] # archive unit ids to delete +CREATED_OFFERS = [] # desiderata offer ids to delete +CIRC_TITLE = f"E2E circulation {RUN}" +SERIES_NAME = f"E2E series {RUN}" +IMPORT_TITLES = (f"E2E import A {RUN}", f"E2E import B {RUN}") +ARCHIVE_REF = f"E2E-{RUN}" RESULTS = [] # (step, ok, detail) DEVICES_BEFORE = set() STATE = {"devices_known": False, "calendar_was_enabled": None} @@ -248,7 +281,7 @@ def onboarding_and_login(): ui.type_text(DEVICE_URL) ui.hide_keyboard() ui.tap(S["action_discover_library"]) - ui.wait(S["action_continue"], 30) + ui.wait(S["action_continue"], 90) # discovery on a loaded dev server ui.tap(S["action_continue"]) ui.wait(S["login_email_label"], 30) ui.tap(S["login_email_label"], exact=True) @@ -276,7 +309,8 @@ def staff_hub(): @step("scan: manual ISBN, scraped preview, add with 2 copies") def scan_add(): - ui.tap(S["staff_scan"]) + ui.scroll_to(S["staff_scan"], exact=True) + ui.tap(S["staff_scan"], exact=True) deny_camera_prompt() ui.wait(S["staff_scan_manual_label"]) scanner_lookup(ISBN) @@ -310,7 +344,8 @@ def scan_duplicate(): @step("manual record without ISBN") def manual_record(): - ui.tap(S["staff_add_manual"]) + ui.scroll_to(S["staff_add_manual"], exact=True) + ui.tap(S["staff_add_manual"], exact=True) ui.wait(S["staff_field_title"]) ui.tap(S["staff_field_title"]) ui.type_text(MANUAL_TITLE) @@ -335,7 +370,8 @@ def edit_location(): raise SkipStep("no manual record") shelf, level = shelves[0], shelves[0]["levels"][0] before = API.book(CREATED[1]) - ui.tap(S["staff_books"]) + ui.scroll_to(S["staff_books"], exact=True) + ui.tap(S["staff_books"], exact=True) ui.tap(S["staff_books_search"]) ui.type_text(MANUAL_TITLE) ui.hide_keyboard() @@ -349,7 +385,8 @@ def edit_location(): ui.tap(S["staff_level"]) ui.tap(s("staff_level_number", level["number"])) ui.tap(S["staff_book_save"]) - ui.gone(S["staff_book_save"], 120) # the edit screen closes after the save + # The save button shows a spinner (no text) while saving: wait for the editor itself to close. + ui.gone(S["staff_book_edit"], 120) after = API.book(CREATED[1]) loc = after.get("location") or {} assert loc.get("shelf_id") == shelf["id"] and loc.get("level_id") == level["id"], f"location {loc}" @@ -416,6 +453,198 @@ def calendar(): ui.back() +# ---------------------------------------------------------------- phase 2 (Mobile API 1.7.0) + +def staff_flag(name): + _, body, _ = API.call("GET", "health", auth=False) + return bool((body.get("data") or {}).get("features", {}).get(name)) + + +def on_hub(nodes=None): + """The hub: its title is on screen and the bottom navigation is not (Profile shows both).""" + nodes = nodes if nodes is not None else ui.dump() + return ui.find(S["staff_title"], exact=True, nodes=nodes) and not ui.find(S["nav_profile"], exact=True, nodes=nodes) + + +def back_to_hub(): + for _ in range(6): + if on_hub(): + return + if ui.find(S["nav_profile"], exact=True): + break + ui.hide_keyboard() + ui.back() + open_staff_hub() + + +@step("circulation: loan by API, return by typed copy code in the app") +def circulation_return(): + if not staff_flag("staff_circulation"): + raise SkipStep("staff_circulation is off on this instance") + _, me, _ = API.call("GET", "me") + user_id = me["data"]["id"] + status, book, _ = API.call("POST", "staff/books", {"title": CIRC_TITLE, "copies": 1}) + assert status in (200, 201), f"create book HTTP {status}" + CREATED.append(book["data"]["id"]) + code = API.book(book["data"]["id"])["copies"][0]["inventory_number"] + key = f"e2e-{RUN}-loan" + status, loan, _ = API.call("POST", "staff/loans", {"user_id": user_id, "book_id": book["data"]["id"], "immediate": True, + "copy_code": code}, headers={"Idempotency-Key": key}) + assert status in (200, 201), f"create loan HTTP {status}: {loan.get('error')}" + loan_id = loan["data"]["id"] + # The same submission again is a replay, not a second loan. + status, again, _ = API.call("POST", "staff/loans", {"user_id": user_id, "book_id": book["data"]["id"], "immediate": True, + "copy_code": code}, headers={"Idempotency-Key": key}) + assert status == 200 and again["data"]["id"] == loan_id, f"replay HTTP {status}" + back_to_hub() + ui.scroll_to(S["staff_scan_copy"]) + ui.tap(S["staff_scan_copy"]) + deny_camera_prompt() + ui.tap(S["staff_copy_code"]) + ui.type_text(code) + ui.hide_keyboard() + ui.tap(S["staff_scan_lookup"]) + ui.wait(CIRC_TITLE, 30) + ui.tap(S["staff_loan_action_return"], exact=True) + ui.wait(S["staff_return_title"], 15) + time.sleep(1.5) + nodes = ui.dump() + buttons = [n for n in nodes if n.text == S["staff_loan_action_return"]] + ui.tap_xy(*buttons[-1].center) + ui.wait(s("staff_copy_returned_count", 1), 30) + _, after, _ = API.call("GET", f"staff/loans/{loan_id}") + assert after["data"]["status"] == "restituito", f"loan status {after['data']['status']}" + back_to_hub() + return f"loan #{loan_id} returned by code {code}" + + +@step("copies: add a copy with an explicit code from the book editor") +def copy_with_code(): + if not staff_flag("staff_copies"): + raise SkipStep("staff_copies is off on this instance") + if not CREATED: + raise SkipStep("no book created by this run") + book_id = CREATED[-1] + title = API.book(book_id)["title"] + code = f"E2E-{RUN}-2" + back_to_hub() + ui.scroll_to(S["staff_books"], exact=True) + ui.tap(S["staff_books"], exact=True) + ui.tap(S["staff_books_search"]) + ui.type_text(title) + ui.hide_keyboard() + ui.tap(title, timeout=30) + ui.wait(S["staff_book_save"], 30) + ui.scroll_to(S["staff_copy_add_with_code"]) + ui.tap(S["staff_copy_add_with_code"]) + ui.tap(S["staff_copy_code"]) + ui.type_text(code) + ui.hide_keyboard() + ui.tap(S["action_save"], exact=True) + ui.wait(S["staff_copy_added"], 30) + codes = [c.get("inventory_number") for c in API.book(book_id)["copies"]] + assert code in codes, f"{code} not in {codes}" + back_to_hub() + return f"{code} on book #{book_id}" + + +@step("series: create a series in the app") +def series_roundtrip(): + if not staff_flag("staff_series"): + raise SkipStep("staff_series is off on this instance") + back_to_hub() + ui.scroll_to(S["staff_series"]) + ui.tap(S["staff_series"], exact=True) + ui.tap(S["staff_series_new"]) + ui.tap(S["staff_field_name"]) + ui.type_text(SERIES_NAME) + ui.hide_keyboard() + ui.tap(S["action_save"], exact=True) + ui.wait(S["staff_series_add_books"], 30) + _, rows, _ = API.call("GET", "staff/series?flat=1&q=" + urllib.request.quote(SERIES_NAME)) + found = [r for r in rows.get("data") or [] if r.get("name") == SERIES_NAME] + CREATED_SERIES.extend(r["id"] for r in found) + assert len(found) == 1, f"expected the series once, found {len(found)}" + back_to_hub() + return f"series #{found[0]['id']}" + + +@step("imports: a two-row CSV job runs to completion in the app") +def csv_import_two_rows(): + if not staff_flag("staff_imports"): + raise SkipStep("staff_imports is off on this instance (backend pending)") + csv = "titolo;autore\n" + "\n".join(f"{t};E2E Author" for t in IMPORT_TITLES) + "\n" + status, job, _ = API.call("POST", "staff/imports", multipart={ + "file": (f"e2e-{RUN}.csv", csv.encode(), "text/csv"), "kind": "csv", "enable_scraping": "0"}) + assert status == 201, f"create import HTTP {status}: {job.get('error')}" + back_to_hub() + ui.scroll_to(S["staff_imports"]) + ui.tap(S["staff_imports"]) + ui.scroll_to(f"e2e-{RUN}.csv") + ui.tap(f"e2e-{RUN}.csv") + ui.wait(S["staff_import_status_done"], 120) + for t in IMPORT_TITLES: + rows = [r for r in API.find_book(t) if r["title"] == t] + CREATED.extend(r["id"] for r in rows if r["id"] not in CREATED) + assert len(rows) == 1, f"{t!r} imported {len(rows)} times" + back_to_hub() + return f"job {job['data']['id']}" + + +@step("desiderata: an offer is received into the catalogue from the app") +def desiderata_receive(): + if not staff_flag("staff_desiderata"): + raise SkipStep("staff_desiderata is off on this instance (backend pending)") + _, wanted, _ = API.call("GET", "desiderata?limit=1") + rows = wanted.get("data") or [] + if not rows: + raise SkipStep("the library wants no title") + title = rows[0]["title"] + status, offer, _ = API.call("POST", "desiderata/offers", {"submission_id": f"e2e-{RUN}", "book_id": rows[0]["id"], "title": title, + "notes": f"E2E {RUN}", "consent": True}) + assert status in (200, 201), f"offer HTTP {status}" + offer_id = offer["data"]["id"] + CREATED_OFFERS.append(offer_id) + back_to_hub() + ui.scroll_to(S["staff_desiderata"]) + ui.tap(S["staff_desiderata"]) + ui.scroll_to(title) + ui.tap(title) + ui.tap(S["staff_offer_receive"]) + ui.wait(S["staff_offer_received"], 30) + _, after, _ = API.call("GET", "staff/desiderata/offers?status=received") + assert any(o["id"] == offer_id for o in after.get("data") or []), "offer not received" + back_to_hub() + return f"offer #{offer_id} for {title!r}" + + +@step("archives: create an unpublished unit in the app") +def archive_create_delete(): + if not staff_flag("staff_archives"): + raise SkipStep("staff_archives is off on this instance (backend pending)") + back_to_hub() + ui.scroll_to(S["staff_archives"]) + ui.tap(S["staff_archives"], exact=True) + ui.tap(S["staff_archive_new"]) + ui.tap(S["staff_archive_published"]) # new units start published: turn it off + ui.tap(S["staff_archive_reference_code"]) + ui.type_text(ARCHIVE_REF) + ui.hide_keyboard() + ui.tap(S["staff_archive_constructed_title"]) + ui.type_text(f"E2E unit {RUN}") + ui.hide_keyboard() + ui.tap(S["action_save"], exact=True) + ui.wait(S["staff_archive_created"], 30) + _, rows, _ = API.call("GET", "staff/archives?published=all&q=" + urllib.request.quote(ARCHIVE_REF)) + found = [r for r in rows.get("data") or [] if r.get("reference_code") == ARCHIVE_REF] + CREATED_ARCHIVES.extend(r["id"] for r in found) + assert len(found) == 1 and not found[0].get("published"), f"archive rows {found}" + _, public, _ = API.call("GET", "archives?q=" + urllib.request.quote(ARCHIVE_REF)) + assert not any(r.get("reference_code") == ARCHIVE_REF for r in public.get("data") or []), "unpublished unit is public" + back_to_hub() + return f"archive #{found[0]['id']}" + + # ---------------------------------------------------------------- database (optional) DB_FLAGS = ("show_sesso", "show_cod_fiscale") @@ -533,6 +762,28 @@ def _cleanup_server(): ui.airplane(False) except Exception: # noqa: BLE001 pass + if API.token: + # Phase 2 records found by their run-unique names, in case a step failed half-way. + for t in (CIRC_TITLE,) + IMPORT_TITLES: + for r in API.find_book(t): + if r.get("title") == t and r["id"] not in CREATED: + CREATED.append(r["id"]) + for book_id in list(CREATED): + # A loan this run left open keeps the book: return it first. + _, loans, _ = API.call("GET", f"staff/loans?bucket=all&book_id={book_id}") + for loan in loans.get("data") or []: + if "return" in (loan.get("actions") or []): + status, _, _ = API.call("POST", f"staff/loans/{loan['id']}/return", {"outcome": "restituito"}) + print(f" return loan #{loan['id']}: HTTP {status}") + for series_id in CREATED_SERIES: + status, _, _ = API.call("DELETE", f"staff/series/{series_id}") + print(f" delete series #{series_id}: HTTP {status}") + for archive_id in CREATED_ARCHIVES: + status, _, _ = API.call("DELETE", f"staff/archives/{archive_id}") + print(f" delete archive unit #{archive_id}: HTTP {status}") + for offer_id in CREATED_OFFERS: + status, _, _ = API.call("DELETE", f"staff/desiderata/offers/{offer_id}") + print(f" delete offer #{offer_id}: HTTP {status}") for book_id in CREATED: status, _, _ = API.call("DELETE", f"staff/books/{book_id}") print(f" delete book #{book_id}: HTTP {status}") @@ -557,7 +808,9 @@ def main(): try: # Without a signed-in app the remaining steps cannot run: stop there. if ok and onboarding_and_login(): - for fn in (staff_hub, scan_add, scan_duplicate, manual_record, edit_location, offline, calendar, hidden_profile_fields): + for fn in (staff_hub, scan_add, scan_duplicate, manual_record, edit_location, + circulation_return, copy_with_code, series_roundtrip, csv_import_two_rows, desiderata_receive, archive_create_delete, + offline, calendar, hidden_profile_fields): fn() finally: cleanup() From 7e8080f737af325d464c9bd9d86f87030efbfd24 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 02:44:12 +0200 Subject: [PATCH 41/62] Follow the backend's content-tool precisions A merge retried after its records were already merged (409 merge_error with meta.missing) is reported as done and the duplicate list refreshed. A cover the source found but could not download has its own message. The imports screen offers the core LibraryThing TSV export when that import kind is available. --- .../java/com/pinakes/app/staffe2e/MergeFlowTest.kt | 12 ++++++++++++ .../main/java/com/pinakes/app/data/model/Models.kt | 2 ++ .../java/com/pinakes/app/data/staff/StaffErrors.kt | 1 + .../ui/screens/staff/content/StaffImportsScreens.kt | 12 +++++++++--- .../app/ui/screens/staff/content/StaffMergeScreen.kt | 9 +++++++-- i18n/de.json | 5 ++++- i18n/en.json | 5 ++++- i18n/fr.json | 5 ++++- i18n/it.json | 5 ++++- 9 files changed, 47 insertions(+), 9 deletions(-) diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/MergeFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/MergeFlowTest.kt index 6ee6544..22cdba2 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/MergeFlowTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/MergeFlowTest.kt @@ -22,4 +22,16 @@ class MergeFlowTest : AppFlowTest() { assertTrue(body, body.contains("\"primary_id\":3")) waitText(text(R.string.staff_merge_done, 2)) } + + @Test fun aRetriedMergeWhoseRecordsAreGoneCountsAsDone() { + launch { + json("GET staff/authors/duplicates", "staff_author_duplicates") + body("POST staff/authors/merge", 409, """{"data":null,"meta":{"missing":[8]},"error":{"code":"merge_error","message":"x"}}""") + } + openStaffHub() + click(text(R.string.staff_merges)) + clickTag("dup-group-0") + clickTag("merge-confirm") + waitText(text(R.string.staff_merge_already_done)) + } } 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 4f7828c..cc21cc9 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 @@ -39,6 +39,8 @@ data class Meta( @SerialName("retry_after") val retryAfter: Int? = null, // A create replayed through its Idempotency-Key: the original resource, nothing new made. val replayed: Boolean? = null, + // 409 `merge_error` on an author/publisher merge: ids that no longer exist (already merged). + val missing: List? = null, ) @Serializable diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt index c854344..5f42665 100644 --- a/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffErrors.kt @@ -35,6 +35,7 @@ object StaffErrors { "job_busy" -> R.string.staff_err_job_busy "gone" -> R.string.staff_err_gone "upload_rejected" -> R.string.staff_err_upload_rejected + "cover_download_failed" -> R.string.staff_err_cover_download_failed SubmissionKeys.CONFLICT -> R.string.staff_err_idempotency_conflict "feature_disabled" -> R.string.staff_err_feature_disabled ErrorCodes.RATE_LIMITED -> R.string.staff_err_rate_limited diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt index 1d0bd4a..57e356f 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt @@ -224,14 +224,17 @@ class StaffImportsViewModel @Inject constructor( fun downloadExample() = download("staff/imports/example.csv", "esempio_import.csv") + /** The whole catalogue as a LibraryThing TSV (core export, 15 a minute). */ + fun exportLibraryThing() = download("staff/exports/librarything.tsv", "librarything.tsv", "text/tab-separated-values") + fun downloadErrors(row: ImportHistoryRow) = download("staff/imports/history/${row.importId}/errors.csv", "errori_${row.importId}.csv") - private fun download(path: String, name: String) { + private fun download(path: String, name: String, mime: String = "text/csv") { viewModelScope.launch { val res = files.download(path, name) _state.update { when (res) { - is ApiResult.Success -> it.copy(export = res.data) + is ApiResult.Success -> it.copy(export = res.data, exportMime = mime) is ApiResult.Failure -> it.copy(forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_download_failed)) } } @@ -300,6 +303,9 @@ fun StaffImportsScreen(onNavigateUp: () -> Unit, onOpenJob: (String) -> Unit) { leadingIcon = Icons.Outlined.UploadFile, modifier = Modifier.fillMaxWidth().testTag("import-start"), ) SecondaryButton(stringResource(R.string.staff_import_example), onClick = vm::downloadExample, leadingIcon = Icons.Outlined.Download, modifier = Modifier.fillMaxWidth()) + if (areas.libraryThing) { + SecondaryButton(stringResource(R.string.staff_export_librarything), onClick = vm::exportLibraryThing, leadingIcon = Icons.Outlined.Download, modifier = Modifier.fillMaxWidth()) + } } } if (state.jobs.isNotEmpty()) { @@ -324,7 +330,7 @@ fun StaffImportsScreen(onNavigateUp: () -> Unit, onOpenJob: (String) -> Unit) { } } } - state.export?.let { ExportFileDialog(it, "text/csv", stringResource(R.string.staff_imports), onDismiss = vm::closeExport) } + state.export?.let { ExportFileDialog(it, state.exportMime, stringResource(R.string.staff_imports), onDismiss = vm::closeExport) } } private val MIME_TYPES = arrayOf("text/csv", "text/comma-separated-values", "text/plain", "text/tab-separated-values", "application/vnd.ms-excel") diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt index 6ea3449..4608a10 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt @@ -188,10 +188,15 @@ class StaffMergeViewModel @Inject constructor( _state.update { when (res) { is ApiResult.Success -> it.copy(busy = false, confirming = false, selection = MergeSelection(), message = StaffMessage(R.string.staff_merge_done, args = listOf(ids.size))) - is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_save)) + // A retried merge whose records are already gone: nothing left to do. + is ApiResult.Failure -> if (res.code == "merge_error" && !res.meta?.missing.isNullOrEmpty()) { + it.copy(busy = false, confirming = false, selection = MergeSelection(), message = StaffMessage(R.string.staff_merge_already_done)) + } else { + it.copy(busy = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_save)) + } } } - if (res is ApiResult.Success && s.tab == MergeTab.AUTHORS) loadDuplicates() + if (s.tab == MergeTab.AUTHORS && (res is ApiResult.Success || (res as? ApiResult.Failure)?.code == "merge_error")) loadDuplicates() } } diff --git a/i18n/de.json b/i18n/de.json index 2d33954..4f51d52 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1293,5 +1293,8 @@ "staff_authority_mandates": "Mandate", "staff_authority_internal_structure": "Interne Struktur", "staff_authority_general_context": "Allgemeiner Kontext", - "staff_authority_linked_units": "Verknüpfte Einheiten" + "staff_authority_linked_units": "Verknüpfte Einheiten", + "staff_export_librarything": "Für LibraryThing exportieren", + "staff_merge_already_done": "Diese Einträge wurden bereits zusammengeführt.", + "staff_err_cover_download_failed": "Ein Cover wurde gefunden, konnte aber nicht heruntergeladen werden." } diff --git a/i18n/en.json b/i18n/en.json index 61fc3fd..f3af598 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1293,5 +1293,8 @@ "staff_authority_mandates": "Mandates", "staff_authority_internal_structure": "Internal structure", "staff_authority_general_context": "General context", - "staff_authority_linked_units": "Linked units" + "staff_authority_linked_units": "Linked units", + "staff_export_librarything": "Export for LibraryThing", + "staff_merge_already_done": "These records were already merged.", + "staff_err_cover_download_failed": "A cover was found but could not be downloaded." } diff --git a/i18n/fr.json b/i18n/fr.json index 4b65367..b3e1199 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1293,5 +1293,8 @@ "staff_authority_mandates": "Textes de référence", "staff_authority_internal_structure": "Organisation interne", "staff_authority_general_context": "Contexte général", - "staff_authority_linked_units": "Unités liées" + "staff_authority_linked_units": "Unités liées", + "staff_export_librarything": "Exporter pour LibraryThing", + "staff_merge_already_done": "Ces notices étaient déjà fusionnées.", + "staff_err_cover_download_failed": "Une couverture a été trouvée mais n’a pas pu être téléchargée." } diff --git a/i18n/it.json b/i18n/it.json index 0835ccf..e0a23c7 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1293,5 +1293,8 @@ "staff_authority_mandates": "Mandati", "staff_authority_internal_structure": "Struttura interna", "staff_authority_general_context": "Contesto generale", - "staff_authority_linked_units": "Unità collegate" + "staff_authority_linked_units": "Unità collegate", + "staff_export_librarything": "Esporta per LibraryThing", + "staff_merge_already_done": "Queste schede erano già state unite.", + "staff_err_cover_download_failed": "È stata trovata una copertina ma non è stato possibile scaricarla." } From 9e7e6c0507bd14532b614e7a508ed7bfae66824e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 02:47:30 +0200 Subject: [PATCH 42/62] Attach eBooks and audiobooks from the book editor When the server offers the Digital Library area, the book editor has a Digital content section: an eBook (PDF or EPUB) or an audiobook picked with the system document picker is streamed to POST /staff/books/{id}/digital on the long-timeout client, never held in memory. Without the flag the section is absent. --- .../assets/fixtures/digital_uploaded.json | 8 ++ .../assets/fixtures/health_staff_digital.json | 42 ++++++ .../com/pinakes/app/staffe2e/AppFlowTest.kt | 24 ++++ .../app/staffe2e/DigitalUploadFlowTest.kt | 40 ++++++ .../pinakes/app/staffe2e/ImportFlowTest.kt | 30 ----- .../app/data/model/StaffContentModels.kt | 7 + .../app/data/network/StaffContentApi.kt | 8 ++ .../data/repository/StaffContentRepository.kt | 25 ++++ .../ui/screens/staff/StaffBookEditScreen.kt | 2 + .../staff/content/StaffDigitalSection.kt | 126 ++++++++++++++++++ i18n/de.json | 10 +- i18n/en.json | 10 +- i18n/fr.json | 10 +- i18n/it.json | 10 +- 14 files changed, 318 insertions(+), 34 deletions(-) create mode 100644 app/src/androidTest/assets/fixtures/digital_uploaded.json create mode 100644 app/src/androidTest/assets/fixtures/health_staff_digital.json create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalUploadFlowTest.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffDigitalSection.kt diff --git a/app/src/androidTest/assets/fixtures/digital_uploaded.json b/app/src/androidTest/assets/fixtures/digital_uploaded.json new file mode 100644 index 0000000..ffe8dc3 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/digital_uploaded.json @@ -0,0 +1,8 @@ +{ + "data": { + "file_url": "/uploads/digital/5.pdf", + "audio_url": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/health_staff_digital.json b/app/src/androidTest/assets/fixtures/health_staff_digital.json new file mode 100644 index 0000000..f4f4ce5 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_staff_digital.json @@ -0,0 +1,42 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": true, + "catalog_sync": true, + "calendar_feed": true, + "staff_circulation": true, + "staff_copies": true, + "staff_series": true, + "staff_merges": true, + "staff_labels": true, + "staff_imports": true, + "staff_covers": true, + "staff_desiderata": true, + "staff_archives": true, + "staff_periodicals": false, + "staff_bookclub": false, + "staff_frbr": false, + "staff_digital_library": true, + "staff_librarything": false + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt index eaf861a..3fd59dd 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/AppFlowTest.kt @@ -197,4 +197,28 @@ abstract class AppFlowTest { instrumentation.uiAutomation.executeShellCommand("cmd connectivity airplane-mode $mode").close() Thread.sleep(1_500) } + + protected fun publishDownload(name: String, content: String, mime: String = "text/csv"): android.net.Uri { + check(android.os.Build.VERSION.SDK_INT >= 29) + val values = android.content.ContentValues().apply { + put(android.provider.MediaStore.Downloads.DISPLAY_NAME, name) + put(android.provider.MediaStore.Downloads.MIME_TYPE, mime) + } + val resolver = context.contentResolver + val uri = resolver.insert(android.provider.MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: error("MediaStore refused the download") + resolver.openOutputStream(uri)!!.use { it.write(content.toByteArray()) } + return uri + } + + /** Find [name] in the system document picker (Recent, else the Downloads root) and pick it. */ + protected fun pickInDocumentsUi(name: String) { + val file = device.wait(Until.findObject(By.text(name)), 6_000) ?: run { + device.wait(Until.findObject(By.desc("Show roots")), 3_000)?.click() + device.wait(Until.findObject(By.text("Downloads")), 3_000)?.click() + device.wait(Until.findObject(By.text(name)), 6_000) + } + org.junit.Assert.assertNotNull("$name not shown by the document picker", file) + file!!.click() + device.waitForIdle() + } } diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalUploadFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalUploadFlowTest.kt new file mode 100644 index 0000000..17557a6 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/DigitalUploadFlowTest.kt @@ -0,0 +1,40 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Book editor → Digital content: an eBook picked in the system picker is streamed to the server. */ +class DigitalUploadFlowTest : AppFlowTest() { + + @Test fun uploadAnEbookFromTheBookEditor() { + val name = "e2e-ebook-${System.currentTimeMillis() % 100000}.pdf" + val uri = publishDownload(name, "%PDF-1.4\n% e2e\n", "application/pdf") + try { + launch(health = "health_staff_digital") { json("POST staff/books/5/digital", "digital_uploaded") } + openStaffHub() + click(text(R.string.staff_books)) + click("Orlando") + click(text(R.string.staff_digital_ebook)) + pickInDocumentsUi(name) + waitRequest("POST", "staff/books/5/digital", timeoutMs = 20_000) + val body = api.recorded("POST", "staff/books/5/digital").last().body.clone().readUtf8() + assertTrue(body, body.contains("filename=\"$name\"")) + assertTrue(body, body.contains("name=\"digital_type\"") && body.contains("ebook")) + assertTrue(body, body.contains("%PDF-1.4")) + waitText(text(R.string.staff_digital_ebook_done)) + } finally { + context.contentResolver.delete(uri, null, null) + } + } + + @Test fun withoutTheFlagThereIsNoDigitalSection() { + launch() + openStaffHub() + click(text(R.string.staff_books)) + click("Orlando") + waitText(text(R.string.staff_book_save)) + assertFalse(exists(text(R.string.staff_digital_ebook))) + } +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ImportFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ImportFlowTest.kt index 69409c0..2c1534d 100644 --- a/app/src/androidTest/java/com/pinakes/app/staffe2e/ImportFlowTest.kt +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ImportFlowTest.kt @@ -1,13 +1,7 @@ package com.pinakes.app.staffe2e -import android.content.ContentValues -import android.os.Build -import android.provider.MediaStore -import androidx.test.uiautomator.By -import androidx.test.uiautomator.Until import com.pinakes.app.R import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test @@ -67,28 +61,4 @@ class ImportFlowTest : AppFlowTest() { context.contentResolver.delete(uri, null, null) } } - - private fun publishDownload(name: String, content: String): android.net.Uri { - check(Build.VERSION.SDK_INT >= 29) - val values = ContentValues().apply { - put(MediaStore.Downloads.DISPLAY_NAME, name) - put(MediaStore.Downloads.MIME_TYPE, "text/csv") - } - val resolver = context.contentResolver - val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: error("MediaStore refused the download") - resolver.openOutputStream(uri)!!.use { it.write(content.toByteArray()) } - return uri - } - - /** Find [name] in the system document picker (Recent, else the Downloads root) and pick it. */ - private fun pickInDocumentsUi(name: String) { - val file = device.wait(Until.findObject(By.text(name)), 6_000) ?: run { - device.wait(Until.findObject(By.desc("Show roots")), 3_000)?.click() - device.wait(Until.findObject(By.text("Downloads")), 3_000)?.click() - device.wait(Until.findObject(By.text(name)), 6_000) - } - assertNotNull("$name not shown by the document picker", file) - file!!.click() - device.waitForIdle() - } } diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt index 0a6447b..1030bf1 100644 --- a/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt @@ -252,6 +252,13 @@ data class EnrichBatchResult( val details: List = emptyList(), ) +/** `POST /staff/books/{id}/digital`: where the uploaded eBook or audiobook is served. */ +@Serializable +data class DigitalUploadResult( + @SerialName("file_url") val fileUrl: String? = null, + @SerialName("audio_url") val audioUrl: String? = null, +) + @Serializable data class CronRequest(val enabled: Boolean) diff --git a/app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt b/app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt index eaa7915..2fb6adb 100644 --- a/app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt @@ -157,6 +157,14 @@ interface StaffContentApi { @PUT("staff/enrich/cron") suspend fun enrichCron(@Body body: CronRequest): Envelope + @Multipart + @POST("staff/books/{id}/digital") + suspend fun uploadDigital( + @Path("id") bookId: Int, + @Part file: MultipartBody.Part, + @Part("digital_type") digitalType: RequestBody, + ): Envelope + /** A binary staff export (PDF, CSV), relative to the API base URL. */ @Streaming @GET diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt index f82cd39..e4b373e 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt @@ -41,6 +41,7 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody +import okio.source /** The import kinds of `POST /staff/imports`. */ enum class ImportKind(val wire: String) { CSV("csv"), LIBRARYTHING("librarything") } @@ -171,5 +172,29 @@ class StaffContentRepository( suspend fun enrichBatch(): ApiResult = session.staffCall { network.longStaffContentApi().enrichBatch() } + /** + * Upload a digital file (`ebook` or `audio`) streamed from [open] (an audiobook can be large: + * it is never read into memory). Uses the long-timeout client. + */ + suspend fun uploadDigital( + bookId: Int, + kind: String, + fileName: String, + mime: String?, + length: Long, + open: () -> java.io.InputStream, + ): ApiResult { + val type = mime?.toMediaTypeOrNull() ?: "application/octet-stream".toMediaType() + val body = object : okhttp3.RequestBody() { + override fun contentType() = type + override fun contentLength() = length + override fun writeTo(sink: okio.BufferedSink) { + open().use { input -> sink.writeAll(input.source()) } + } + } + val part = MultipartBody.Part.createFormData("file", fileName, body) + return session.staffCall { network.longStaffContentApi().uploadDigital(bookId, part, kind.toRequestBody("text/plain".toMediaType())) } + } + suspend fun setEnrichCron(enabled: Boolean): ApiResult = session.staffCall { api.enrichCron(CronRequest(enabled)) } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt index 5cf5003..816ddd3 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffBookEditScreen.kt @@ -307,6 +307,8 @@ private fun BookForm(state: StaffBookEditUiState, vm: StaffBookEditViewModel) { onMessage = vm::showMessage, onForbidden = vm::markForbidden, ) + // Digital Library plugin (`staff_digital_library`): eBook / audiobook upload. + com.pinakes.app.ui.screens.staff.content.StaffDigitalSection(onMessage = vm::showMessage, onForbidden = vm::markForbidden) } else { QuantityStepper(stringResource(R.string.staff_copies), f.copies, { n -> vm.edit { it.copy(copies = n) } }, range = StaffBookForm.COPIES_RANGE) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffDigitalSection.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffDigitalSection.kt new file mode 100644 index 0000000..a0475ec --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffDigitalSection.kt @@ -0,0 +1,126 @@ +package com.pinakes.app.ui.screens.staff.content + +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.MenuBook +import androidx.compose.material.icons.outlined.Headphones +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffContentRepository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** The digital kinds of the Digital Library plugin. */ +enum class DigitalKind(val wire: String, val mimes: Array) { + EBOOK("ebook", arrayOf("application/pdf", "application/epub+zip")), + AUDIO("audio", arrayOf("audio/mpeg", "audio/mp4", "audio/x-m4a", "audio/ogg", "audio/wav")), +} + +data class DigitalUiState(val uploading: DigitalKind? = null, val message: StaffMessage? = null, val forbidden: Boolean = false) + +/** + * Attach an eBook or an audiobook to a record (Digital Library plugin, `staff_digital_library`). + * The file is streamed from the document provider, never held in memory, on the long-timeout client. + */ +@HiltViewModel +class StaffDigitalViewModel @Inject constructor( + private val content: StaffContentRepository, + @ApplicationContext private val context: Context, + savedState: SavedStateHandle, +) : ViewModel() { + private val bookId: Int = savedState.get(Routes.ARG_BOOK_ID) ?: 0 + private val _state = MutableStateFlow(DigitalUiState()) + val state: StateFlow = _state.asStateFlow() + + fun upload(kind: DigitalKind, uri: Uri?) { + if (uri == null || bookId <= 0 || _state.value.uploading != null) return + _state.update { it.copy(uploading = kind) } + viewModelScope.launch { + val resolver = context.contentResolver + var name = "${kind.wire}.bin" + var size = -1L + runCatching { + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> + if (c.moveToFirst()) { + c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { i -> c.getString(i)?.let { name = it } } + c.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }?.let { i -> if (!c.isNull(i)) size = c.getLong(i) } + } + } + } + val res = content.uploadDigital(bookId, kind.wire, name, resolver.getType(uri), size) { + resolver.openInputStream(uri) ?: error("unreadable") + } + _state.update { + when (res) { + is ApiResult.Success -> it.copy(uploading = null, message = StaffMessage(if (kind == DigitalKind.EBOOK) R.string.staff_digital_ebook_done else R.string.staff_digital_audio_done)) + is ApiResult.Failure -> it.copy(uploading = null, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_digital_failed)) + } + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +@Composable +fun StaffDigitalSection(onMessage: (String) -> Unit, onForbidden: () -> Unit) { + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + if (!areas.digital) return + val vm: StaffDigitalViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val message = state.message?.resolve() + LaunchedEffect(state.message) { if (!message.isNullOrBlank()) { onMessage(message); vm.consumeMessage() } } + LaunchedEffect(state.forbidden) { if (state.forbidden) onForbidden() } + val ebook = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { vm.upload(DigitalKind.EBOOK, it) } + val audio = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { vm.upload(DigitalKind.AUDIO, it) } + Column(verticalArrangement = Arrangement.spacedBy(Spacing.xs), modifier = Modifier.testTag("digital-section")) { + FormSectionTitle(stringResource(R.string.staff_digital_title)) + Text(stringResource(R.string.staff_digital_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + SecondaryButton( + stringResource(if (state.uploading == DigitalKind.EBOOK) R.string.staff_digital_uploading else R.string.staff_digital_ebook), + onClick = { ebook.launch(DigitalKind.EBOOK.mimes) }, enabled = state.uploading == null, + leadingIcon = Icons.AutoMirrored.Outlined.MenuBook, modifier = Modifier.fillMaxWidth(), + ) + SecondaryButton( + stringResource(if (state.uploading == DigitalKind.AUDIO) R.string.staff_digital_uploading else R.string.staff_digital_audio), + onClick = { audio.launch(DigitalKind.AUDIO.mimes) }, enabled = state.uploading == null, + leadingIcon = Icons.Outlined.Headphones, modifier = Modifier.fillMaxWidth(), + ) + } +} diff --git a/i18n/de.json b/i18n/de.json index 4f51d52..48892e4 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1296,5 +1296,13 @@ "staff_authority_linked_units": "Verknüpfte Einheiten", "staff_export_librarything": "Für LibraryThing exportieren", "staff_merge_already_done": "Diese Einträge wurden bereits zusammengeführt.", - "staff_err_cover_download_failed": "Ein Cover wurde gefunden, konnte aber nicht heruntergeladen werden." + "staff_err_cover_download_failed": "Ein Cover wurde gefunden, konnte aber nicht heruntergeladen werden.", + "staff_digital_title": "Digitale Inhalte", + "staff_digital_hint": "Das E-Book oder Hörbuch anhängen, das Leser auf der Buchseite öffnen.", + "staff_digital_ebook": "E-Book hochladen (PDF oder EPUB)", + "staff_digital_audio": "Hörbuch hochladen", + "staff_digital_uploading": "Wird hochgeladen…", + "staff_digital_ebook_done": "E-Book angehängt", + "staff_digital_audio_done": "Hörbuch angehängt", + "staff_digital_failed": "Die Datei konnte nicht angehängt werden." } diff --git a/i18n/en.json b/i18n/en.json index f3af598..d0e87b2 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1296,5 +1296,13 @@ "staff_authority_linked_units": "Linked units", "staff_export_librarything": "Export for LibraryThing", "staff_merge_already_done": "These records were already merged.", - "staff_err_cover_download_failed": "A cover was found but could not be downloaded." + "staff_err_cover_download_failed": "A cover was found but could not be downloaded.", + "staff_digital_title": "Digital content", + "staff_digital_hint": "Attach the eBook or the audiobook readers can open from the book page.", + "staff_digital_ebook": "Upload an eBook (PDF or EPUB)", + "staff_digital_audio": "Upload an audiobook", + "staff_digital_uploading": "Uploading…", + "staff_digital_ebook_done": "eBook attached", + "staff_digital_audio_done": "Audiobook attached", + "staff_digital_failed": "The file could not be attached." } diff --git a/i18n/fr.json b/i18n/fr.json index b3e1199..3c31344 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1296,5 +1296,13 @@ "staff_authority_linked_units": "Unités liées", "staff_export_librarything": "Exporter pour LibraryThing", "staff_merge_already_done": "Ces notices étaient déjà fusionnées.", - "staff_err_cover_download_failed": "Une couverture a été trouvée mais n’a pas pu être téléchargée." + "staff_err_cover_download_failed": "Une couverture a été trouvée mais n’a pas pu être téléchargée.", + "staff_digital_title": "Contenus numériques", + "staff_digital_hint": "Joignez l’eBook ou le livre audio que les lecteurs ouvrent depuis la fiche.", + "staff_digital_ebook": "Envoyer un eBook (PDF ou EPUB)", + "staff_digital_audio": "Envoyer un livre audio", + "staff_digital_uploading": "Envoi…", + "staff_digital_ebook_done": "eBook joint", + "staff_digital_audio_done": "Livre audio joint", + "staff_digital_failed": "Le fichier n’a pas pu être joint." } diff --git a/i18n/it.json b/i18n/it.json index e0a23c7..b65fdf3 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1296,5 +1296,13 @@ "staff_authority_linked_units": "Unità collegate", "staff_export_librarything": "Esporta per LibraryThing", "staff_merge_already_done": "Queste schede erano già state unite.", - "staff_err_cover_download_failed": "È stata trovata una copertina ma non è stato possibile scaricarla." + "staff_err_cover_download_failed": "È stata trovata una copertina ma non è stato possibile scaricarla.", + "staff_digital_title": "Contenuti digitali", + "staff_digital_hint": "Allega l’eBook o l’audiolibro che i lettori aprono dalla scheda del libro.", + "staff_digital_ebook": "Carica un eBook (PDF o EPUB)", + "staff_digital_audio": "Carica un audiolibro", + "staff_digital_uploading": "Caricamento…", + "staff_digital_ebook_done": "eBook allegato", + "staff_digital_audio_done": "Audiolibro allegato", + "staff_digital_failed": "Non è stato possibile allegare il file." } From 126b167787206829f2263d47c83aceaf58752ac6 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 03:38:03 +0200 Subject: [PATCH 43/62] Make the live smoke's offer step self-contained and record the green run The Desiderata step now sends a free offer with a UUID submission id and receives it in the app into the record the run created, instead of picking a wanted title that other test runs may remove; slow copy saves get two minutes. STATUS records the Tier 2 scope (digital upload only) and the 16/16 live run. --- STATUS.md | 6 +++--- tools/e2e/live_smoke.py | 36 ++++++++++++++++++++++-------------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/STATUS.md b/STATUS.md index d2c8c66..9e5e41a 100644 --- a/STATUS.md +++ b/STATUS.md @@ -9,9 +9,9 @@ Requires Mobile API 1.7.0. Every area is gated on its own `/health` flag (`staff - **Book tools**: per-copy list in the book editor (state, notes, delete with the server's refusal reasons, a copy with its own inventory code), book and copy label PDFs and the label settings sheet; series tree with search, details, rename (an existing name asks before merging), merge, delete, parent work, book numbers, removal and multi-select assignment; duplicate merges (suggested author groups or manual pick, publishers, genres); CSV and LibraryThing imports picked with the system document picker and run chunk by chunk from the server's progress (out-of-order recovery, busy retry, resumable after process death, pause/resume/cancel, error lines, past imports with their error CSV); missing covers fetched one book at a time with progress and the rate limit honoured; server-side cover sync and enrichment batches on the long-timeout client behind a confirmation; automatic enrichment switch. - **Plugin content**: Desiderata offers by status (accept, reject, receive into the wanted title or a picked record, delete) and wanted titles marked received; Archives units (published or not) created and edited with the plugin's rules checked client-side, publish switch, authorities with roles, cover and document uploads, delete; authority records list and editor. - Local verification: 369 debug unit tests (new: tolerant 1.7.0 parsing, loan actions, penalty validation, copy codes and export file names, staff areas × flags × connectivity, idempotency keys, series tree and series/label forms, import chunk rules, archive/authority forms and offer actions), zero lint errors (111 warnings, all pre-existing), debug and R8 release builds. -- Device tests: 77 instrumented tests on Android 15 (`staffe2e` with `FakePinakes`). New flows: return as damaged with penalty, invalid penalty not sent, borrower page, 409 message, new loan with user search and Idempotency-Key, ineligible borrower, return by typed copy code, unknown code, reservation cancel and create, circulation hidden offline and restored, desk screen closing offline, no section without flags; copy edit, refused delete, copy with code, taken code; series number and rename clash merge; suggested author merge; import resume with out-of-order recovery and an upload through the real system document picker; covers loop and enrichment switch; desiderata receive (linked and into a picked record) and closed offer; archive publish keeping every field and create after validation. -- Live smoke (`tools/e2e/live_smoke.py`) gains: loan created by API with an Idempotency-Key and its replay then returned in the app by typing the copy code, a copy with an explicit code from the book editor, a series created in the app, a two-row CSV import followed to completion, a Desiderata offer received, an unpublished archive unit absent from the public list. Each phase 2 step is skipped while its flag is off; cleanup returns loans the run left open and deletes only the books, series, units and offers the run created. -- Tier 2 (periodicals desk, book clubs, FRBR works, digital uploads) is not in this build: the flags exist and stay hidden. +- Device tests: 80 instrumented tests on Android 15 (`staffe2e` with `FakePinakes`). New flows: return as damaged with penalty, invalid penalty not sent, borrower page, 409 message, new loan with user search and Idempotency-Key, ineligible borrower, return by typed copy code, unknown code, reservation cancel and create, circulation hidden offline and restored, desk screen closing offline, no section without flags; copy edit, refused delete, copy with code, taken code; series number and rename clash merge; suggested author merge; import resume with out-of-order recovery and an upload through the real system document picker; covers loop and enrichment switch; desiderata receive (linked and into a picked record) and closed offer; archive publish keeping every field and create after validation; a retried merge already done; digital upload through the system picker and its absence without the flag. +- Live smoke (`tools/e2e/live_smoke.py`) gains: loan created by API with an Idempotency-Key and its replay then returned in the app by typing the copy code, a copy with an explicit code from the book editor, a series created in the app, a two-row CSV import followed to completion, a free Desiderata offer received into a picked record, an unpublished archive unit absent from the public list. Each phase 2 step is skipped while its flag is off; cleanup returns loans the run left open and deletes only the books, series, units and offers the run created. Run on 2026-10-14 against the local server (Mobile API 1.7.0 branch) on an Android 15 emulator: 16 passed, 0 skipped, 0 failed. +- Tier 2: eBook and audiobook upload from the book editor (`staff_digital_library`, streamed, long-timeout client). The periodicals desk, book clubs and FRBR works are not in this build: their contract is frozen only at endpoint level and the server does not offer them yet; their flags are parsed and every entry stays hidden. ## 1.7.0 (versionCode 20): staff catalogue, offline catalogue, loans calendar diff --git a/tools/e2e/live_smoke.py b/tools/e2e/live_smoke.py index 4faf6ec..c42f038 100755 --- a/tools/e2e/live_smoke.py +++ b/tools/e2e/live_smoke.py @@ -23,7 +23,7 @@ has no shelf with a level), phase 2 (each skipped while its /health staff_* flag is off): loan created by API with an Idempotency-Key and its replay, then returned in the app by typing the copy code; a copy with an explicit code from the book editor; a series created in -the app; a two-row CSV import job followed to completion; a Desiderata offer received; an +the app; a two-row CSV import job followed to completion; a free Desiderata offer received into a picked record; an unpublished archive unit created and checked absent from the public list; then airplane-mode offline search and detail, calendar link + feed. Cleanup (only what this run created): created books deleted, the calendar link revoked when @@ -40,6 +40,7 @@ import traceback import urllib.error import urllib.request +import uuid sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import ui # noqa: E402 @@ -541,7 +542,7 @@ def copy_with_code(): ui.type_text(code) ui.hide_keyboard() ui.tap(S["action_save"], exact=True) - ui.wait(S["staff_copy_added"], 30) + ui.wait(S["staff_copy_added"], 120) # the copy save runs the full admin pipeline codes = [c.get("inventory_number") for c in API.book(book_id)["copies"]] assert code in codes, f"{code} not in {codes}" back_to_hub() @@ -591,18 +592,18 @@ def csv_import_two_rows(): return f"job {job['data']['id']}" -@step("desiderata: an offer is received into the catalogue from the app") +@step("desiderata: a free offer is received into a picked record from the app") def desiderata_receive(): if not staff_flag("staff_desiderata"): raise SkipStep("staff_desiderata is off on this instance (backend pending)") - _, wanted, _ = API.call("GET", "desiderata?limit=1") - rows = wanted.get("data") or [] - if not rows: - raise SkipStep("the library wants no title") - title = rows[0]["title"] - status, offer, _ = API.call("POST", "desiderata/offers", {"submission_id": f"e2e-{RUN}", "book_id": rows[0]["id"], "title": title, + target = next((r for r in API.find_book(CIRC_TITLE) if r.get("title") == CIRC_TITLE), None) + if target is None: + raise SkipStep("no record created by this run to receive into") + title = f"E2E offer {RUN}" + # A free offer (no wanted title): the run never touches someone else's wanted list. + status, offer, _ = API.call("POST", "desiderata/offers", {"submission_id": str(uuid.uuid4()), "title": title, "author": "E2E", "notes": f"E2E {RUN}", "consent": True}) - assert status in (200, 201), f"offer HTTP {status}" + assert status in (200, 201), f"offer HTTP {status}: {offer.get('error')}" offer_id = offer["data"]["id"] CREATED_OFFERS.append(offer_id) back_to_hub() @@ -610,12 +611,19 @@ def desiderata_receive(): ui.tap(S["staff_desiderata"]) ui.scroll_to(title) ui.tap(title) + time.sleep(1.5) # let the sheet finish expanding ui.tap(S["staff_offer_receive"]) - ui.wait(S["staff_offer_received"], 30) - _, after, _ = API.call("GET", "staff/desiderata/offers?status=received") - assert any(o["id"] == offer_id for o in after.get("data") or []), "offer not received" + ui.wait(S["staff_offer_receive_into"], 30) + ui.tap(S["cd_clear"]) + ui.type_text(CIRC_TITLE) + ui.hide_keyboard() + ui.tap(CIRC_TITLE, timeout=60) + ui.wait(S["staff_offer_received"], 120) + _, after, _ = API.call("GET", "staff/desiderata/offers?status=received&q=" + urllib.request.quote(title)) + got = [o for o in after.get("data") or [] if o["id"] == offer_id] + assert got and (got[0].get("received_book") or {}).get("id") == target["id"], f"offer after receive: {got}" back_to_hub() - return f"offer #{offer_id} for {title!r}" + return f"offer #{offer_id} received into book #{target['id']}" @step("archives: create an unpublished unit in the app") From eca52e6e143ca1452fa1725991b71a2f98ef4869 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 03:50:30 +0200 Subject: [PATCH 44/62] Write staff counts so they read correctly with one item MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Counts in the staff texts used a counted noun after the number ("1 books", "1 Bücher", "1 rows"); the i18n pipeline generates plain strings, so they now read "Books: 1" style in all four languages: duplicate groups, series counts, delete and merge messages, missing covers, enrichment, import progress, bulk extend, days overdue, genre merge, authority units, merged records and cover sync. A unit test fails if a staff string puts a counted noun right after a number again. New texts for select all, an import that ended elsewhere and a document over 200 MB. --- .../app/staff/StaffStringsPluralTest.kt | 33 +++++++++++++++++++ i18n/de.json | 33 ++++++++++--------- i18n/en.json | 31 +++++++++-------- i18n/fr.json | 31 +++++++++-------- i18n/it.json | 33 ++++++++++--------- 5 files changed, 103 insertions(+), 58 deletions(-) create mode 100644 app/src/test/java/com/pinakes/app/staff/StaffStringsPluralTest.kt diff --git a/app/src/test/java/com/pinakes/app/staff/StaffStringsPluralTest.kt b/app/src/test/java/com/pinakes/app/staff/StaffStringsPluralTest.kt new file mode 100644 index 0000000..ca00f07 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/StaffStringsPluralTest.kt @@ -0,0 +1,33 @@ +package com.pinakes.app.staff + +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** + * Staff texts never put a counted noun right after a number ("1 books", "1 Bücher"): the i18n + * pipeline generates plain strings, so counts are written as "Books: 1" in every language. + */ +class StaffStringsPluralTest { + + private val nouns = listOf( + "books", "loans", "days", "rows", "units", "records", "copies", "reservations", + "libri", "prestiti", "giorni", "righe", "unità", "schede", "copie", + "livres", "prêts", "jours", "lignes", "unités", "notices", "exemplaires", + "Bücher", "Ausleihen", "Tage", "Zeilen", "Einheiten", "Einträge", "Exemplare", + ) + private val pattern = Regex("%\\d\\${'$'}d\\s+(" + nouns.joinToString("|") + ")\\b") + + private fun i18nDir(): File = listOf(File("../i18n"), File("i18n")).first { it.isDirectory } + + @Test fun noCountedNounAfterANumberInStaffStrings() { + val offenders = mutableListOf() + for (lang in listOf("en", "it", "fr", "de")) { + val text = File(i18nDir(), "$lang.json").readText() + Regex(""""(staff_[a-z0-9_]+)":\s*"((?:[^"\\]|\\.)*)"""").findAll(text).forEach { m -> + if (pattern.containsMatchIn(m.groupValues[2])) offenders += "$lang ${m.groupValues[1]}: ${m.groupValues[2]}" + } + } + assertTrue(offenders.joinToString("\n"), offenders.isEmpty()) + } +} diff --git a/i18n/de.json b/i18n/de.json index 48892e4..a746aa6 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -871,10 +871,10 @@ "staff_not_selected": "Nicht ausgewählt", "staff_selected_count": "Ausgewählt: %1$d", "staff_bulk_extend": "Verlängern", - "staff_bulk_extend_body": "Das Fälligkeitsdatum von %1$d Ausleihen verschieben um:", + "staff_bulk_extend_body": "Ausgewählte Ausleihen: %1$d. Fälligkeit verschieben um:", "staff_bulk_extend_days": "Tage", "staff_bulk_extend_days_invalid": "Zwischen 1 und 365 Tagen", - "staff_bulk_extended": "%1$d Ausleihen um %2$d Tage verlängert", + "staff_bulk_extended": "Verlängerte Ausleihen: %1$d · hinzugefügte Tage: %2$d", "staff_bulk_recalled": "Mahnungen gesendet: %1$d, übersprungen: %2$d", "staff_loan_title": "Ausleihe", "staff_loan_borrower": "Nutzer", @@ -892,7 +892,7 @@ "staff_loan_renewals_value": "%1$d von %2$d", "staff_loan_recalls": "Mahnungen", "staff_loan_processed_by": "Bearbeitet von", - "staff_loan_days_overdue": "Seit %1$d Tagen überfällig", + "staff_loan_days_overdue": "Tage überfällig: %1$d", "staff_loan_pickup_by": "Abholen bis %1$s", "staff_loan_starts_on": "Beginnt am %1$s", "staff_penalty": "Gebühr", @@ -1044,7 +1044,7 @@ "staff_series_new": "Neue Reihe", "staff_series_search": "Reihen suchen", "staff_series_empty": "Noch keine Reihen", - "staff_series_book_count": "%1$d Bücher", + "staff_series_book_count": "Bücher: %1$d", "staff_series_complete": "Vollständig", "staff_series_exists": "Eine Reihe „%1$s“ gibt es schon. Öffnen?", "staff_series_open_existing": "Öffnen", @@ -1064,9 +1064,9 @@ "staff_series_merge_body": "Die Bücher wandern in die gewählte Reihe, dann wird diese Reihe entfernt.", "staff_series_merge_into": "Mit %1$s zusammenführen", "staff_series_merged": "Reihen zusammengeführt", - "staff_series_merged_count": "Reihen zusammengeführt: %1$d Bücher verschoben", + "staff_series_merged_count": "Reihen zusammengeführt. Verschobene Bücher: %1$d", "staff_series_delete": "Reihe löschen", - "staff_series_delete_body": "„%1$s“ löschen? Die %2$d Bücher bleiben ohne Reihe im Katalog.", + "staff_series_delete_body": "„%1$s“ löschen? Ihre Bücher (%2$d) bleiben ohne Reihe im Katalog.", "staff_series_parent_work": "Übergeordnetes Werk anlegen", "staff_series_parent_work_body": "Ein Katalogeintrag, der die ganze Reihe zusammenfasst, wie im Web.", "staff_series_parent_work_create": "Anlegen", @@ -1091,17 +1091,17 @@ "staff_merge_review": "Zusammenführung prüfen", "staff_merge_suggested": "Mögliche Dubletten", "staff_merge_no_duplicates": "Keine doppelten Autoren gefunden.", - "staff_merge_author_books": "%1$s (%2$d Bücher)", + "staff_merge_author_books": "%1$s · Bücher: %2$d", "staff_merge_primary_hint": "Der gewählte Eintrag bleibt; die anderen werden zusammengeführt und ihre Bücher verschoben.", "staff_merge_new_name": "Neuer Name (optional)", "staff_merge_new_name_hint": "Leer behält den Namen des verbleibenden Eintrags.", "staff_merge_action": "Zusammenführen", - "staff_merge_done": "%1$d Einträge zusammengeführt", + "staff_merge_done": "Zusammengeführte Einträge: %1$d", "staff_merge_genre_hint": "Bücher und Untergenres des ersten Genres wandern zum zweiten, dann wird das erste entfernt.", "staff_merge_genre_source": "Zu entfernendes Genre", "staff_merge_genre_target": "Verbleibendes Genre", "staff_merge_genre_pick": "Genre wählen", - "staff_merge_genre_done": "Genres zusammengeführt: %1$d Bücher, %2$d Untergenres verschoben", + "staff_merge_genre_done": "Genres zusammengeführt. Verschobene Bücher: %1$d · verschobene Untergenres: %2$d", "staff_merge_genre_descendant": "Ein Genre kann nicht mit einem eigenen Untergenre zusammengeführt werden.", "staff_import_field_authors": "Autoren", "staff_import_field_contributors": "Weitere Mitwirkende", @@ -1132,7 +1132,7 @@ "staff_import_example": "Beispiel-CSV", "staff_import_open_jobs": "Abzuschließende Importe", "staff_import_history": "Frühere Importe", - "staff_import_progress": "%1$d von %2$d Zeilen", + "staff_import_progress": "Zeilen: %1$d von %2$d", "staff_import_when": "Gestartet", "staff_import_by": "Von", "staff_import_result": "Ergebnis", @@ -1158,7 +1158,7 @@ "staff_import_resume": "Fortsetzen", "staff_import_cancel": "Import abbrechen", "staff_covers_missing_title": "Fehlende Cover", - "staff_covers_missing_count": "%1$d Bücher mit ISBN haben kein Cover.", + "staff_covers_missing_count": "Bücher mit ISBN, aber ohne Cover: %1$d", "staff_covers_missing_unknown": "Bücher ohne Cover werden gezählt…", "staff_covers_fetch_missing": "Fehlende Cover holen", "staff_covers_loop_progress": "%1$d von %2$d · gefunden %3$d · nicht gefunden %4$d", @@ -1166,7 +1166,7 @@ "staff_covers_stop": "Anhalten", "staff_covers_server_sync": "Alle Cover auf dem Server abgleichen", "staff_covers_server_sync_body": "Der Server holt alle fehlenden Cover auf einmal. Das kann bis zu zehn Minuten dauern; die App offen lassen.", - "staff_covers_sync_done": "Cover abgeglichen: %1$d von %2$d", + "staff_covers_sync_done": "Abgeglichene Cover: %1$d von %2$d", "staff_long_job_running": "Der Server arbeitet, das kann einige Minuten dauern…", "staff_enrich_title": "Anreicherung", "staff_enrich_with_isbn": "Bücher mit ISBN", @@ -1177,7 +1177,7 @@ "staff_enrich_cron_hint": "Der Server ergänzt planmäßig einen Stapel Bücher.", "staff_enrich_run": "Jetzt einen Stapel ausführen", "staff_enrich_run_body": "Der Server reichert die nächsten 20 Bücher an. Das kann ein paar Minuten dauern.", - "staff_enrich_done": "%1$d von %2$d Büchern angereichert", + "staff_enrich_done": "Angereicherte Bücher: %1$d von %2$d", "staff_offers_pending": "Zu prüfen", "staff_offers_accepted": "Angenommen", "staff_offers_received": "Erhalten", @@ -1275,7 +1275,7 @@ "staff_archive_delete_body": "„%1$s“ und die Anhänge löschen?", "staff_authorities_search": "Normdaten suchen", "staff_authorities_empty": "Keine Normdatensätze", - "staff_authority_units": "%1$d Einheiten", + "staff_authority_units": "Einheiten: %1$d", "staff_authority_new": "Neuer Normdatensatz", "staff_authority_edit": "Normdatensatz bearbeiten", "staff_authority_delete": "Normdatensatz löschen", @@ -1304,5 +1304,8 @@ "staff_digital_uploading": "Wird hochgeladen…", "staff_digital_ebook_done": "E-Book angehängt", "staff_digital_audio_done": "Hörbuch angehängt", - "staff_digital_failed": "Die Datei konnte nicht angehängt werden." + "staff_digital_failed": "Die Datei konnte nicht angehängt werden.", + "staff_select_all": "Alle auswählen", + "staff_import_status_gone": "Dieser Import wurde anderswo beendet", + "staff_archive_document_too_big": "Das Dokument ist größer als 200 MB." } diff --git a/i18n/en.json b/i18n/en.json index d0e87b2..a8b0892 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -871,10 +871,10 @@ "staff_not_selected": "Not selected", "staff_selected_count": "Selected: %1$d", "staff_bulk_extend": "Extend", - "staff_bulk_extend_body": "Move the due date of %1$d loans forward by:", + "staff_bulk_extend_body": "Selected loans: %1$d. Move their due date forward by:", "staff_bulk_extend_days": "Days", "staff_bulk_extend_days_invalid": "Between 1 and 365 days", - "staff_bulk_extended": "%1$d loans extended by %2$d days", + "staff_bulk_extended": "Loans extended: %1$d · days added: %2$d", "staff_bulk_recalled": "Reminders sent: %1$d, skipped: %2$d", "staff_loan_title": "Loan", "staff_loan_borrower": "Borrower", @@ -892,7 +892,7 @@ "staff_loan_renewals_value": "%1$d of %2$d", "staff_loan_recalls": "Reminders", "staff_loan_processed_by": "Handled by", - "staff_loan_days_overdue": "Overdue by %1$d days", + "staff_loan_days_overdue": "Days overdue: %1$d", "staff_loan_pickup_by": "Pick up by %1$s", "staff_loan_starts_on": "Starts on %1$s", "staff_penalty": "Penalty", @@ -1044,7 +1044,7 @@ "staff_series_new": "New series", "staff_series_search": "Search series", "staff_series_empty": "No series yet", - "staff_series_book_count": "%1$d books", + "staff_series_book_count": "Books: %1$d", "staff_series_complete": "Complete", "staff_series_exists": "A series called “%1$s” already exists. Open it?", "staff_series_open_existing": "Open it", @@ -1064,9 +1064,9 @@ "staff_series_merge_body": "Its books move to the series you pick, then this series is removed.", "staff_series_merge_into": "Merge into %1$s", "staff_series_merged": "Series merged", - "staff_series_merged_count": "Series merged: %1$d books moved", + "staff_series_merged_count": "Series merged. Books moved: %1$d", "staff_series_delete": "Delete series", - "staff_series_delete_body": "Delete “%1$s”? Its %2$d books stay in the catalogue without a series.", + "staff_series_delete_body": "Delete “%1$s”? Its books (%2$d) stay in the catalogue without a series.", "staff_series_parent_work": "Create the parent work", "staff_series_parent_work_body": "A catalogue record that groups the whole series, as on the web.", "staff_series_parent_work_create": "Create", @@ -1091,17 +1091,17 @@ "staff_merge_review": "Review the merge", "staff_merge_suggested": "Possible duplicates", "staff_merge_no_duplicates": "No duplicate authors found.", - "staff_merge_author_books": "%1$s (%2$d books)", + "staff_merge_author_books": "%1$s · books: %2$d", "staff_merge_primary_hint": "The selected record stays; the others are merged into it and their books move.", "staff_merge_new_name": "New name (optional)", "staff_merge_new_name_hint": "Empty keeps the name of the record that stays.", "staff_merge_action": "Merge", - "staff_merge_done": "%1$d records merged", + "staff_merge_done": "Records merged: %1$d", "staff_merge_genre_hint": "The books and subgenres of the first genre move to the second, then the first is removed.", "staff_merge_genre_source": "Genre to remove", "staff_merge_genre_target": "Genre that stays", "staff_merge_genre_pick": "Pick a genre", - "staff_merge_genre_done": "Genres merged: %1$d books, %2$d subgenres moved", + "staff_merge_genre_done": "Genres merged. Books moved: %1$d · subgenres moved: %2$d", "staff_merge_genre_descendant": "A genre cannot be merged into one of its own subgenres.", "staff_import_field_authors": "Authors", "staff_import_field_contributors": "Other contributors", @@ -1132,7 +1132,7 @@ "staff_import_example": "Example CSV", "staff_import_open_jobs": "Imports to finish", "staff_import_history": "Past imports", - "staff_import_progress": "%1$d of %2$d rows", + "staff_import_progress": "Rows: %1$d of %2$d", "staff_import_when": "Started", "staff_import_by": "By", "staff_import_result": "Result", @@ -1158,7 +1158,7 @@ "staff_import_resume": "Resume", "staff_import_cancel": "Cancel the import", "staff_covers_missing_title": "Missing covers", - "staff_covers_missing_count": "%1$d books with an ISBN have no cover.", + "staff_covers_missing_count": "Books with an ISBN but no cover: %1$d", "staff_covers_missing_unknown": "Counting the books without a cover…", "staff_covers_fetch_missing": "Fetch the missing covers", "staff_covers_loop_progress": "%1$d of %2$d · found %3$d · not found %4$d", @@ -1177,7 +1177,7 @@ "staff_enrich_cron_hint": "The server completes a batch of books on its schedule.", "staff_enrich_run": "Run a batch now", "staff_enrich_run_body": "The server enriches the next 20 books. It can take a couple of minutes.", - "staff_enrich_done": "Enriched %1$d of %2$d books", + "staff_enrich_done": "Books enriched: %1$d of %2$d", "staff_offers_pending": "To review", "staff_offers_accepted": "Accepted", "staff_offers_received": "Received", @@ -1275,7 +1275,7 @@ "staff_archive_delete_body": "Delete “%1$s” and its attachments?", "staff_authorities_search": "Search authorities", "staff_authorities_empty": "No authority records", - "staff_authority_units": "%1$d units", + "staff_authority_units": "Units: %1$d", "staff_authority_new": "New authority", "staff_authority_edit": "Edit authority", "staff_authority_delete": "Delete authority", @@ -1304,5 +1304,8 @@ "staff_digital_uploading": "Uploading…", "staff_digital_ebook_done": "eBook attached", "staff_digital_audio_done": "Audiobook attached", - "staff_digital_failed": "The file could not be attached." + "staff_digital_failed": "The file could not be attached.", + "staff_select_all": "Select all", + "staff_import_status_gone": "This import ended elsewhere", + "staff_archive_document_too_big": "The document is larger than 200 MB." } diff --git a/i18n/fr.json b/i18n/fr.json index 3c31344..5f4b133 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -871,10 +871,10 @@ "staff_not_selected": "Non sélectionné", "staff_selected_count": "Sélectionnés : %1$d", "staff_bulk_extend": "Prolonger", - "staff_bulk_extend_body": "Repousser l’échéance de %1$d prêts de :", + "staff_bulk_extend_body": "Prêts sélectionnés : %1$d. Repousser leur échéance de :", "staff_bulk_extend_days": "Jours", "staff_bulk_extend_days_invalid": "Entre 1 et 365 jours", - "staff_bulk_extended": "%1$d prêts prolongés de %2$d jours", + "staff_bulk_extended": "Prêts prolongés : %1$d · jours ajoutés : %2$d", "staff_bulk_recalled": "Relances envoyées : %1$d, ignorées : %2$d", "staff_loan_title": "Prêt", "staff_loan_borrower": "Lecteur", @@ -892,7 +892,7 @@ "staff_loan_renewals_value": "%1$d sur %2$d", "staff_loan_recalls": "Relances", "staff_loan_processed_by": "Traité par", - "staff_loan_days_overdue": "En retard de %1$d jours", + "staff_loan_days_overdue": "Jours de retard : %1$d", "staff_loan_pickup_by": "À retirer avant le %1$s", "staff_loan_starts_on": "Commence le %1$s", "staff_penalty": "Pénalité", @@ -1044,7 +1044,7 @@ "staff_series_new": "Nouvelle collection", "staff_series_search": "Rechercher une collection", "staff_series_empty": "Aucune collection pour l’instant", - "staff_series_book_count": "%1$d livres", + "staff_series_book_count": "Livres : %1$d", "staff_series_complete": "Complète", "staff_series_exists": "Une collection « %1$s » existe déjà. L’ouvrir ?", "staff_series_open_existing": "L’ouvrir", @@ -1064,9 +1064,9 @@ "staff_series_merge_body": "Ses livres passent dans la collection choisie, puis celle-ci est supprimée.", "staff_series_merge_into": "Fusionner avec %1$s", "staff_series_merged": "Collections fusionnées", - "staff_series_merged_count": "Collections fusionnées : %1$d livres déplacés", + "staff_series_merged_count": "Collections fusionnées. Livres déplacés : %1$d", "staff_series_delete": "Supprimer la collection", - "staff_series_delete_body": "Supprimer « %1$s » ? Ses %2$d livres restent au catalogue sans collection.", + "staff_series_delete_body": "Supprimer « %1$s » ? Ses livres (%2$d) restent au catalogue sans collection.", "staff_series_parent_work": "Créer l’œuvre mère", "staff_series_parent_work_body": "Une notice qui regroupe toute la collection, comme sur le web.", "staff_series_parent_work_create": "Créer", @@ -1091,17 +1091,17 @@ "staff_merge_review": "Vérifier la fusion", "staff_merge_suggested": "Doublons possibles", "staff_merge_no_duplicates": "Aucun auteur en double trouvé.", - "staff_merge_author_books": "%1$s (%2$d livres)", + "staff_merge_author_books": "%1$s · livres : %2$d", "staff_merge_primary_hint": "La notice choisie reste ; les autres y sont fusionnées et leurs livres déplacés.", "staff_merge_new_name": "Nouveau nom (facultatif)", "staff_merge_new_name_hint": "Vide garde le nom de la notice qui reste.", "staff_merge_action": "Fusionner", - "staff_merge_done": "%1$d notices fusionnées", + "staff_merge_done": "Notices fusionnées : %1$d", "staff_merge_genre_hint": "Les livres et sous-genres du premier genre passent au second, puis le premier est supprimé.", "staff_merge_genre_source": "Genre à supprimer", "staff_merge_genre_target": "Genre conservé", "staff_merge_genre_pick": "Choisir un genre", - "staff_merge_genre_done": "Genres fusionnés : %1$d livres, %2$d sous-genres déplacés", + "staff_merge_genre_done": "Genres fusionnés. Livres déplacés : %1$d · sous-genres déplacés : %2$d", "staff_merge_genre_descendant": "Un genre ne peut pas être fusionné avec l’un de ses sous-genres.", "staff_import_field_authors": "Auteurs", "staff_import_field_contributors": "Autres contributeurs", @@ -1132,7 +1132,7 @@ "staff_import_example": "CSV d’exemple", "staff_import_open_jobs": "Imports à terminer", "staff_import_history": "Imports précédents", - "staff_import_progress": "%1$d sur %2$d lignes", + "staff_import_progress": "Lignes : %1$d sur %2$d", "staff_import_when": "Démarré", "staff_import_by": "Par", "staff_import_result": "Résultat", @@ -1158,7 +1158,7 @@ "staff_import_resume": "Reprendre", "staff_import_cancel": "Annuler l’import", "staff_covers_missing_title": "Couvertures manquantes", - "staff_covers_missing_count": "%1$d livres avec ISBN n’ont pas de couverture.", + "staff_covers_missing_count": "Livres avec ISBN mais sans couverture : %1$d", "staff_covers_missing_unknown": "Comptage des livres sans couverture…", "staff_covers_fetch_missing": "Récupérer les couvertures manquantes", "staff_covers_loop_progress": "%1$d sur %2$d · trouvées %3$d · introuvables %4$d", @@ -1177,7 +1177,7 @@ "staff_enrich_cron_hint": "Le serveur complète un lot de livres selon son planning.", "staff_enrich_run": "Lancer un lot maintenant", "staff_enrich_run_body": "Le serveur enrichit les 20 livres suivants. Cela peut prendre quelques minutes.", - "staff_enrich_done": "Enrichis %1$d sur %2$d livres", + "staff_enrich_done": "Livres enrichis : %1$d sur %2$d", "staff_offers_pending": "À examiner", "staff_offers_accepted": "Acceptées", "staff_offers_received": "Reçues", @@ -1275,7 +1275,7 @@ "staff_archive_delete_body": "Supprimer « %1$s » et ses pièces jointes ?", "staff_authorities_search": "Rechercher une autorité", "staff_authorities_empty": "Aucune notice d’autorité", - "staff_authority_units": "%1$d unités", + "staff_authority_units": "Unités : %1$d", "staff_authority_new": "Nouvelle autorité", "staff_authority_edit": "Modifier l’autorité", "staff_authority_delete": "Supprimer l’autorité", @@ -1304,5 +1304,8 @@ "staff_digital_uploading": "Envoi…", "staff_digital_ebook_done": "eBook joint", "staff_digital_audio_done": "Livre audio joint", - "staff_digital_failed": "Le fichier n’a pas pu être joint." + "staff_digital_failed": "Le fichier n’a pas pu être joint.", + "staff_select_all": "Tout sélectionner", + "staff_import_status_gone": "Cet import s’est terminé ailleurs", + "staff_archive_document_too_big": "Le document dépasse 200 Mo." } diff --git a/i18n/it.json b/i18n/it.json index b65fdf3..14d6bfb 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -871,10 +871,10 @@ "staff_not_selected": "Non selezionato", "staff_selected_count": "Selezionati: %1$d", "staff_bulk_extend": "Proroga", - "staff_bulk_extend_body": "Sposta in avanti la scadenza di %1$d prestiti di:", + "staff_bulk_extend_body": "Prestiti selezionati: %1$d. Sposta in avanti la scadenza di:", "staff_bulk_extend_days": "Giorni", "staff_bulk_extend_days_invalid": "Tra 1 e 365 giorni", - "staff_bulk_extended": "%1$d prestiti prorogati di %2$d giorni", + "staff_bulk_extended": "Prestiti prorogati: %1$d · giorni aggiunti: %2$d", "staff_bulk_recalled": "Solleciti inviati: %1$d, saltati: %2$d", "staff_loan_title": "Prestito", "staff_loan_borrower": "Utente", @@ -892,7 +892,7 @@ "staff_loan_renewals_value": "%1$d di %2$d", "staff_loan_recalls": "Solleciti", "staff_loan_processed_by": "Gestito da", - "staff_loan_days_overdue": "In ritardo di %1$d giorni", + "staff_loan_days_overdue": "Giorni di ritardo: %1$d", "staff_loan_pickup_by": "Ritiro entro il %1$s", "staff_loan_starts_on": "Inizia il %1$s", "staff_penalty": "Sanzione", @@ -1044,7 +1044,7 @@ "staff_series_new": "Nuova collana", "staff_series_search": "Cerca collane", "staff_series_empty": "Ancora nessuna collana", - "staff_series_book_count": "%1$d libri", + "staff_series_book_count": "Libri: %1$d", "staff_series_complete": "Completa", "staff_series_exists": "Esiste già una collana «%1$s». Aprirla?", "staff_series_open_existing": "Aprila", @@ -1064,9 +1064,9 @@ "staff_series_merge_body": "I suoi libri passano alla collana scelta, poi questa viene eliminata.", "staff_series_merge_into": "Unisci a %1$s", "staff_series_merged": "Collane unite", - "staff_series_merged_count": "Collane unite: %1$d libri spostati", + "staff_series_merged_count": "Collane unite. Libri spostati: %1$d", "staff_series_delete": "Elimina collana", - "staff_series_delete_body": "Eliminare «%1$s»? I suoi %2$d libri restano in catalogo senza collana.", + "staff_series_delete_body": "Eliminare «%1$s»? I suoi libri (%2$d) restano in catalogo senza collana.", "staff_series_parent_work": "Crea l’opera madre", "staff_series_parent_work_body": "Una scheda di catalogo che raccoglie l’intera collana, come sul web.", "staff_series_parent_work_create": "Crea", @@ -1091,17 +1091,17 @@ "staff_merge_review": "Rivedi l’unione", "staff_merge_suggested": "Possibili duplicati", "staff_merge_no_duplicates": "Nessun autore duplicato trovato.", - "staff_merge_author_books": "%1$s (%2$d libri)", + "staff_merge_author_books": "%1$s · libri: %2$d", "staff_merge_primary_hint": "La scheda selezionata resta; le altre vengono unite e i loro libri spostati.", "staff_merge_new_name": "Nuovo nome (facoltativo)", "staff_merge_new_name_hint": "Vuoto mantiene il nome della scheda che resta.", "staff_merge_action": "Unisci", - "staff_merge_done": "%1$d schede unite", + "staff_merge_done": "Schede unite: %1$d", "staff_merge_genre_hint": "I libri e i sottogeneri del primo genere passano al secondo, poi il primo viene eliminato.", "staff_merge_genre_source": "Genere da eliminare", "staff_merge_genre_target": "Genere che resta", "staff_merge_genre_pick": "Scegli un genere", - "staff_merge_genre_done": "Generi uniti: %1$d libri, %2$d sottogeneri spostati", + "staff_merge_genre_done": "Generi uniti. Libri spostati: %1$d · sottogeneri spostati: %2$d", "staff_merge_genre_descendant": "Un genere non si può unire a un suo sottogenere.", "staff_import_field_authors": "Autori", "staff_import_field_contributors": "Altri contributori", @@ -1132,7 +1132,7 @@ "staff_import_example": "CSV di esempio", "staff_import_open_jobs": "Importazioni da completare", "staff_import_history": "Importazioni passate", - "staff_import_progress": "%1$d di %2$d righe", + "staff_import_progress": "Righe: %1$d su %2$d", "staff_import_when": "Avviata", "staff_import_by": "Da", "staff_import_result": "Esito", @@ -1158,7 +1158,7 @@ "staff_import_resume": "Riprendi", "staff_import_cancel": "Annulla l’importazione", "staff_covers_missing_title": "Copertine mancanti", - "staff_covers_missing_count": "%1$d libri con ISBN non hanno copertina.", + "staff_covers_missing_count": "Libri con ISBN ma senza copertina: %1$d", "staff_covers_missing_unknown": "Conto i libri senza copertina…", "staff_covers_fetch_missing": "Recupera le copertine mancanti", "staff_covers_loop_progress": "%1$d di %2$d · trovate %3$d · non trovate %4$d", @@ -1166,7 +1166,7 @@ "staff_covers_stop": "Interrompi", "staff_covers_server_sync": "Sincronizza tutte le copertine sul server", "staff_covers_server_sync_body": "Il server recupera tutte le copertine mancanti in un colpo solo. Può richiedere fino a dieci minuti; tieni aperta l’app.", - "staff_covers_sync_done": "Copertine sincronizzate: %1$d di %2$d", + "staff_covers_sync_done": "Copertine sincronizzate: %1$d su %2$d", "staff_long_job_running": "Lavoro in corso sul server, può richiedere qualche minuto…", "staff_enrich_title": "Arricchimento", "staff_enrich_with_isbn": "Libri con ISBN", @@ -1177,7 +1177,7 @@ "staff_enrich_cron_hint": "Il server completa un gruppo di libri secondo la sua pianificazione.", "staff_enrich_run": "Esegui ora un gruppo", "staff_enrich_run_body": "Il server arricchisce i prossimi 20 libri. Può richiedere un paio di minuti.", - "staff_enrich_done": "Arricchiti %1$d di %2$d libri", + "staff_enrich_done": "Libri arricchiti: %1$d su %2$d", "staff_offers_pending": "Da valutare", "staff_offers_accepted": "Accettate", "staff_offers_received": "Ricevute", @@ -1275,7 +1275,7 @@ "staff_archive_delete_body": "Eliminare «%1$s» e i suoi allegati?", "staff_authorities_search": "Cerca autorità", "staff_authorities_empty": "Nessuna scheda di autorità", - "staff_authority_units": "%1$d unità", + "staff_authority_units": "Unità: %1$d", "staff_authority_new": "Nuova autorità", "staff_authority_edit": "Modifica autorità", "staff_authority_delete": "Elimina autorità", @@ -1304,5 +1304,8 @@ "staff_digital_uploading": "Caricamento…", "staff_digital_ebook_done": "eBook allegato", "staff_digital_audio_done": "Audiolibro allegato", - "staff_digital_failed": "Non è stato possibile allegare il file." + "staff_digital_failed": "Non è stato possibile allegare il file.", + "staff_select_all": "Seleziona tutti", + "staff_import_status_gone": "Questa importazione è terminata altrove", + "staff_archive_document_too_big": "Il documento supera i 200 MB." } From 80d7173e410da028bd039e17e8fb5ce623103c8e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 03:50:50 +0200 Subject: [PATCH 45/62] Fix paging, batching, uploads and failed loads in the staff screens - A load-more interrupted by a reload no longer leaves the loan list stuck as loading. - Reservations, donation offers, wanted titles and authority records page past the first server page. - Bulk extend and bulk recall send the whole selection in batches of the server limits (500 and 50) and sum the counts; a Select all action covers every loaded loan. - Archive documents and digital files are streamed from the provider, never read into memory; a provider that refuses the stream becomes an IOException, an ordinary failure instead of a crash on OkHttp's thread; provider lookups for digital files run off the main thread. - Staff exports (receipts, labels, error CSVs) are deleted on sign out and on instance switch. - An authority that failed to load shows an error with Retry and can no longer be saved over. - Clearing a series' order sends an explicit null, so the server clears it. - An import answered with 410 gone shows that it ended elsewhere and offers no Resume or Cancel. - A failed duplicate list is reported with Retry instead of "no duplicates", and an old error no longer shows in the merge sheet. --- .../assets/fixtures/authorities_list.json | 16 + .../assets/fixtures/authority_7.json | 19 + .../assets/fixtures/bulk_recall_10.json | 8 + .../assets/fixtures/bulk_recall_50.json | 8 + .../fixtures/staff_loans_overdue_60.json | 2831 +++++++++++++++++ .../fixtures/staff_reservations_page1.json | 69 + .../fixtures/staff_reservations_page2.json | 39 + .../app/staffe2e/ReviewFixesFlowTest.kt | 99 + .../app/data/model/StaffContentModels.kt | 13 - .../pinakes/app/data/network/NetworkModule.kt | 31 +- .../app/data/network/StaffContentApi.kt | 5 +- .../repository/StaffCirculationRepository.kt | 37 +- .../data/repository/StaffContentRepository.kt | 12 +- .../data/repository/StaffFilesRepository.kt | 3 + .../data/repository/StaffPluginsRepository.kt | 13 +- .../app/data/repository/StreamingBody.kt | 43 + .../staff/circulation/StaffLoansScreen.kt | 10 +- .../circulation/StaffReservationsScreen.kt | 30 +- .../staff/content/StaffDigitalSection.kt | 24 +- .../staff/content/StaffImportsScreens.kt | 16 +- .../screens/staff/content/StaffMergeScreen.kt | 13 +- .../staff/content/StaffSeriesScreens.kt | 24 +- .../staff/plugins/StaffArchivesScreens.kt | 78 +- .../staff/plugins/StaffDesiderataScreen.kt | 43 +- .../com/pinakes/app/staff/SeriesTreeTest.kt | 18 +- 25 files changed, 3420 insertions(+), 82 deletions(-) create mode 100644 app/src/androidTest/assets/fixtures/authorities_list.json create mode 100644 app/src/androidTest/assets/fixtures/authority_7.json create mode 100644 app/src/androidTest/assets/fixtures/bulk_recall_10.json create mode 100644 app/src/androidTest/assets/fixtures/bulk_recall_50.json create mode 100644 app/src/androidTest/assets/fixtures/staff_loans_overdue_60.json create mode 100644 app/src/androidTest/assets/fixtures/staff_reservations_page1.json create mode 100644 app/src/androidTest/assets/fixtures/staff_reservations_page2.json create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/ReviewFixesFlowTest.kt create mode 100644 app/src/main/java/com/pinakes/app/data/repository/StreamingBody.kt diff --git a/app/src/androidTest/assets/fixtures/authorities_list.json b/app/src/androidTest/assets/fixtures/authorities_list.json new file mode 100644 index 0000000..f39a2e5 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/authorities_list.json @@ -0,0 +1,16 @@ +{ + "data": [ + { + "id": 7, + "type": "person", + "name": "Rossi, Mario", + "dates": "1880-1950", + "units_count": 2 + } + ], + "meta": { + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/authority_7.json b/app/src/androidTest/assets/fixtures/authority_7.json new file mode 100644 index 0000000..c323de7 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/authority_7.json @@ -0,0 +1,19 @@ +{ + "data": { + "id": 7, + "type": "person", + "authorised_form": "Rossi, Mario", + "dates_of_existence": "1880-1950", + "history": "Notaio.", + "units": [ + { + "id": 3, + "title": "Fondo Rossi", + "role": "creator" + } + ], + "authors": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/bulk_recall_10.json b/app/src/androidTest/assets/fixtures/bulk_recall_10.json new file mode 100644 index 0000000..3251567 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/bulk_recall_10.json @@ -0,0 +1,8 @@ +{ + "data": { + "sent": 9, + "skipped": 1 + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/bulk_recall_50.json b/app/src/androidTest/assets/fixtures/bulk_recall_50.json new file mode 100644 index 0000000..f4ec775 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/bulk_recall_50.json @@ -0,0 +1,8 @@ +{ + "data": { + "sent": 50, + "skipped": 0 + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_loans_overdue_60.json b/app/src/androidTest/assets/fixtures/staff_loans_overdue_60.json new file mode 100644 index 0000000..9c9e37f --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_loans_overdue_60.json @@ -0,0 +1,2831 @@ +{ + "data": [ + { + "id": 1000, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2000, + "title": "Overdue 00", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1001, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2001, + "title": "Overdue 01", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1002, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2002, + "title": "Overdue 02", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1003, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2003, + "title": "Overdue 03", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1004, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2004, + "title": "Overdue 04", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1005, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2005, + "title": "Overdue 05", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1006, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2006, + "title": "Overdue 06", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1007, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2007, + "title": "Overdue 07", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1008, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2008, + "title": "Overdue 08", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1009, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2009, + "title": "Overdue 09", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1010, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2010, + "title": "Overdue 10", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1011, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2011, + "title": "Overdue 11", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1012, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2012, + "title": "Overdue 12", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1013, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2013, + "title": "Overdue 13", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1014, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2014, + "title": "Overdue 14", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1015, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2015, + "title": "Overdue 15", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1016, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2016, + "title": "Overdue 16", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1017, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2017, + "title": "Overdue 17", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1018, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2018, + "title": "Overdue 18", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1019, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2019, + "title": "Overdue 19", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1020, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2020, + "title": "Overdue 20", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1021, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2021, + "title": "Overdue 21", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1022, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2022, + "title": "Overdue 22", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1023, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2023, + "title": "Overdue 23", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1024, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2024, + "title": "Overdue 24", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1025, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2025, + "title": "Overdue 25", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1026, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2026, + "title": "Overdue 26", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1027, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2027, + "title": "Overdue 27", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1028, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2028, + "title": "Overdue 28", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1029, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2029, + "title": "Overdue 29", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1030, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2030, + "title": "Overdue 30", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1031, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2031, + "title": "Overdue 31", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1032, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2032, + "title": "Overdue 32", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1033, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2033, + "title": "Overdue 33", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1034, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2034, + "title": "Overdue 34", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1035, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2035, + "title": "Overdue 35", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1036, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2036, + "title": "Overdue 36", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1037, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2037, + "title": "Overdue 37", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1038, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2038, + "title": "Overdue 38", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1039, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2039, + "title": "Overdue 39", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1040, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2040, + "title": "Overdue 40", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1041, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2041, + "title": "Overdue 41", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1042, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2042, + "title": "Overdue 42", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1043, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2043, + "title": "Overdue 43", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1044, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2044, + "title": "Overdue 44", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1045, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2045, + "title": "Overdue 45", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1046, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2046, + "title": "Overdue 46", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1047, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2047, + "title": "Overdue 47", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1048, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2048, + "title": "Overdue 48", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1049, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2049, + "title": "Overdue 49", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1050, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2050, + "title": "Overdue 50", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1051, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2051, + "title": "Overdue 51", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1052, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2052, + "title": "Overdue 52", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1053, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2053, + "title": "Overdue 53", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1054, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2054, + "title": "Overdue 54", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1055, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2055, + "title": "Overdue 55", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1056, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2056, + "title": "Overdue 56", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1057, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2057, + "title": "Overdue 57", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1058, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2058, + "title": "Overdue 58", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + }, + { + "id": 1059, + "status": "in_ritardo", + "status_label": "In corso", + "origin": "diretto", + "book": { + "id": 2059, + "title": "Overdue 59", + "cover_url": null, + "isbn13": "9780156701600" + }, + "copy": { + "id": 11, + "inventory_number": "INV-0011", + "status": "prestato" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "processed_by": { + "id": 1, + "name": "Admin" + }, + "requested_at": "2026-09-30", + "loaned_at": "2026-10-01", + "due_at": "2026-10-31", + "pickup_deadline": null, + "returned_at": null, + "returned_late": false, + "days_overdue": 3, + "renewals": 0, + "max_renewals": 2, + "penalty": null, + "notes": null, + "recall_count": 0, + "last_recall_at": null, + "created_at": "2026-10-01 10:00:00", + "updated_at": "2026-10-01 10:00:00", + "actions": [ + "return", + "recall" + ] + } + ], + "meta": { + "count": 60, + "limit": 100, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_reservations_page1.json b/app/src/androidTest/assets/fixtures/staff_reservations_page1.json new file mode 100644 index 0000000..fc67ba1 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_reservations_page1.json @@ -0,0 +1,69 @@ +{ + "data": [ + { + "id": 31, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "queue_position": 1, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + }, + { + "id": 32, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 5, + "title": "Orlando", + "cover_url": null, + "isbn13": "9780156701600" + }, + "user": { + "id": 4, + "name": "Bruno Sospeso", + "email": null, + "card_number": "C-0013", + "status": "sospeso" + }, + "queue_position": 2, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + } + ], + "meta": { + "count": 2, + "limit": 50, + "next_cursor": "Mg", + "has_more": true + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/staff_reservations_page2.json b/app/src/androidTest/assets/fixtures/staff_reservations_page2.json new file mode 100644 index 0000000..b720788 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/staff_reservations_page2.json @@ -0,0 +1,39 @@ +{ + "data": [ + { + "id": 33, + "status": "attiva", + "status_label": "Attiva", + "book": { + "id": 9, + "title": "Page two title", + "cover_url": null + }, + "user": { + "id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "card_number": "C-0012", + "status": "attivo" + }, + "queue_position": 1, + "reserved_at": "2026-10-10", + "expires_at": "2026-11-10", + "requested_from": "2026-11-01", + "requested_to": "2026-11-30", + "notified": false, + "created_at": "2026-10-10 09:00:00", + "actions": [ + "edit", + "cancel" + ] + } + ], + "meta": { + "count": 1, + "limit": 50, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/ReviewFixesFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReviewFixesFlowTest.kt new file mode 100644 index 0000000..610619e --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/ReviewFixesFlowTest.kt @@ -0,0 +1,99 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +/** Regressions from the phase 2 review: batching, paging, failed loads, ended jobs, exports on sign-out. */ +class ReviewFixesFlowTest : AppFlowTest() { + + @Test fun bulkRecallOfSixtyLoansGoesInTwoBatches() { + launch { + json("GET staff/circulation/summary", "circulation_summary") + json("GET staff/loans", "staff_loans_overdue_60") + sequence("POST staff/loans/bulk-recall", 200 to "bulk_recall_50", 200 to "bulk_recall_10") + } + openStaffHub() + click(text(R.string.staff_circulation_desk)) + clickTag("counter-overdue") + waitText("Overdue 00") + clickDescription(text(R.string.staff_select_many)) + click(text(R.string.staff_select_all)) + waitText(text(R.string.staff_selected_count, 60)) + click(text(R.string.staff_loan_action_recall)) + compose.waitUntil(10_000) { api.recorded("POST", "staff/loans/bulk-recall").size == 2 } + val sizes = api.recorded("POST", "staff/loans/bulk-recall").map { r -> + Regex("""\[(.*)]""").find(r.body.clone().readUtf8())!!.groupValues[1].split(',').size + } + assertEquals(listOf(50, 10), sizes) + waitText(text(R.string.staff_bulk_recalled, 59, 1)) + } + + @Test fun reservationsLoadTheNextPage() { + launch { + sequence("GET staff/reservations", 200 to "staff_reservations_page1", 200 to "staff_reservations_page2") + } + openStaffHub() + click(text(R.string.staff_reservations)) + waitText("Page two title", timeoutMs = 15_000) + assertEquals("Mg", api.recorded("GET", "staff/reservations").last().requestUrl?.queryParameter("cursor")) + } + + @Test fun anAuthorityThatFailedToLoadCannotBeSaved() { + launch { + json("GET staff/archives/lookups", "archive_lookups") + json("GET staff/archives/authorities", "authorities_list") + body("GET staff/archives/authorities/7", 500, """{"data":null,"meta":{},"error":{"code":"internal_error","message":"Authority failed"}}""") + } + openStaffHub() + click(text(R.string.staff_authorities)) + click("Rossi, Mario") + waitText("Authority failed") + assertFalse(compose.onAllNodes(androidx.compose.ui.test.hasTestTag("authority-save")).fetchSemanticsNodes().isNotEmpty()) + api.json("GET staff/archives/authorities/7", "authority_7") + click(text(R.string.action_retry)) + waitText("Notaio.") + waitFor(androidx.compose.ui.test.hasTestTag("authority-save")) + assertTrue(api.recorded("PUT", "staff/archives/authorities/7").isEmpty()) + } + + @Test fun aFailedDuplicateListIsReportedWithRetry() { + launch { + body("GET staff/authors/duplicates", 500, """{"data":null,"meta":{},"error":{"code":"internal_error","message":"Duplicates failed"}}""") + } + openStaffHub() + click(text(R.string.staff_merges)) + waitText("Duplicates failed") + assertFalse(exists(text(R.string.staff_merge_no_duplicates))) + api.json("GET staff/authors/duplicates", "staff_author_duplicates") + click(text(R.string.action_retry)) + waitText(text(R.string.staff_merge_author_books, "Woolf, Virginia", 1)) + } + + @Test fun anImportThatEndedElsewhereIsNotResumable() { + launch { + json("GET staff/imports", "imports_overview") + json("GET staff/imports/6f1c2a", "import_job_created") + body("POST staff/imports/6f1c2a/chunk", 410, """{"data":null,"meta":{},"error":{"code":"gone","message":"Import expired"}}""") + } + openStaffHub() + click(text(R.string.staff_imports)) + scrollListTo("libri.csv") + click("libri.csv") + waitText(text(R.string.staff_import_status_gone)) + assertFalse(exists(text(R.string.staff_import_resume))) + assertFalse(exists(text(R.string.staff_import_cancel))) + } + + @Test fun staffExportsAreDeletedOnSignOut() { + launch() + val exports = File(context.cacheDir, "exports").apply { mkdirs() } + File(exports, "prestito_7_20261014.pdf").writeText("%PDF-1.4") + runBlocking { deps.authRepository().logout() } + assertFalse(File(exports, "prestito_7_20261014.pdf").exists()) + } +} diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt index 1030bf1..2bf91e1 100644 --- a/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/StaffContentModels.kt @@ -69,19 +69,6 @@ data class StaffSeriesDetail( val related: List = emptyList(), ) -@Serializable -data class SeriesWrite( - val name: String, - val type: String? = null, - @SerialName("parent_id") val parentId: Int? = null, - @SerialName("parent_name") val parentName: String? = null, - val group: String? = null, - val cycle: String? = null, - @SerialName("cycle_order") val cycleOrder: Int? = null, - val description: String? = null, - val complete: Boolean? = null, -) - @Serializable data class SeriesRenameRequest(val name: String, @SerialName("merge_if_exists") val mergeIfExists: Boolean) diff --git a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt index 6950dda..2a00cef 100644 --- a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt +++ b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt @@ -34,7 +34,7 @@ import java.util.concurrent.TimeUnit * otherwise collide — so it is purged on logout and on instance switch (see * [clearHttpCache]) and no response body outlives the session that fetched it. */ -class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { +class NetworkModule(private val session: SessionStore, private val cacheDir: File? = null) { val json: Json = Json { ignoreUnknownKeys = true @@ -112,6 +112,10 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { private var cachedLongStaffApi: StaffContentApi? = null @Volatile private var cachedLongBaseUrl: String? = null + @Volatile + private var cachedLongPluginsApi: StaffPluginsApi? = null + @Volatile + private var cachedLongPluginsBaseUrl: String? = null /** * The same client with an 11-minute read timeout, for the synchronous server jobs that may @@ -235,6 +239,23 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { return api } + /** [StaffPluginsApi] on the long-timeout client, for archive document uploads (up to 200 MB). */ + @Synchronized + fun longStaffPluginsApi(): StaffPluginsApi { + val target = requireNotNull(session.instanceUrl) { "No instance URL configured. Complete onboarding first." } + val normalized = if (target.endsWith("/")) target else "$target/" + cachedLongPluginsApi?.let { if (normalized == cachedLongPluginsBaseUrl) return it } + val api = Retrofit.Builder() + .baseUrl(normalized) + .client(longOkHttpClient) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(StaffPluginsApi::class.java) + cachedLongPluginsApi = api + cachedLongPluginsBaseUrl = normalized + return api + } + /** Drop the cached Retrofit so the next [api] call rebuilds against a new instance URL. */ @Synchronized fun invalidate() { @@ -250,6 +271,8 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { cachedStaffPluginsApi = null cachedLongStaffApi = null cachedLongBaseUrl = null + cachedLongPluginsApi = null + cachedLongPluginsBaseUrl = null } /** @@ -262,9 +285,11 @@ class NetworkModule(private val session: SessionStore, cacheDir: File? = null) { * signing out into an error the user has to fight. */ suspend fun clearHttpCache() { - val cache = httpCache ?: return withContext(Dispatchers.IO) { - runCatching { cache.evictAll() } + runCatching { httpCache?.evictAll() } + // Staff exports (receipts naming a borrower, label PDFs, error CSVs) are response + // bodies too: they never outlive the session that downloaded them. + cacheDir?.let { runCatching { com.pinakes.app.data.repository.StaffFilesRepository.purge(it) } } } } diff --git a/app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt b/app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt index 2fb6adb..60c47b0 100644 --- a/app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/StaffContentApi.kt @@ -24,7 +24,6 @@ import com.pinakes.app.data.model.ParentWorkResult import com.pinakes.app.data.model.SeriesDeleteResult import com.pinakes.app.data.model.SeriesMergeResult import com.pinakes.app.data.model.SeriesRenameRequest -import com.pinakes.app.data.model.SeriesWrite import com.pinakes.app.data.model.StaffCopy import com.pinakes.app.data.model.StaffSeries import com.pinakes.app.data.model.StaffSeriesDetail @@ -73,10 +72,10 @@ interface StaffContentApi { suspend fun seriesSuggest(@Query("q") q: String, @Query("field") field: String? = null): Envelope> @POST("staff/series") - suspend fun createSeries(@Body body: SeriesWrite): Envelope + suspend fun createSeries(@Body body: JsonObject): Envelope @PUT("staff/series/{id}") - suspend fun updateSeries(@Path("id") id: Int, @Body body: SeriesWrite): Envelope + suspend fun updateSeries(@Path("id") id: Int, @Body body: JsonObject): Envelope @POST("staff/series/{id}/rename") suspend fun renameSeries(@Path("id") id: Int, @Body body: SeriesRenameRequest): Envelope diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt index 4a1659b..5a64683 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffCirculationRepository.kt @@ -42,6 +42,12 @@ class StaffCirculationRepository( private val network: NetworkModule, private val session: SessionStore, ) { + companion object { + /** Server limits of `POST /staff/loans/bulk-extend` and `/bulk-recall`. */ + const val BULK_EXTEND_MAX = 500 + const val BULK_RECALL_MAX = 50 + } + private val api get() = network.staffCirculationApi() suspend fun summary(): ApiResult = session.staffCall { api.summary() } @@ -88,12 +94,37 @@ class StaffCirculationRepository( suspend fun renew(id: Int): ApiResult = session.staffCall { api.renew(id) } - suspend fun bulkExtend(ids: List, days: Int): ApiResult = - session.staffCall { api.bulkExtend(BulkExtendRequest(ids.take(500), days.coerceIn(1, 365))) } + /** + * Extend every selected loan: the server takes at most [BULK_EXTEND_MAX] ids per call, so a + * larger selection goes in batches and the counts are summed. A failing batch stops there + * and reports the failure (the earlier batches are already applied server-side). + */ + suspend fun bulkExtend(ids: List, days: Int): ApiResult { + val d = days.coerceIn(1, 365) + var extended = 0 + for (batch in ids.distinct().chunked(BULK_EXTEND_MAX)) { + when (val res = session.staffCall { api.bulkExtend(BulkExtendRequest(batch, d)) }) { + is ApiResult.Success -> extended += res.data.extended + is ApiResult.Failure -> return res + } + } + return ApiResult.Success(BulkExtendResult(extended, d)) + } suspend fun recall(id: Int): ApiResult = session.staffCall { api.recall(id) } - suspend fun bulkRecall(ids: List): ApiResult = session.staffCall { api.bulkRecall(BulkIdsRequest(ids.take(50))) } + /** Remind every selected borrower, in batches of [BULK_RECALL_MAX] (the server's limit), summing the counts. */ + suspend fun bulkRecall(ids: List): ApiResult { + var sent = 0 + var skipped = 0 + for (batch in ids.distinct().chunked(BULK_RECALL_MAX)) { + when (val res = session.staffCall { api.bulkRecall(BulkIdsRequest(batch)) }) { + is ApiResult.Success -> { sent += res.data.sent; skipped += res.data.skipped } + is ApiResult.Failure -> return res + } + } + return ApiResult.Success(BulkRecallResult(sent, skipped)) + } suspend fun emailReceipt(id: Int): ApiResult = session.staffCall { api.emailReceipt(id) } diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt index e4b373e..61afb8b 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffContentRepository.kt @@ -24,7 +24,6 @@ import com.pinakes.app.data.model.ParentWorkResult import com.pinakes.app.data.model.SeriesDeleteResult import com.pinakes.app.data.model.SeriesMergeResult import com.pinakes.app.data.model.SeriesRenameRequest -import com.pinakes.app.data.model.SeriesWrite import com.pinakes.app.data.model.StaffCopy import com.pinakes.app.data.model.StaffSeries import com.pinakes.app.data.model.StaffSeriesDetail @@ -41,7 +40,6 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody -import okio.source /** The import kinds of `POST /staff/imports`. */ enum class ImportKind(val wire: String) { CSV("csv"), LIBRARYTHING("librarything") } @@ -76,7 +74,7 @@ class StaffContentRepository( suspend fun seriesSuggest(query: String, field: String = "name"): ApiResult> = session.staffCall { api.seriesSuggest(query.trim(), field) } - suspend fun saveSeries(id: Int?, body: SeriesWrite): ApiResult = + suspend fun saveSeries(id: Int?, body: kotlinx.serialization.json.JsonObject): ApiResult = session.staffCall { if (id == null || id <= 0) api.createSeries(body) else api.updateSeries(id, body) } suspend fun renameSeries(id: Int, name: String, mergeIfExists: Boolean): ApiResult = @@ -185,13 +183,7 @@ class StaffContentRepository( open: () -> java.io.InputStream, ): ApiResult { val type = mime?.toMediaTypeOrNull() ?: "application/octet-stream".toMediaType() - val body = object : okhttp3.RequestBody() { - override fun contentType() = type - override fun contentLength() = length - override fun writeTo(sink: okio.BufferedSink) { - open().use { input -> sink.writeAll(input.source()) } - } - } + val body = StreamingBody(type, length, open) val part = MultipartBody.Part.createFormData("file", fileName, body) return session.staffCall { network.longStaffContentApi().uploadDigital(bookId, part, kind.toRequestBody("text/plain".toMediaType())) } } diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffFilesRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffFilesRepository.kt index 06d4b54..0c05bba 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StaffFilesRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffFilesRepository.kt @@ -63,6 +63,9 @@ class StaffFilesRepository( const val EXPORTS_DIR = "exports" const val MAX_AGE_MS = 24L * 60 * 60 * 1000 + /** Delete every staff export under [cacheDir] (logout, instance switch). */ + fun purge(cacheDir: File): Boolean = File(cacheDir, EXPORTS_DIR).let { !it.exists() || it.deleteRecursively() } + /** `attachment; filename="prestito_7_20261014.pdf"` → `prestito_7_20261014.pdf`. */ fun filenameFrom(disposition: String?): String? { if (disposition.isNullOrBlank()) return null diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffPluginsRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffPluginsRepository.kt index 1e41076..74b8b4c 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/StaffPluginsRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffPluginsRepository.kt @@ -68,10 +68,17 @@ class StaffPluginsRepository( suspend fun deleteArchiveCover(id: Int): ApiResult = session.staffCall { api.deleteArchiveCover(id) } - suspend fun uploadArchiveDocument(id: Int, fileName: String, bytes: ByteArray, mime: String?): ApiResult { + /** A document streamed from [open] (up to 200 MB server-side), on the long-timeout client. */ + suspend fun uploadArchiveDocument( + id: Int, + fileName: String, + mime: String?, + length: Long, + open: () -> java.io.InputStream, + ): ApiResult { val type = mime?.toMediaTypeOrNull() ?: "application/octet-stream".toMediaType() - val part = MultipartBody.Part.createFormData("document", fileName, bytes.toRequestBody(type)) - return session.staffCall { api.uploadArchiveDocument(id, part) } + val part = MultipartBody.Part.createFormData("document", fileName, StreamingBody(type, length, open)) + return session.staffCall { network.longStaffPluginsApi().uploadArchiveDocument(id, part) } } suspend fun deleteArchiveDocument(id: Int, fileId: Int): ApiResult = diff --git a/app/src/main/java/com/pinakes/app/data/repository/StreamingBody.kt b/app/src/main/java/com/pinakes/app/data/repository/StreamingBody.kt new file mode 100644 index 0000000..0470a94 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/StreamingBody.kt @@ -0,0 +1,43 @@ +package com.pinakes.app.data.repository + +import okhttp3.MediaType +import okhttp3.RequestBody +import okio.BufferedSink +import okio.source +import java.io.IOException +import java.io.InputStream + +/** + * A request body streamed from [open] (a picked document): large files (audiobooks, archive + * scans, videos) are never held in memory. + * + * [writeTo] runs on OkHttp's dispatcher thread, where anything but an [IOException] is rethrown + * and would crash the app (it never reaches `apiCall`). A provider that refuses the stream + * (`SecurityException`, a null stream, a revoked grant) is therefore turned into an + * [IOException], which the call reports as an ordinary failure. + */ +class StreamingBody( + private val type: MediaType, + private val length: Long, + private val open: () -> InputStream, +) : RequestBody() { + override fun contentType(): MediaType = type + override fun contentLength(): Long = length + + override fun writeTo(sink: BufferedSink) { + val stream = try { + open() + } catch (e: IOException) { + throw e + } catch (e: Exception) { + throw IOException("The document could not be read", e) + } + try { + stream.use { input -> sink.writeAll(input.source()) } + } catch (e: IOException) { + throw e + } catch (e: Exception) { + throw IOException("The document could not be read", e) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoansScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoansScreen.kt index dab05ee..4efffe3 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoansScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoansScreen.kt @@ -146,7 +146,11 @@ class StaffLoansViewModel @Inject constructor( _state.update { it.copy(loadingMore = true) } viewModelScope.launch { val res = circulation.loans(s.bucket, s.query, cursor) - if (gen != generation) return@launch + if (gen != generation) { + // A reload replaced the list meanwhile: drop this page, but never stay "loading". + _state.update { it.copy(loadingMore = false) } + return@launch + } _state.update { when (res) { is ApiResult.Success -> it.copy(items = (it.items + res.data.items).distinctBy(StaffLoan::id), nextCursor = res.data.nextCursor, loadingMore = false) @@ -170,6 +174,9 @@ class StaffLoansViewModel @Inject constructor( fun toggleSelecting() = _state.update { it.copy(selecting = !it.selecting && it.canSelect, selected = emptySet()) } + /** Select every loaded loan that can be extended or recalled. */ + fun selectAll() = _state.update { s -> s.copy(selected = s.items.filter(LoanActions::isBulkSelectable).map(StaffLoan::id).toSet()) } + fun toggle(loan: StaffLoan) { if (!LoanActions.isBulkSelectable(loan)) return _state.update { s -> s.copy(selected = if (loan.id in s.selected) s.selected - loan.id else s.selected + loan.id) } @@ -275,6 +282,7 @@ fun StaffLoansScreen(onNavigateUp: () -> Unit, onOpenLoan: (Int) -> Unit) { verticalAlignment = Alignment.CenterVertically, ) { Text(stringResource(R.string.staff_selected_count, state.selected.size), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.weight(1f)) + PinakesTextButton(stringResource(R.string.staff_select_all), onClick = vm::selectAll) SecondaryButton(stringResource(R.string.staff_loan_action_recall), onClick = vm::bulkRecall, enabled = state.selected.isNotEmpty() && !state.bulkBusy) PrimaryButton(stringResource(R.string.staff_bulk_extend), onClick = { extendOpen = true }, enabled = state.selected.isNotEmpty(), loading = state.bulkBusy) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffReservationsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffReservationsScreen.kt index 50ba62b..7ba4b27 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffReservationsScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffReservationsScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -87,6 +88,8 @@ data class StaffReservationsUiState( val filter: ReservationFilter = ReservationFilter.ACTIVE, val query: String = "", val items: List = emptyList(), + val nextCursor: String? = null, + val loadingMore: Boolean = false, val loading: Boolean = true, val refreshing: Boolean = false, val error: StaffMessage? = null, @@ -117,13 +120,33 @@ class StaffReservationsViewModel @Inject constructor(private val circulation: St if (gen != generation) return@launch _state.update { when (res) { - is ApiResult.Success -> it.copy(items = res.data.items, loading = false, refreshing = false) + is ApiResult.Success -> it.copy(items = res.data.items, nextCursor = res.data.nextCursor, loading = false, refreshing = false) is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) } } } } + /** Next page: a queue cut at a page boundary continues under the same title header. */ + fun loadMore() { + val s = _state.value + val cursor = s.nextCursor ?: return + if (s.loadingMore || s.loading) return + val gen = generation + _state.update { it.copy(loadingMore = true) } + viewModelScope.launch { + val res = circulation.reservations(s.filter.wire, s.query, cursor) + _state.update { + when { + gen != generation -> it.copy(loadingMore = false) + res is ApiResult.Success -> it.copy(items = (it.items + res.data.items).distinctBy(StaffReservation::id), nextCursor = res.data.nextCursor, loadingMore = false) + res is ApiResult.Failure -> it.copy(loadingMore = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_load)) + else -> it.copy(loadingMore = false) + } + } + } + } + fun refresh() { _state.update { it.copy(refreshing = true) } reload() @@ -131,7 +154,7 @@ class StaffReservationsViewModel @Inject constructor(private val circulation: St fun setFilter(filter: ReservationFilter) { if (filter == _state.value.filter) return - _state.update { it.copy(filter = filter, items = emptyList()) } + _state.update { it.copy(filter = filter, items = emptyList(), nextCursor = null) } reload() } @@ -254,6 +277,9 @@ fun StaffReservationsScreen( } items(queue, key = { it.id }) { r -> StaffReservationRow(r, onClick = { vm.select(r) }, showBook = false) } } + if (state.nextCursor != null) item(key = "more") { + LaunchedEffect(state.items.size) { vm.loadMore() } + } } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffDigitalSection.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffDigitalSection.kt index a0475ec..1c80ff2 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffDigitalSection.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffDigitalSection.kt @@ -44,6 +44,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext /** The digital kinds of the Digital Library plugin. */ enum class DigitalKind(val wire: String, val mimes: Array) { @@ -72,18 +74,22 @@ class StaffDigitalViewModel @Inject constructor( _state.update { it.copy(uploading = kind) } viewModelScope.launch { val resolver = context.contentResolver - var name = "${kind.wire}.bin" - var size = -1L - runCatching { - resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> - if (c.moveToFirst()) { - c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { i -> c.getString(i)?.let { name = it } } - c.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }?.let { i -> if (!c.isNull(i)) size = c.getLong(i) } + // Provider lookups can block (cloud providers such as Drive): off the main thread. + val (name, size, mime) = withContext(Dispatchers.IO) { + var name = "${kind.wire}.bin" + var size = -1L + runCatching { + resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> + if (c.moveToFirst()) { + c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let { i -> c.getString(i)?.let { name = it } } + c.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }?.let { i -> if (!c.isNull(i)) size = c.getLong(i) } + } } } + Triple(name, size, runCatching { resolver.getType(uri) }.getOrNull()) } - val res = content.uploadDigital(bookId, kind.wire, name, resolver.getType(uri), size) { - resolver.openInputStream(uri) ?: error("unreadable") + val res = content.uploadDigital(bookId, kind.wire, name, mime, size) { + resolver.openInputStream(uri) ?: throw java.io.IOException("No stream for $uri") } _state.update { when (res) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt index 57e356f..cd74dfe 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffImportsScreens.kt @@ -346,6 +346,8 @@ data class ImportJobUiState( val error: StaffMessage? = null, val forbidden: Boolean = false, val cancelled: Boolean = false, + /** The server answered 410 `gone`: the job ended elsewhere (finished, cancelled, expired). */ + val ended: Boolean = false, ) /** @@ -378,7 +380,7 @@ class StaffImportJobViewModel @Inject constructor( fun resume() { val job = _state.value.job ?: return - if (loop?.isActive == true || ImportProgress.isFinished(job)) return + if (loop?.isActive == true || ImportProgress.isFinished(job) || _state.value.ended) return _state.update { it.copy(running = true, error = null) } loop = viewModelScope.launch { var step: ChunkStep = ImportProgress.next(job) @@ -390,6 +392,9 @@ class StaffImportJobViewModel @Inject constructor( ChunkStep.Done -> break is ChunkStep.Stop -> { _state.update { it.copy(forbidden = s.failure.isForbidden(), error = s.failure.toStaffMessage(R.string.staff_import_failed)) } + // The job ended elsewhere (410 gone: finished, cancelled or expired on another + // device): read its real final state, so Resume/Cancel go away. + if (s.failure.code == "gone" || s.failure.httpStatus == 410) refreshAfterGone() break } } @@ -411,6 +416,12 @@ class StaffImportJobViewModel @Inject constructor( } } + private suspend fun refreshAfterGone() { + // Whatever the server still says about it, a gone job is never resumable here. + _state.update { it.copy(ended = true) } + (content.importJob(jobId) as? ApiResult.Success)?.let { res -> _state.update { it.copy(job = res.data) } } + } + fun pause() { loop?.cancel() _state.update { it.copy(running = false) } @@ -445,12 +456,13 @@ fun StaffImportJobScreen(onNavigateUp: () -> Unit) { else -> LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(Spacing.lg)) { item(key = "progress") { Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { - val finished = ImportProgress.isFinished(job) + val finished = ImportProgress.isFinished(job) || state.ended Text( stringResource( when { job.status == "cancelled" -> R.string.staff_import_status_cancelled job.status == "failed" -> R.string.staff_import_status_failed + state.ended && !ImportProgress.isFinished(job) -> R.string.staff_import_status_gone finished -> R.string.staff_import_status_done state.running -> R.string.staff_import_status_running else -> R.string.staff_import_status_paused diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt index 4608a10..89ae5ae 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffMergeScreen.kt @@ -92,6 +92,7 @@ data class MergeUiState( val tab: MergeTab = MergeTab.AUTHORS, val duplicates: List> = emptyList(), val duplicatesLoading: Boolean = true, + val duplicatesError: StaffMessage? = null, val selection: MergeSelection = MergeSelection(), val picking: Boolean = false, val confirming: Boolean = false, @@ -129,13 +130,14 @@ class StaffMergeViewModel @Inject constructor( init { loadDuplicates() } fun loadDuplicates() { - _state.update { it.copy(duplicatesLoading = true) } + _state.update { it.copy(duplicatesLoading = true, duplicatesError = null) } viewModelScope.launch { val res = content.authorDuplicates() _state.update { when (res) { is ApiResult.Success -> it.copy(duplicates = res.data.filter { g -> g.size >= 2 }, duplicatesLoading = false) - is ApiResult.Failure -> it.copy(duplicatesLoading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + // Its own field: a failed suggestion list is not a failed merge. + is ApiResult.Failure -> it.copy(duplicatesLoading = false, forbidden = res.isForbidden(), duplicatesError = res.toStaffMessage(R.string.staff_error_load)) } } } @@ -174,7 +176,7 @@ class StaffMergeViewModel @Inject constructor( fun remove(item: PickItem) = toggle(item) fun setPrimary(id: Int) = _state.update { it.copy(selection = it.selection.copy(primaryId = id)) } fun setNewName(value: String) = _state.update { it.copy(selection = it.selection.copy(newName = value)) } - fun openConfirm() = _state.update { it.copy(confirming = it.selection.ready, picking = false) } + fun openConfirm() = _state.update { it.copy(confirming = it.selection.ready, picking = false, error = null) } fun closeConfirm() = _state.update { it.copy(confirming = false, error = null) } fun merge() { @@ -299,7 +301,10 @@ private fun RecordMerge(state: MergeUiState, vm: StaffMergeViewModel) { } if (state.tab == MergeTab.AUTHORS) { item(key = "dup-title") { FormSectionTitle(stringResource(R.string.staff_merge_suggested)) } - if (!state.duplicatesLoading && state.duplicates.isEmpty()) item(key = "dup-empty") { + state.duplicatesError?.let { err -> + item(key = "dup-error") { InlineErrorBanner(message = err.resolve(), onRetry = vm::loadDuplicates) } + } + if (!state.duplicatesLoading && state.duplicatesError == null && state.duplicates.isEmpty()) item(key = "dup-empty") { Text(stringResource(R.string.staff_merge_no_duplicates), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) } itemsIndexed(state.duplicates, key = { i, g -> "g$i-${g.first().id}" }) { index, group -> diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffSeriesScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffSeriesScreens.kt index 2c05957..c1c6a65 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffSeriesScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/content/StaffSeriesScreens.kt @@ -62,7 +62,6 @@ import com.pinakes.app.R import com.pinakes.app.data.model.IdName import com.pinakes.app.data.model.LookupOption import com.pinakes.app.data.model.SeriesBook -import com.pinakes.app.data.model.SeriesWrite import com.pinakes.app.data.model.StaffLookups import com.pinakes.app.data.model.StaffSeries import com.pinakes.app.data.model.StaffSeriesDetail @@ -127,11 +126,20 @@ data class SeriesForm( val cycleOrderValid: Boolean get() = cycleOrder.isBlank() || cycleOrder.trim().toIntOrNull()?.let { it in 0..65535 } == true val valid: Boolean get() = name.isNotBlank() && name.trim().length <= 100 && cycleOrderValid - /** Cleared optional fields go as "" so an edit can empty them (absent keys are kept). */ - fun toWrite(): SeriesWrite = SeriesWrite( - name = name.trim(), type = type.ifBlank { null }, parentName = parentName.trim(), group = group.trim(), cycle = cycle.trim(), - cycleOrder = cycleOrder.trim().toIntOrNull(), description = description.trim(), complete = complete, - ) + /** + * Every key is sent so an edit can empty a field: cleared text goes as "", a cleared order + * as JSON null (an absent key would keep the stored value on the server). + */ + fun toJson(): kotlinx.serialization.json.JsonObject = kotlinx.serialization.json.buildJsonObject { + put("name", kotlinx.serialization.json.JsonPrimitive(name.trim())) + put("type", type.ifBlank { null }?.let { kotlinx.serialization.json.JsonPrimitive(it) } ?: kotlinx.serialization.json.JsonNull) + put("parent_name", kotlinx.serialization.json.JsonPrimitive(parentName.trim())) + put("group", kotlinx.serialization.json.JsonPrimitive(group.trim())) + put("cycle", kotlinx.serialization.json.JsonPrimitive(cycle.trim())) + put("cycle_order", cycleOrder.trim().toIntOrNull()?.let { kotlinx.serialization.json.JsonPrimitive(it) } ?: kotlinx.serialization.json.JsonNull) + put("description", kotlinx.serialization.json.JsonPrimitive(description.trim())) + put("complete", kotlinx.serialization.json.JsonPrimitive(complete)) + } companion object { fun from(d: StaffSeriesDetail) = SeriesForm( @@ -206,7 +214,7 @@ class StaffSeriesViewModel @Inject constructor( if (!form.valid || _state.value.saving) return _state.update { it.copy(saving = true) } viewModelScope.launch { - val res = content.saveSeries(null, form.toWrite()) + val res = content.saveSeries(null, form.toJson()) _state.update { when (res) { is ApiResult.Success -> it.copy(saving = false, creating = null, createdId = res.data.id) @@ -440,7 +448,7 @@ class StaffSeriesDetailViewModel @Inject constructor( fun saveEdit() { val form = _state.value.form ?: return if (!form.valid) return - mutate(R.string.staff_series_saved) { content.saveSeries(seriesId, form.toWrite()) } + mutate(R.string.staff_series_saved) { content.saveSeries(seriesId, form.toJson()) } } fun rename(name: String, merge: Boolean = false) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffArchivesScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffArchivesScreens.kt index da586ab..97d7b1c 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffArchivesScreens.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffArchivesScreens.kt @@ -376,12 +376,26 @@ class StaffArchiveEditViewModel @Inject constructor( fun uploadDocument(uri: Uri?) { val id = _state.value.id if (uri == null || id <= 0) return - upload({ - withContext(Dispatchers.IO) { readDocument(uri) }?.let { (name, mime, bytes) -> plugins.uploadArchiveDocument(id, name, bytes, mime) } - }, R.string.staff_archive_document_uploaded) + viewModelScope.launch { + // Provider lookups can block (cloud providers): off the main thread. + val meta = withContext(Dispatchers.IO) { documentMeta(uri) } + if (meta != null && meta.third > MAX_DOCUMENT_BYTES) { + _state.update { it.copy(message = StaffMessage(R.string.staff_archive_document_too_big)) } + return@launch + } + upload({ + meta?.let { (name, mime, size) -> + // Streamed from the provider: a large scan or video never sits in memory. + plugins.uploadArchiveDocument(id, name, mime, size) { + context.contentResolver.openInputStream(uri) ?: throw java.io.IOException("No stream for $uri") + } + } + }, R.string.staff_archive_document_uploaded) + } } - private fun readDocument(uri: Uri): Triple? = runCatching { + /** Display name, type and size (-1 when the provider does not say) of a picked document. */ + private fun documentMeta(uri: Uri): Triple? = runCatching { val resolver = context.contentResolver var name = "document" var size = -1L @@ -391,9 +405,7 @@ class StaffArchiveEditViewModel @Inject constructor( c.getColumnIndex(OpenableColumns.SIZE).takeIf { it >= 0 }?.let { i -> if (!c.isNull(i)) size = c.getLong(i) } } } - if (size > MAX_DOCUMENT_BYTES) return null - val bytes = resolver.openInputStream(uri)?.use { it.readBytes() } ?: return null - Triple(name, resolver.getType(uri), bytes) + Triple(name, resolver.getType(uri), size) }.getOrNull() fun removeDocument(fileId: Int) { @@ -631,6 +643,7 @@ private fun ArchiveForm( data class AuthoritiesUiState( val query: String = "", val items: List = emptyList(), + val nextCursor: String? = null, val loading: Boolean = true, val error: StaffMessage? = null, val forbidden: Boolean = false, @@ -649,7 +662,7 @@ class StaffAuthoritiesViewModel @Inject constructor(private val plugins: StaffPl val res = plugins.authorities(_state.value.query) _state.update { when (res) { - is ApiResult.Success -> it.copy(items = res.data.items, loading = false, error = null) + is ApiResult.Success -> it.copy(items = res.data.items, nextCursor = res.data.nextCursor, loading = false, error = null) is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) } } @@ -657,6 +670,18 @@ class StaffAuthoritiesViewModel @Inject constructor(private val plugins: StaffPl } fun onQuery(value: String) { _state.update { it.copy(query = value) }; reload() } + + fun loadMore() { + val s = _state.value + val cursor = s.nextCursor ?: return + if (job?.isActive == true) return + job = viewModelScope.launch { + val res = plugins.authorities(s.query, cursor = cursor) + if (res is ApiResult.Success && _state.value.query == s.query) { + _state.update { it.copy(items = (it.items + res.data.items).distinctBy(AuthorityRow::id), nextCursor = res.data.nextCursor) } + } + } + } } @OptIn(ExperimentalMaterial3Api::class) @@ -690,6 +715,9 @@ fun StaffAuthoritiesScreen(onNavigateUp: () -> Unit, onOpen: (Int) -> Unit) { StaffActionRow(Icons.Outlined.Person, a.name, listOfNotNull(a.type, a.dates, stringResource(R.string.staff_authority_units, a.unitsCount)).joinToString(" · ")) { onOpen(a.id) } } + if (state.nextCursor != null) item(key = "more") { + LaunchedEffect(state.items.size) { vm.loadMore() } + } } } } @@ -708,7 +736,13 @@ data class AuthorityEditUiState( val confirmDelete: Boolean = false, val done: Boolean = false, val forbidden: Boolean = false, -) + val loadError: StaffMessage? = null, + /** An existing record was read from the server (a new one needs no read). */ + val loaded: Boolean = false, +) { + /** A new authority, or an existing one that was actually loaded. */ + val editable: Boolean get() = id <= 0 || loaded +} @HiltViewModel class StaffAuthorityEditViewModel @Inject constructor( @@ -720,22 +754,36 @@ class StaffAuthorityEditViewModel @Inject constructor( init { viewModelScope.launch { (plugins.archiveLookups() as? ApiResult.Success)?.data?.let { l -> _state.update { it.copy(lookups = l) } } } + load() + } + + /** + * Read the record. Until an existing authority is loaded the form is a placeholder: it is + * neither shown nor savable (a PUT of blank fields would wipe the record). + */ + fun load() { val id = _state.value.id - if (id <= 0) _state.update { it.copy(loading = false) } else viewModelScope.launch { + if (id <= 0) { + _state.update { it.copy(loading = false) } + return + } + _state.update { it.copy(loading = true, loadError = null) } + viewModelScope.launch { val res = plugins.authority(id) _state.update { when (res) { - is ApiResult.Success -> it.copy(form = AuthorityForm.from(res.data), units = res.data.units, loading = false) - is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + is ApiResult.Success -> it.copy(form = AuthorityForm.from(res.data), units = res.data.units, loading = false, loaded = true) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), loadError = res.toStaffMessage(R.string.staff_error_load)) } } } } - fun edit(key: String, value: String) = _state.update { it.copy(form = it.form.with(key, value), error = null) } + fun edit(key: String, value: String) = _state.update { if (!it.editable) it else it.copy(form = it.form.with(key, value), error = null) } fun save() { val s = _state.value + if (!s.editable) return if (!s.form.valid) { _state.update { it.copy(showErrors = true) }; return } if (s.saving) return _state.update { it.copy(saving = true, error = null) } @@ -784,6 +832,10 @@ fun StaffAuthorityEditScreen(onNavigateUp: () -> Unit) { }, ) { padding -> if (state.loading) { LoadingState(modifier = Modifier.padding(padding)); return@Scaffold } + if (!state.editable) { + ErrorState(message = state.loadError?.resolve() ?: stringResource(R.string.staff_error_load), onRetry = vm::load, modifier = Modifier.padding(padding)) + return@Scaffold + } val f = state.form Column(Modifier.fillMaxSize().padding(padding).verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).imePadding(), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffDesiderataScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffDesiderataScreen.kt index b247558..f712222 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffDesiderataScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/plugins/StaffDesiderataScreen.kt @@ -99,6 +99,8 @@ data class DesiderataUiState( val query: String = "", val offers: List = emptyList(), val wanted: List = emptyList(), + val nextCursor: String? = null, + val loadingMore: Boolean = false, val loading: Boolean = true, val refreshing: Boolean = false, val error: StaffMessage? = null, @@ -139,7 +141,7 @@ class StaffDesiderataViewModel @Inject constructor( if (gen != generation) return@launch _state.update { when (res) { - is ApiResult.Success -> it.copy(wanted = res.data.items, loading = false, refreshing = false) + is ApiResult.Success -> it.copy(wanted = res.data.items, nextCursor = res.data.nextCursor, loading = false, refreshing = false) is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) } } @@ -148,7 +150,7 @@ class StaffDesiderataViewModel @Inject constructor( if (gen != generation) return@launch _state.update { when (res) { - is ApiResult.Success -> it.copy(offers = res.data.items, loading = false, refreshing = false) + is ApiResult.Success -> it.copy(offers = res.data.items, nextCursor = res.data.nextCursor, loading = false, refreshing = false) is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) } } @@ -161,9 +163,41 @@ class StaffDesiderataViewModel @Inject constructor( reload() } + /** The next page of the current tab (offers or wanted titles). */ + fun loadMore() { + val s = _state.value + val cursor = s.nextCursor ?: return + if (s.loadingMore || s.loading) return + val gen = generation + _state.update { it.copy(loadingMore = true) } + viewModelScope.launch { + if (s.tab == DesiderataTab.WANTED) { + val res = plugins.wanted(s.query, cursor) + _state.update { + when { + gen != generation -> it.copy(loadingMore = false) + res is ApiResult.Success -> it.copy(wanted = (it.wanted + res.data.items).distinctBy(StaffWantedBook::id), nextCursor = res.data.nextCursor, loadingMore = false) + res is ApiResult.Failure -> it.copy(loadingMore = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_load)) + else -> it.copy(loadingMore = false) + } + } + } else { + val res = plugins.offers(s.tab.wire, s.query, cursor) + _state.update { + when { + gen != generation -> it.copy(loadingMore = false) + res is ApiResult.Success -> it.copy(offers = (it.offers + res.data.items).distinctBy(StaffOffer::id), nextCursor = res.data.nextCursor, loadingMore = false) + res is ApiResult.Failure -> it.copy(loadingMore = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_load)) + else -> it.copy(loadingMore = false) + } + } + } + } + } + fun setTab(tab: DesiderataTab) { if (tab == _state.value.tab) return - _state.update { it.copy(tab = tab, offers = emptyList(), wanted = emptyList()) } + _state.update { it.copy(tab = tab, offers = emptyList(), wanted = emptyList(), nextCursor = null) } reload() } @@ -324,6 +358,9 @@ fun StaffDesiderataScreen(onNavigateUp: () -> Unit, onOpenBook: (Int) -> Unit) { } } } + if (state.nextCursor != null) item(key = "more") { + LaunchedEffect(if (wantedTab) state.wanted.size else state.offers.size) { vm.loadMore() } + } } } } diff --git a/app/src/test/java/com/pinakes/app/staff/SeriesTreeTest.kt b/app/src/test/java/com/pinakes/app/staff/SeriesTreeTest.kt index 0fc1c15..e8a4cb9 100644 --- a/app/src/test/java/com/pinakes/app/staff/SeriesTreeTest.kt +++ b/app/src/test/java/com/pinakes/app/staff/SeriesTreeTest.kt @@ -4,6 +4,10 @@ import com.pinakes.app.data.model.StaffSeries import com.pinakes.app.data.staff.SeriesTree import com.pinakes.app.ui.screens.staff.content.LabelForm import com.pinakes.app.ui.screens.staff.content.SeriesForm +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonPrimitive import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -51,11 +55,15 @@ class SeriesTreeTest { assertFalse(SeriesForm(name = " ").valid) assertFalse(SeriesForm(name = "x".repeat(101)).valid) assertFalse(SeriesForm(name = "A", cycleOrder = "70000").valid) - val write = SeriesForm(name = " Discworld ", cycleOrder = "3", parentName = "", complete = true).toWrite() - assertEquals("Discworld", write.name) - assertEquals(3, write.cycleOrder) - assertEquals("", write.parentName) - assertEquals(true, write.complete) + val body = SeriesForm(name = " Discworld ", cycleOrder = "3", parentName = "", complete = true).toJson() + assertEquals("Discworld", body["name"]!!.jsonPrimitive.content) + assertEquals(3, body["cycle_order"]!!.jsonPrimitive.int) + assertEquals("", body["parent_name"]!!.jsonPrimitive.content) + assertEquals(true, body["complete"]!!.jsonPrimitive.boolean) + // A cleared order is sent as an explicit null, so the server clears it instead of keeping it. + val cleared = SeriesForm(name = "Discworld", cycleOrder = "").toJson() + assertTrue(cleared.containsKey("cycle_order")) + assertEquals(JsonNull, cleared["cycle_order"]) } @Test fun labelFormRanges() { From b8db4c6f04819b518c74523626cf9a1aec5f7e14 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 04:08:02 +0200 Subject: [PATCH 46/62] Manage periodicals, book clubs and FRBR works from the staff area The Tier 2 bridges get native screens, each shown only with its health flag. - Periodicals desk: an arriving issue is scanned (EAN-13) or typed and looked up, then received or claimed; mastheads open on their years, whose issues show their state with receive or claim, and take new years, new issues and subscriptions (supplier, cost, dates, renewal). - Book clubs: clubs are listed and created; a club's settings, its reading workflow (states with label, colour and voting, reading-now and archived marks, reordered, added and removed) and its members (role, membership state, adding a borrower) are edited, and a club can be deleted. - FRBR works: works are searched, created, edited and deleted, take expressions (translations, revisions, critical editions...) and have books linked or unlinked. The scanner viewfinder with its camera permission handling is now shared by the copy scanner and the periodicals desk. --- .../assets/fixtures/book_work_link.json | 12 + .../assets/fixtures/club_1132.json | 57 ++ .../fixtures/club_member_5_moderator.json | 13 + .../assets/fixtures/clubs_list.json | 22 + .../assets/fixtures/expression_created.json | 13 + .../assets/fixtures/health_staff_tier2.json | 42 ++ .../assets/fixtures/issue_4_received.json | 16 + .../assets/fixtures/periodical_16.json | 22 + .../assets/fixtures/periodicals_list.json | 18 + .../assets/fixtures/scan_issue.json | 20 + .../assets/fixtures/subscriptions_empty.json | 5 + .../androidTest/assets/fixtures/work_2.json | 21 + .../assets/fixtures/works_list.json | 28 + .../assets/fixtures/year_2_issues.json | 18 + .../assets/fixtures/year_created.json | 11 + .../com/pinakes/app/staffe2e/Tier2FlowTest.kt | 135 ++++ .../app/data/model/StaffTier2Models.kt | 194 ++++++ .../pinakes/app/data/network/NetworkModule.kt | 11 + .../pinakes/app/data/network/StaffTier2Api.kt | 114 ++++ .../data/repository/StaffTier2Repository.kt | 92 +++ .../com/pinakes/app/data/staff/StaffAreas.kt | 2 +- .../com/pinakes/app/data/staff/Tier2Forms.kt | 225 +++++++ .../main/java/com/pinakes/app/di/AppModule.kt | 4 + .../app/ui/navigation/PinakesNavHost.kt | 39 ++ .../com/pinakes/app/ui/navigation/Routes.kt | 13 + .../app/ui/screens/staff/StaffHubScreen.kt | 10 + .../app/ui/screens/staff/scan/ScannerBox.kt | 99 +++ .../screens/staff/scan/StaffCopyScanScreen.kt | 45 +- .../staff/tier2/StaffBookClubScreens.kt | 504 +++++++++++++++ .../staff/tier2/StaffPeriodicalsScreens.kt | 597 ++++++++++++++++++ .../screens/staff/tier2/StaffWorksScreens.kt | 483 ++++++++++++++ .../com/pinakes/app/staff/Tier2FormsTest.kt | 114 ++++ i18n/de.json | 136 +++- i18n/en.json | 136 +++- i18n/fr.json | 136 +++- i18n/it.json | 136 +++- 36 files changed, 3497 insertions(+), 46 deletions(-) create mode 100644 app/src/androidTest/assets/fixtures/book_work_link.json create mode 100644 app/src/androidTest/assets/fixtures/club_1132.json create mode 100644 app/src/androidTest/assets/fixtures/club_member_5_moderator.json create mode 100644 app/src/androidTest/assets/fixtures/clubs_list.json create mode 100644 app/src/androidTest/assets/fixtures/expression_created.json create mode 100644 app/src/androidTest/assets/fixtures/health_staff_tier2.json create mode 100644 app/src/androidTest/assets/fixtures/issue_4_received.json create mode 100644 app/src/androidTest/assets/fixtures/periodical_16.json create mode 100644 app/src/androidTest/assets/fixtures/periodicals_list.json create mode 100644 app/src/androidTest/assets/fixtures/scan_issue.json create mode 100644 app/src/androidTest/assets/fixtures/subscriptions_empty.json create mode 100644 app/src/androidTest/assets/fixtures/work_2.json create mode 100644 app/src/androidTest/assets/fixtures/works_list.json create mode 100644 app/src/androidTest/assets/fixtures/year_2_issues.json create mode 100644 app/src/androidTest/assets/fixtures/year_created.json create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/Tier2FlowTest.kt create mode 100644 app/src/main/java/com/pinakes/app/data/model/StaffTier2Models.kt create mode 100644 app/src/main/java/com/pinakes/app/data/network/StaffTier2Api.kt create mode 100644 app/src/main/java/com/pinakes/app/data/repository/StaffTier2Repository.kt create mode 100644 app/src/main/java/com/pinakes/app/data/staff/Tier2Forms.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/scan/ScannerBox.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffBookClubScreens.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffPeriodicalsScreens.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffWorksScreens.kt create mode 100644 app/src/test/java/com/pinakes/app/staff/Tier2FormsTest.kt diff --git a/app/src/androidTest/assets/fixtures/book_work_link.json b/app/src/androidTest/assets/fixtures/book_work_link.json new file mode 100644 index 0000000..afd3831 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/book_work_link.json @@ -0,0 +1,12 @@ +{ + "data": { + "book_id": 5, + "work": { + "id": 2, + "uniform_title": "Odissea" + }, + "expression_id": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/club_1132.json b/app/src/androidTest/assets/fixtures/club_1132.json new file mode 100644 index 0000000..64b9d26 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/club_1132.json @@ -0,0 +1,57 @@ +{ + "data": { + "id": 1132, + "slug": "lettori", + "name": "Lettori del giovedì", + "description": "", + "rules": "", + "color": "#4f46e5", + "privacy": "public", + "max_members": null, + "active": true, + "moderate_proposals": false, + "max_proposals_per_member": null, + "modules": [], + "member_count": 1, + "created_at": "2026-10-13 17:21:02", + "workflow": [ + { + "key": "proposed", + "label": "Proposto", + "color": "#6b7280", + "flags": { + "votable": true + } + }, + { + "key": "voting", + "label": "In votazione", + "color": "#8b5cf6", + "flags": { + "voting": true + } + }, + { + "key": "reading", + "label": "In lettura", + "color": "#10b981", + "flags": { + "current": true + } + } + ], + "members": [ + { + "id": 5, + "user_id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "role": "member", + "role_name": "Membro", + "status": "active" + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/club_member_5_moderator.json b/app/src/androidTest/assets/fixtures/club_member_5_moderator.json new file mode 100644 index 0000000..270f909 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/club_member_5_moderator.json @@ -0,0 +1,13 @@ +{ + "data": { + "id": 5, + "user_id": 3, + "name": "Ada Rossi", + "email": "ada@example.org", + "role": "moderator", + "role_name": "Moderatore", + "status": "active" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/clubs_list.json b/app/src/androidTest/assets/fixtures/clubs_list.json new file mode 100644 index 0000000..6d4ed18 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/clubs_list.json @@ -0,0 +1,22 @@ +{ + "data": [ + { + "id": 1132, + "slug": "lettori", + "name": "Lettori del giovedì", + "description": "", + "rules": "", + "color": "#4f46e5", + "privacy": "public", + "max_members": null, + "active": true, + "moderate_proposals": false, + "max_proposals_per_member": null, + "modules": [], + "member_count": 1, + "created_at": "2026-10-13 17:21:02" + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/expression_created.json b/app/src/androidTest/assets/fixtures/expression_created.json new file mode 100644 index 0000000..7650749 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/expression_created.json @@ -0,0 +1,13 @@ +{ + "data": { + "id": 3, + "work_id": 2, + "type": "testo", + "language": "ita", + "title": "Odissea (trad. Calzecchi Onesti)", + "year": 1963, + "notes": null + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/health_staff_tier2.json b/app/src/androidTest/assets/fixtures/health_staff_tier2.json new file mode 100644 index 0000000..0567d60 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/health_staff_tier2.json @@ -0,0 +1,42 @@ +{ + "data": { + "name": "Test Library", + "version": "0.8.5", + "api_version": "v1", + "features": { + "catalog": true, + "loans": true, + "reservations": true, + "wishlist": true, + "messages": true, + "notifications": true, + "push": false, + "reviews": false, + "archives": false, + "desiderata": false, + "staff_catalog": true, + "catalog_sync": true, + "calendar_feed": true, + "staff_circulation": true, + "staff_copies": true, + "staff_series": true, + "staff_merges": true, + "staff_labels": true, + "staff_imports": true, + "staff_covers": true, + "staff_desiderata": true, + "staff_archives": true, + "staff_periodicals": true, + "staff_bookclub": true, + "staff_frbr": true, + "staff_digital_library": false, + "staff_librarything": false + }, + "catalogue_mode": false, + "loan_approval_required": true, + "app_access_enabled": true, + "registration_enabled": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/issue_4_received.json b/app/src/androidTest/assets/fixtures/issue_4_received.json new file mode 100644 index 0000000..2b3cfc3 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/issue_4_received.json @@ -0,0 +1,16 @@ +{ + "data": { + "id": 4, + "periodical_id": 16, + "periodical": "Internazionale", + "year_id": 2, + "year": 2026, + "number": "12", + "date": "2026-10-09", + "status": "posseduto", + "claimed_at": null, + "claims": 0 + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/periodical_16.json b/app/src/androidTest/assets/fixtures/periodical_16.json new file mode 100644 index 0000000..86fdca0 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/periodical_16.json @@ -0,0 +1,22 @@ +{ + "data": { + "id": 16, + "title": "Internazionale", + "issn": "1122-2832", + "type": "rivista", + "frequency": "settimanale", + "holdings": "1993-", + "years": [ + { + "id": 2, + "year": 2026, + "volume": "33", + "bound": false, + "issues_count": 2, + "owned_count": 1 + } + ] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/periodicals_list.json b/app/src/androidTest/assets/fixtures/periodicals_list.json new file mode 100644 index 0000000..9830c7e --- /dev/null +++ b/app/src/androidTest/assets/fixtures/periodicals_list.json @@ -0,0 +1,18 @@ +{ + "data": [ + { + "id": 16, + "title": "Internazionale", + "issn": "1122-2832", + "type": "rivista", + "frequency": "settimanale", + "years_count": 2, + "issues_count": 14 + } + ], + "meta": { + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/scan_issue.json b/app/src/androidTest/assets/fixtures/scan_issue.json new file mode 100644 index 0000000..0531700 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/scan_issue.json @@ -0,0 +1,20 @@ +{ + "data": { + "found": true, + "match": "issue", + "action": "receive", + "issue": { + "id": 4, + "numero": "12", + "stato": "atteso", + "annata_id": 2, + "anno": 2026, + "testata_id": 16, + "titolo": "Internazionale", + "url": "x" + }, + "message": "Fascicolo n. 12 in attesa" + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/subscriptions_empty.json b/app/src/androidTest/assets/fixtures/subscriptions_empty.json new file mode 100644 index 0000000..86d8eb6 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/subscriptions_empty.json @@ -0,0 +1,5 @@ +{ + "data": [], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/work_2.json b/app/src/androidTest/assets/fixtures/work_2.json new file mode 100644 index 0000000..f18e235 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/work_2.json @@ -0,0 +1,21 @@ +{ + "data": { + "id": 2, + "uniform_title": "Odissea", + "original_title": "Ὀδύσσεια", + "main_author_id": null, + "main_author_name": null, + "created_from": -725, + "created_to": null, + "original_language": "grc", + "viaf_work_id": null, + "wikidata_id": null, + "notes": null, + "slug": "odissea", + "editions_count": 0, + "editions": [], + "expressions": [] + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/works_list.json b/app/src/androidTest/assets/fixtures/works_list.json new file mode 100644 index 0000000..57086b3 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/works_list.json @@ -0,0 +1,28 @@ +{ + "data": [ + { + "id": 2, + "uniform_title": "Odissea", + "original_title": "Ὀδύσσεια", + "main_author_id": null, + "main_author_name": null, + "created_from": -725, + "created_to": null, + "original_language": "grc", + "viaf_work_id": null, + "wikidata_id": null, + "notes": null, + "slug": "odissea", + "editions_count": 0, + "editions": [], + "expressions": [] + } + ], + "meta": { + "count": 1, + "limit": 100, + "next_cursor": null, + "has_more": false + }, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/year_2_issues.json b/app/src/androidTest/assets/fixtures/year_2_issues.json new file mode 100644 index 0000000..184ecf5 --- /dev/null +++ b/app/src/androidTest/assets/fixtures/year_2_issues.json @@ -0,0 +1,18 @@ +{ + "data": [ + { + "id": 4, + "number": "12", + "status": "atteso", + "publication_date": "2026-10-09" + }, + { + "id": 5, + "number": "11", + "status": "posseduto", + "publication_date": "2026-10-02" + } + ], + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/assets/fixtures/year_created.json b/app/src/androidTest/assets/fixtures/year_created.json new file mode 100644 index 0000000..21142bb --- /dev/null +++ b/app/src/androidTest/assets/fixtures/year_created.json @@ -0,0 +1,11 @@ +{ + "data": { + "id": 9, + "periodical_id": 16, + "year": 2027, + "volume": "34", + "bound": false + }, + "meta": {}, + "error": null +} diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/Tier2FlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/Tier2FlowTest.kt new file mode 100644 index 0000000..c8958ca --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/Tier2FlowTest.kt @@ -0,0 +1,135 @@ +package com.pinakes.app.staffe2e + +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.onFirst +import androidx.compose.ui.test.performTextReplacement +import com.pinakes.app.R +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Tier 2 bridges: periodicals desk, book club management and FRBR works, each behind its own flag. */ +class Tier2FlowTest : AppFlowTest() { + + private fun tier2(configure: FakePinakes.() -> Unit) = launch(health = "health_staff_tier2", configure = configure) + + @Test fun withoutTheirFlagsTheTier2RowsAreHidden() { + launch() + openStaffHub() + waitText(text(R.string.staff_books)) + assertFalse(exists(text(R.string.staff_periodicals))) + assertFalse(exists(text(R.string.staff_clubs))) + assertFalse(exists(text(R.string.staff_works))) + } + + @Test fun aTypedIssueCodeIsReceived() { + tier2 { + json("GET periodicals", "periodicals_list") + json("GET periodicals/staff/scan-lookup", "scan_issue") + json("POST periodicals/staff/issues/4/receive", "issue_4_received") + } + openStaffHub() + click(text(R.string.staff_periodicals)) + denyCameraPromptIfShown() + typeInto(text(R.string.staff_periodicals_code), "977112228320612") + clickDescription(text(R.string.staff_scan_lookup)) + waitRequest("GET", "periodicals/staff/scan-lookup") + assertEquals("977112228320612", api.recorded("GET", "periodicals/staff/scan-lookup").last().requestUrl?.queryParameter("code")) + clickTag("issue-receive") + waitRequest("POST", "periodicals/staff/issues/4/receive") + waitText(text(R.string.staff_issue_received, "12", "Internazionale")) + } + + @Test fun aMastheadShowsItsIssuesAndTakesANewYear() { + tier2 { + json("GET periodicals", "periodicals_list") + json("GET periodicals/16", "periodical_16") + json("GET periodicals/years/2/issues", "year_2_issues") + json("GET periodicals/staff/subscriptions", "subscriptions_empty") + json("POST periodicals/staff/16/years", "year_created", status = 201) + } + openStaffHub() + click(text(R.string.staff_periodicals)) + denyCameraPromptIfShown() + scrollListTo("Internazionale") + click("Internazionale") + clickTag("year-2") + waitText(text(R.string.staff_issue_number, "12")) + assertTrue(exists(text(R.string.staff_issue_receive))) + clickDescription(text(R.string.staff_year_add)) + compose.onAllNodes(hasSetTextAction() and hasTestTag("year-input")).onFirst().performTextReplacement("2027") + typeInto(text(R.string.staff_year_volume), "34") + clickTag("form-save") + val body = lastBody("POST", "periodicals/staff/16/years") + assertTrue(body, body.contains("\"year\":2027") && body.contains("\"volume\":\"34\"")) + waitText(text(R.string.staff_year_added)) + } + + @Test fun createAClubThenChangeAMemberRole() { + tier2 { + json("GET bookclub/staff/clubs", "clubs_list") + json("POST bookclub/staff/clubs", "club_1132", status = 201) + json("GET bookclub/staff/clubs/1132", "club_1132") + json("PUT bookclub/staff/clubs/1132/members/5", "club_member_5_moderator") + } + openStaffHub() + click(text(R.string.staff_clubs)) + clickDescription(text(R.string.staff_club_new)) + compose.onAllNodes(hasSetTextAction() and hasTestTag("club-name")).onFirst().performTextReplacement("Lettori del giovedì") + clickTag("form-save") + val body = lastBody("POST", "bookclub/staff/clubs") + assertTrue(body, body.contains("\"name\":\"Lettori del giovedì\"") && body.contains("\"privacy\":\"public\"")) + waitText("Ada Rossi") + click("Ada Rossi") + clickTag("role-moderator") + assertEquals("""{"role":"moderator"}""", lastBody("PUT", "bookclub/staff/clubs/1132/members/5")) + waitText(text(R.string.staff_club_member_saved)) + } + + @Test fun editTheClubWorkflow() { + tier2 { + json("GET bookclub/staff/clubs", "clubs_list") + json("GET bookclub/staff/clubs/1132", "club_1132") + json("PUT bookclub/staff/clubs/1132/workflow", "club_1132") + } + openStaffHub() + click(text(R.string.staff_clubs)) + click("Lettori del giovedì") + click(text(R.string.staff_club_workflow_edit)) + click(text(R.string.staff_club_state_add)) + clickTag("form-save") + val body = lastBody("PUT", "bookclub/staff/clubs/1132/workflow") + assertEquals(4, Regex("\"label\"").findAll(body).count()) + assertTrue(body, body.contains("\"voting\":true")) + waitText(text(R.string.staff_club_workflow_saved)) + } + + @Test fun createAWorkAddAnExpressionAndLinkABook() { + tier2 { + json("GET staff/works", "works_list") + json("POST staff/works", "work_2", status = 201) + json("GET staff/works/2", "work_2") + json("POST staff/works/2/expressions", "expression_created", status = 201) + json("POST staff/books/5/work", "book_work_link") + } + openStaffHub() + click(text(R.string.staff_works)) + clickDescription(text(R.string.staff_work_new)) + compose.onAllNodes(hasSetTextAction() and hasTestTag("work-title")).onFirst().performTextReplacement("Odissea") + clickTag("form-save") + assertTrue(lastBody("POST", "staff/works").contains("\"uniform_title\":\"Odissea\"")) + waitText(text(R.string.staff_work_expressions).uppercase()) + click(text(R.string.staff_expression_add)) + compose.onAllNodes(hasSetTextAction() and hasTestTag("expression-title")).onFirst().performTextReplacement("Odissea (trad. Calzecchi Onesti)") + clickTag("form-save") + val expr = lastBody("POST", "staff/works/2/expressions") + assertTrue(expr, expr.contains("\"type\":\"testo\"") && expr.contains("Calzecchi")) + click(text(R.string.staff_work_attach)) + typeIntoTag("picker-search", "Orl") + clickTag("pick-5") + assertEquals("""{"work_id":2}""", lastBody("POST", "staff/books/5/work")) + waitText(text(R.string.staff_work_book_attached)) + } +} diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffTier2Models.kt b/app/src/main/java/com/pinakes/app/data/model/StaffTier2Models.kt new file mode 100644 index 0000000..5c7f3a2 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/StaffTier2Models.kt @@ -0,0 +1,194 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull + +// Mobile API 1.7.0 Tier 2 bridges: the periodicals desk (emeroteca plugin, +// /periodicals/staff), book club management (book-club plugin, /bookclub/staff) and FRBR +// works (frbr-lrm plugin, /staff/works). All answer the core envelope; every field has a default. + +// ---------- Periodicals desk ---------- + +/** `GET /periodicals/staff/scan-lookup`: `match` is `none` | `issue` | `title` | `ambiguous`. */ +@Serializable +data class PeriodicalScan( + val found: Boolean = false, + val match: String = "none", + /** `receive` when the matched issue is expected or claimed. */ + val action: String? = null, + val issue: ScannedIssue? = null, + val title: ScannedTitle? = null, + val matches: Int? = null, + val message: String? = null, +) + +@Serializable +data class ScannedIssue( + val id: Int = 0, + @SerialName("numero") val number: String = "", + @SerialName("stato") val status: String = "", + @SerialName("annata_id") val yearId: Int = 0, + @SerialName("anno") val year: Int = 0, + @SerialName("testata_id") val periodicalId: Int = 0, + @SerialName("titolo") val title: String = "", +) + +@Serializable +data class ScannedTitle(val id: Int = 0, @SerialName("titolo") val title: String = "") + +/** An issue as the desk writes answer it. `status`: posseduto | mancante | atteso | smarrito | reclamato | scartato. */ +@Serializable +data class StaffIssue( + val id: Int = 0, + @SerialName("periodical_id") val periodicalId: Int = 0, + val periodical: String = "", + @SerialName("year_id") val yearId: Int = 0, + val year: Int = 0, + val number: String = "", + val date: String? = null, + val status: String = "", + @SerialName("claimed_at") val claimedAt: String? = null, + val claims: Int = 0, +) + +@Serializable +data class StaffYear( + val id: Int = 0, + @SerialName("periodical_id") val periodicalId: Int = 0, + val year: Int = 0, + val volume: String? = null, + val bound: Boolean = false, +) + +@Serializable +data class IssueWrite(val number: String, val date: String? = null, val status: String? = null) + +@Serializable +data class YearWrite(val year: Int, val volume: String? = null, val bound: Boolean = false) + +@Serializable +data class StaffSubscription( + val id: Int = 0, + @SerialName("periodical_id") val periodicalId: Int = 0, + val periodical: String = "", + val supplier: String = "", + val cost: Double? = null, + val currency: String? = null, + @SerialName("start_date") val startDate: String? = null, + @SerialName("end_date") val endDate: String? = null, + @SerialName("auto_renew") val autoRenew: Boolean = false, + val active: Boolean = true, + val notes: String? = null, +) + +// ---------- Book clubs ---------- + +@Serializable +data class StaffClub( + val id: Int = 0, + val slug: String = "", + val name: String = "", + val description: String = "", + val rules: String = "", + val color: String = "", + /** public | private | invite | hidden */ + val privacy: String = "public", + @SerialName("max_members") val maxMembers: Int? = null, + val active: Boolean = true, + @SerialName("moderate_proposals") val moderateProposals: Boolean = false, + @SerialName("max_proposals_per_member") val maxProposalsPerMember: Int? = null, + val modules: List = emptyList(), + @SerialName("member_count") val memberCount: Int? = null, + @SerialName("created_at") val createdAt: String? = null, + val workflow: List = emptyList(), + val members: List = emptyList(), +) + +/** A workflow state; `flags` is an object of booleans, or an empty JSON array when none is set. */ +@Serializable +data class ClubState( + val key: String = "", + val label: String = "", + val color: String = "#6b7280", + val flags: JsonElement? = null, +) { + fun flag(name: String): Boolean = ((flags as? JsonObject)?.get(name) as? JsonPrimitive)?.booleanOrNull == true +} + +@Serializable +data class ClubMember( + val id: Int = 0, + @SerialName("user_id") val userId: Int = 0, + val name: String = "", + val email: String = "", + /** owner | moderator | member | guest */ + val role: String = "", + @SerialName("role_name") val roleName: String = "", + /** pending | active | suspended | left | banned */ + val status: String = "", +) + +@Serializable +data class AddMemberRequest(@SerialName("user_id") val userId: Int, val role: String) + +@Serializable +data class MemberUpdate(val status: String? = null, val role: String? = null) + +// ---------- FRBR works ---------- + +@Serializable +data class StaffWork( + val id: Int = 0, + @SerialName("uniform_title") val uniformTitle: String? = null, + @SerialName("original_title") val originalTitle: String? = null, + @SerialName("main_author_id") val mainAuthorId: Int? = null, + @SerialName("main_author_name") val mainAuthorName: String? = null, + @SerialName("created_from") val createdFrom: Int? = null, + @SerialName("created_to") val createdTo: Int? = null, + @SerialName("original_language") val originalLanguage: String? = null, + @SerialName("viaf_work_id") val viafWorkId: String? = null, + @SerialName("wikidata_id") val wikidataId: String? = null, + val notes: String? = null, + val slug: String? = null, + @SerialName("editions_count") val editionsCount: Int? = null, + val editions: List = emptyList(), + val expressions: List = emptyList(), +) + +@Serializable +data class WorkEdition( + val id: Int = 0, + val title: String = "", + val year: Int? = null, + val isbn13: String? = null, + @SerialName("expression_id") val expressionId: Int? = null, +) + +/** `type`: testo | traduzione | revisione | adattamento | edizione_critica | audio | altro. */ +@Serializable +data class WorkExpression( + val id: Int = 0, + @SerialName("work_id") val workId: Int = 0, + val type: String? = null, + val language: String? = null, + val title: String? = null, + val year: Int? = null, + @SerialName("translator_id") val translatorId: Int? = null, + @SerialName("curator_id") val curatorId: Int? = null, + @SerialName("reviser_id") val reviserId: Int? = null, + val notes: String? = null, +) + +@Serializable +data class BookWorkLink( + @SerialName("book_id") val bookId: Int = 0, + val work: IdTitleWork? = null, + @SerialName("expression_id") val expressionId: Int? = null, +) + +@Serializable +data class IdTitleWork(val id: Int = 0, @SerialName("uniform_title") val uniformTitle: String = "") diff --git a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt index 2a00cef..2054abc 100644 --- a/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt +++ b/app/src/main/java/com/pinakes/app/data/network/NetworkModule.kt @@ -109,6 +109,8 @@ class NetworkModule(private val session: SessionStore, private val cacheDir: Fil @Volatile private var cachedStaffPluginsApi: StaffPluginsApi? = null @Volatile + private var cachedStaffTier2Api: StaffTier2Api? = null + @Volatile private var cachedLongStaffApi: StaffContentApi? = null @Volatile private var cachedLongBaseUrl: String? = null @@ -151,6 +153,7 @@ class NetworkModule(private val session: SessionStore, private val cacheDir: Fil cachedStaffCirculationApi = null cachedStaffContentApi = null cachedStaffPluginsApi = null + cachedStaffTier2Api = null return retrofit } @@ -219,6 +222,13 @@ class NetworkModule(private val session: SessionStore, private val cacheDir: Fil return cachedStaffPluginsApi ?: retrofit.create(StaffPluginsApi::class.java).also { cachedStaffPluginsApi = it } } + /** Tier 2 staff bridges: periodicals desk, book club management, FRBR works. */ + @Synchronized + fun staffTier2Api(baseUrl: String? = null): StaffTier2Api { + val retrofit = retrofit(baseUrl) + return cachedStaffTier2Api ?: retrofit.create(StaffTier2Api::class.java).also { cachedStaffTier2Api = it } + } + /** * [StaffContentApi] on the long-timeout client. Use it only for `covers/sync`, * `enrich/batch` and import chunks with scraping, which the server runs synchronously. @@ -269,6 +279,7 @@ class NetworkModule(private val session: SessionStore, private val cacheDir: Fil cachedStaffCirculationApi = null cachedStaffContentApi = null cachedStaffPluginsApi = null + cachedStaffTier2Api = null cachedLongStaffApi = null cachedLongBaseUrl = null cachedLongPluginsApi = null diff --git a/app/src/main/java/com/pinakes/app/data/network/StaffTier2Api.kt b/app/src/main/java/com/pinakes/app/data/network/StaffTier2Api.kt new file mode 100644 index 0000000..3da4a17 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/StaffTier2Api.kt @@ -0,0 +1,114 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.AddMemberRequest +import com.pinakes.app.data.model.BookWorkLink +import com.pinakes.app.data.model.ClubMember +import com.pinakes.app.data.model.DeleteResult +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.IssueWrite +import com.pinakes.app.data.model.MemberUpdate +import com.pinakes.app.data.model.PeriodicalScan +import com.pinakes.app.data.model.StaffClub +import com.pinakes.app.data.model.StaffIssue +import com.pinakes.app.data.model.StaffSubscription +import com.pinakes.app.data.model.StaffWork +import com.pinakes.app.data.model.StaffYear +import com.pinakes.app.data.model.WorkExpression +import com.pinakes.app.data.model.YearWrite +import kotlinx.serialization.json.JsonObject +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Tier 2 staff bridges (Mobile API 1.7.0), each mounted only with its plugin: the periodicals + * desk, book club management and FRBR works. Same base URL, token and envelope as [PinakesApi]. + */ +interface StaffTier2Api { + + // ---- periodicals desk (emeroteca) ---- + + @GET("periodicals/staff/scan-lookup") + suspend fun scanLookup(@Query("code") code: String, @Query("periodical_id") periodicalId: Int? = null): Envelope + + @POST("periodicals/staff/issues/{id}/receive") + suspend fun receiveIssue(@Path("id") id: Int): Envelope + + @POST("periodicals/staff/issues/{id}/claim") + suspend fun claimIssue(@Path("id") id: Int): Envelope + + @POST("periodicals/staff/years/{id}/issues") + suspend fun addIssue(@Path("id") yearId: Int, @Body body: IssueWrite): Envelope + + @POST("periodicals/staff/{id}/years") + suspend fun addYear(@Path("id") periodicalId: Int, @Body body: YearWrite): Envelope + + @GET("periodicals/staff/subscriptions") + suspend fun subscriptions(@Query("periodical_id") periodicalId: Int? = null): Envelope> + + @POST("periodicals/staff/subscriptions") + suspend fun createSubscription(@Body body: JsonObject): Envelope + + @PUT("periodicals/staff/subscriptions/{id}") + suspend fun updateSubscription(@Path("id") id: Int, @Body body: JsonObject): Envelope + + // ---- book clubs ---- + + @GET("bookclub/staff/clubs") + suspend fun clubs(): Envelope> + + @GET("bookclub/staff/clubs/{id}") + suspend fun club(@Path("id") id: Int): Envelope + + @POST("bookclub/staff/clubs") + suspend fun createClub(@Body body: JsonObject): Envelope + + @PUT("bookclub/staff/clubs/{id}") + suspend fun updateClub(@Path("id") id: Int, @Body body: JsonObject): Envelope + + @DELETE("bookclub/staff/clubs/{id}") + suspend fun deleteClub(@Path("id") id: Int): Envelope + + @PUT("bookclub/staff/clubs/{id}/workflow") + suspend fun saveWorkflow(@Path("id") id: Int, @Body body: JsonObject): Envelope + + @POST("bookclub/staff/clubs/{id}/members") + suspend fun addMember(@Path("id") id: Int, @Body body: AddMemberRequest): Envelope + + @PUT("bookclub/staff/clubs/{id}/members/{memberId}") + suspend fun updateMember(@Path("id") id: Int, @Path("memberId") memberId: Int, @Body body: MemberUpdate): Envelope + + // ---- FRBR works ---- + + @GET("staff/works") + suspend fun works(@Query("q") q: String? = null, @Query("limit") limit: Int? = null): Envelope> + + @GET("staff/works/{id}") + suspend fun work(@Path("id") id: Int): Envelope + + @POST("staff/works") + suspend fun createWork(@Body body: JsonObject): Envelope + + @PUT("staff/works/{id}") + suspend fun updateWork(@Path("id") id: Int, @Body body: JsonObject): Envelope + + @DELETE("staff/works/{id}") + suspend fun deleteWork(@Path("id") id: Int): Envelope + + @POST("staff/works/{id}/expressions") + suspend fun createExpression(@Path("id") workId: Int, @Body body: JsonObject): Envelope + + @PUT("staff/works/expressions/{id}") + suspend fun updateExpression(@Path("id") id: Int, @Body body: JsonObject): Envelope + + @DELETE("staff/works/expressions/{id}") + suspend fun deleteExpression(@Path("id") id: Int): Envelope + + /** Body `{work_id: int|null, expression_id?: int|null}`; null detaches. */ + @POST("staff/books/{id}/work") + suspend fun attachBook(@Path("id") bookId: Int, @Body body: JsonObject): Envelope +} diff --git a/app/src/main/java/com/pinakes/app/data/repository/StaffTier2Repository.kt b/app/src/main/java/com/pinakes/app/data/repository/StaffTier2Repository.kt new file mode 100644 index 0000000..584f8de --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/StaffTier2Repository.kt @@ -0,0 +1,92 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.AddMemberRequest +import com.pinakes.app.data.model.BookWorkLink +import com.pinakes.app.data.model.ClubMember +import com.pinakes.app.data.model.DeleteResult +import com.pinakes.app.data.model.IssueWrite +import com.pinakes.app.data.model.MemberUpdate +import com.pinakes.app.data.model.PeriodicalScan +import com.pinakes.app.data.model.StaffClub +import com.pinakes.app.data.model.StaffIssue +import com.pinakes.app.data.model.StaffSubscription +import com.pinakes.app.data.model.StaffWork +import com.pinakes.app.data.model.StaffYear +import com.pinakes.app.data.model.WorkExpression +import com.pinakes.app.data.model.YearWrite +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.store.SessionStore +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +/** Tier 2 staff bridges (Mobile API 1.7.0). Every call goes through [staffCall]. */ +class StaffTier2Repository( + private val network: NetworkModule, + private val session: SessionStore, +) { + private val api get() = network.staffTier2Api() + + // ---- periodicals desk ---- + + /** Mastheads, years and issues come from the public periodicals API (same token). */ + suspend fun periodicals(query: String?, cursor: String?): ApiResult> = + session.staffCall { network.periodicalsApi().periodicals(q = query.blankToNull(), cursor = cursor, limit = 30) }.toPage() + + suspend fun periodical(id: Int): ApiResult = session.staffCall { network.periodicalsApi().periodical(id) } + + suspend fun yearIssues(yearId: Int): ApiResult> = + session.staffCall { network.periodicalsApi().yearIssues(yearId) } + + suspend fun scanLookup(code: String): ApiResult = session.staffCall { api.scanLookup(code.trim()) } + suspend fun receiveIssue(id: Int): ApiResult = session.staffCall { api.receiveIssue(id) } + suspend fun claimIssue(id: Int): ApiResult = session.staffCall { api.claimIssue(id) } + + suspend fun addIssue(yearId: Int, number: String, date: String?, status: String?): ApiResult = + session.staffCall { api.addIssue(yearId, IssueWrite(number.trim(), date.blankToNull(), status.blankToNull())) } + + suspend fun addYear(periodicalId: Int, year: Int, volume: String?, bound: Boolean): ApiResult = + session.staffCall { api.addYear(periodicalId, YearWrite(year, volume.blankToNull(), bound)) } + + suspend fun subscriptions(periodicalId: Int?): ApiResult> = session.staffCall { api.subscriptions(periodicalId) } + + suspend fun saveSubscription(id: Int?, body: JsonObject): ApiResult = + session.staffCall { if (id == null || id <= 0) api.createSubscription(body) else api.updateSubscription(id, body) } + + // ---- book clubs ---- + + suspend fun clubs(): ApiResult> = session.staffCall { api.clubs() } + suspend fun club(id: Int): ApiResult = session.staffCall { api.club(id) } + + suspend fun saveClub(id: Int?, body: JsonObject): ApiResult = + session.staffCall { if (id == null || id <= 0) api.createClub(body) else api.updateClub(id, body) } + + suspend fun deleteClub(id: Int): ApiResult = session.staffCall { api.deleteClub(id) } + suspend fun saveWorkflow(id: Int, body: JsonObject): ApiResult = session.staffCall { api.saveWorkflow(id, body) } + suspend fun addMember(id: Int, userId: Int, role: String): ApiResult = session.staffCall { api.addMember(id, AddMemberRequest(userId, role)) } + + suspend fun updateMember(id: Int, memberId: Int, status: String? = null, role: String? = null): ApiResult = + session.staffCall { api.updateMember(id, memberId, MemberUpdate(status, role)) } + + // ---- FRBR works ---- + + suspend fun works(query: String?): ApiResult> = session.staffCall { api.works(query.blankToNull(), 100) } + suspend fun work(id: Int): ApiResult = session.staffCall { api.work(id) } + + suspend fun saveWork(id: Int?, body: JsonObject): ApiResult = + session.staffCall { if (id == null || id <= 0) api.createWork(body) else api.updateWork(id, body) } + + suspend fun deleteWork(id: Int): ApiResult = session.staffCall { api.deleteWork(id) } + + suspend fun saveExpression(workId: Int, id: Int?, body: JsonObject): ApiResult = + session.staffCall { if (id == null || id <= 0) api.createExpression(workId, body) else api.updateExpression(id, body) } + + suspend fun deleteExpression(id: Int): ApiResult = session.staffCall { api.deleteExpression(id) } + + /** Link [bookId] to [workId], or unlink it with null. */ + suspend fun attachBook(bookId: Int, workId: Int?): ApiResult = session.staffCall { + api.attachBook(bookId, buildJsonObject { put("work_id", workId?.let { JsonPrimitive(it) } ?: JsonNull) }) + } +} diff --git a/app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt b/app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt index b36fdac..dba4401 100644 --- a/app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt +++ b/app/src/main/java/com/pinakes/app/data/staff/StaffAreas.kt @@ -29,7 +29,7 @@ data class StaffAreas( val anyBooksTool: Boolean get() = series || merges || imports || covers || labels /** At least one plugin managed natively. */ - val anyPlugin: Boolean get() = desiderata || archives + val anyPlugin: Boolean get() = desiderata || archives || periodicals || bookClub || works companion object { val None = StaffAreas() diff --git a/app/src/main/java/com/pinakes/app/data/staff/Tier2Forms.kt b/app/src/main/java/com/pinakes/app/data/staff/Tier2Forms.kt new file mode 100644 index 0000000..5fc56c6 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/staff/Tier2Forms.kt @@ -0,0 +1,225 @@ +package com.pinakes.app.data.staff + +import com.pinakes.app.data.model.ClubState +import com.pinakes.app.data.model.StaffClub +import com.pinakes.app.data.model.StaffSubscription +import com.pinakes.app.data.model.StaffWork +import com.pinakes.app.data.model.WorkExpression +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject + +private fun text(value: String): JsonPrimitive = JsonPrimitive(value.trim()) +private fun intOrNull(value: String) = value.trim().toIntOrNull()?.let { JsonPrimitive(it) } ?: JsonNull + +/** Desk rules for a periodical issue status (the server re-checks). */ +object IssueStatus { + val ALL = listOf("posseduto", "mancante", "atteso", "smarrito", "reclamato", "scartato") + + /** Expected or claimed issues can be received. */ + fun receivable(status: String) = status == "atteso" || status == "reclamato" + + /** Expected or missing issues can be claimed from the supplier. */ + fun claimable(status: String) = status == "atteso" || status == "mancante" || status == "reclamato" +} + +/** A subscription being edited. Every key is sent so a field can be emptied. Pure. */ +data class SubscriptionForm( + val id: Int = 0, + val periodicalId: Int = 0, + val supplier: String = "", + val cost: String = "", + val currency: String = "EUR", + val startDate: String? = null, + val endDate: String? = null, + val autoRenew: Boolean = false, + val active: Boolean = true, + val notes: String = "", +) { + val costValid: Boolean get() = cost.isBlank() || cost.trim().replace(',', '.').toDoubleOrNull()?.let { it >= 0 } == true + val valid: Boolean get() = periodicalId > 0 && supplier.isNotBlank() && costValid && + (startDate == null || endDate == null || endDate >= startDate) + + fun toJson(): JsonObject = buildJsonObject { + if (id <= 0) put("periodical_id", JsonPrimitive(periodicalId)) + put("supplier", text(supplier)) + put("cost", cost.trim().replace(',', '.').toDoubleOrNull()?.let { JsonPrimitive(it) } ?: JsonPrimitive("")) + put("currency", text(currency)) + put("start_date", JsonPrimitive(startDate.orEmpty())) + put("end_date", JsonPrimitive(endDate.orEmpty())) + put("auto_renew", JsonPrimitive(autoRenew)) + put("active", JsonPrimitive(active)) + put("notes", text(notes)) + } + + companion object { + fun from(s: StaffSubscription) = SubscriptionForm( + id = s.id, periodicalId = s.periodicalId, supplier = s.supplier, + cost = s.cost?.let { if (it % 1.0 == 0.0) it.toInt().toString() else it.toString() }.orEmpty(), + currency = s.currency.orEmpty(), startDate = s.startDate?.take(10), endDate = s.endDate?.take(10), + autoRenew = s.autoRenew, active = s.active, notes = s.notes.orEmpty(), + ) + } +} + +/** A book club's settings. Pure. */ +data class ClubForm( + val id: Int = 0, + val name: String = "", + val description: String = "", + val rules: String = "", + val color: String = "#4f46e5", + val privacy: String = "public", + val maxMembers: String = "", + val active: Boolean = true, + val moderateProposals: Boolean = false, +) { + val colorValid: Boolean get() = Regex("^#[0-9a-fA-F]{6}$").matches(color.trim()) + val maxMembersValid: Boolean get() = maxMembers.isBlank() || maxMembers.trim().toIntOrNull()?.let { it > 0 } == true + val valid: Boolean get() = name.isNotBlank() && name.trim().length <= 190 && colorValid && maxMembersValid && privacy in PRIVACIES + + fun toJson(): JsonObject = buildJsonObject { + put("name", text(name)) + put("description", text(description)) + put("rules", text(rules)) + put("color", text(color)) + put("privacy", JsonPrimitive(privacy)) + put("max_members", intOrNull(maxMembers)) + put("active", JsonPrimitive(active)) + put("moderate_proposals", JsonPrimitive(moderateProposals)) + } + + companion object { + val PRIVACIES = listOf("public", "private", "invite", "hidden") + val ROLES = listOf("owner", "moderator", "member", "guest") + val MEMBER_STATUSES = listOf("pending", "active", "suspended", "left", "banned") + + fun from(c: StaffClub) = ClubForm( + id = c.id, name = c.name, description = c.description, rules = c.rules, color = c.color.ifBlank { "#4f46e5" }, + privacy = c.privacy, maxMembers = c.maxMembers?.toString().orEmpty(), active = c.active, moderateProposals = c.moderateProposals, + ) + } +} + +/** One editable workflow state. */ +data class WorkflowStateDraft( + val key: String, + val label: String, + val color: String, + val voting: Boolean = false, + val current: Boolean = false, + val archived: Boolean = false, +) + +/** The club workflow editor: at least two states, each with a label; reorder, add, remove. Pure. */ +data class WorkflowDraft(val states: List) { + val valid: Boolean get() = states.size >= 2 && states.all { it.label.isNotBlank() && Regex("^#[0-9a-fA-F]{6}$").matches(it.color.trim()) } + + fun move(index: Int, by: Int): WorkflowDraft { + val target = index + by + if (index !in states.indices || target !in states.indices) return this + val list = states.toMutableList() + list[index] = states[target].also { list[target] = states[index] } + return WorkflowDraft(list) + } + + fun remove(index: Int): WorkflowDraft = if (states.size <= 2 || index !in states.indices) this else WorkflowDraft(states.filterIndexed { i, _ -> i != index }) + + fun add(label: String): WorkflowDraft = WorkflowDraft(states + WorkflowStateDraft(key = "", label = label, color = "#6b7280")) + + fun update(index: Int, transform: (WorkflowStateDraft) -> WorkflowStateDraft): WorkflowDraft = + WorkflowDraft(states.mapIndexed { i, s -> if (i == index) transform(s) else s }) + + /** `{states: [{key, label, color, flags: {voting, current, archived}}]}`; a new state has an empty key (the server derives it). */ + fun toJson(): JsonObject = buildJsonObject { + put("states", JsonArray(states.map { s -> + buildJsonObject { + put("key", JsonPrimitive(s.key)) + put("label", text(s.label)) + put("color", text(s.color)) + put("flags", buildJsonObject { + put("voting", JsonPrimitive(s.voting)) + put("current", JsonPrimitive(s.current)) + put("archived", JsonPrimitive(s.archived)) + }) + } + })) + } + + companion object { + fun from(states: List) = WorkflowDraft(states.map { + WorkflowStateDraft(it.key, it.label, it.color, it.flag("voting"), it.flag("current"), it.flag("archived")) + }) + } +} + +/** An FRBR work (WEMI "work") being edited. Pure. */ +data class WorkForm( + val id: Int = 0, + val uniformTitle: String = "", + val originalTitle: String = "", + val mainAuthorId: Int? = null, + val mainAuthorName: String = "", + val createdFrom: String = "", + val createdTo: String = "", + val originalLanguage: String = "", + val viafWorkId: String = "", + val wikidataId: String = "", + val notes: String = "", +) { + private fun year(v: String) = v.isBlank() || v.trim().toIntOrNull() != null + val valid: Boolean get() = uniformTitle.isNotBlank() && uniformTitle.trim().length <= 255 && year(createdFrom) && year(createdTo) + + fun toJson(): JsonObject = buildJsonObject { + put("uniform_title", text(uniformTitle)) + put("original_title", text(originalTitle)) + put("main_author_id", mainAuthorId?.let { JsonPrimitive(it) } ?: JsonNull) + put("created_from", intOrNull(createdFrom)) + put("created_to", intOrNull(createdTo)) + put("original_language", text(originalLanguage)) + put("viaf_work_id", text(viafWorkId)) + put("wikidata_id", text(wikidataId)) + put("notes", text(notes)) + } + + companion object { + fun from(w: StaffWork) = WorkForm( + id = w.id, uniformTitle = w.uniformTitle.orEmpty(), originalTitle = w.originalTitle.orEmpty(), + mainAuthorId = w.mainAuthorId, mainAuthorName = w.mainAuthorName.orEmpty(), + createdFrom = w.createdFrom?.toString().orEmpty(), createdTo = w.createdTo?.toString().orEmpty(), + originalLanguage = w.originalLanguage.orEmpty(), viafWorkId = w.viafWorkId.orEmpty(), wikidataId = w.wikidataId.orEmpty(), + notes = w.notes.orEmpty(), + ) + } +} + +/** An expression of a work. Pure. */ +data class ExpressionForm( + val id: Int = 0, + val type: String = "testo", + val language: String = "", + val title: String = "", + val year: String = "", + val notes: String = "", +) { + val valid: Boolean get() = type in TYPES && (year.isBlank() || year.trim().toIntOrNull() != null) + + fun toJson(): JsonObject = buildJsonObject { + put("type", JsonPrimitive(type)) + put("language", text(language)) + put("title", text(title)) + put("year", intOrNull(year)) + put("notes", text(notes)) + } + + companion object { + val TYPES = listOf("testo", "traduzione", "revisione", "adattamento", "edizione_critica", "audio", "altro") + + fun from(e: WorkExpression) = ExpressionForm( + id = e.id, type = e.type ?: "testo", language = e.language.orEmpty(), title = e.title.orEmpty(), + year = e.year?.toString().orEmpty(), notes = e.notes.orEmpty(), + ) + } +} diff --git a/app/src/main/java/com/pinakes/app/di/AppModule.kt b/app/src/main/java/com/pinakes/app/di/AppModule.kt index a375f43..1fa97e0 100644 --- a/app/src/main/java/com/pinakes/app/di/AppModule.kt +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -125,6 +125,10 @@ object AppModule { fun staffPluginsRepository(network: NetworkModule, session: SessionStore): StaffPluginsRepository = StaffPluginsRepository(network, session) + @Provides @Singleton + fun staffTier2Repository(network: NetworkModule, session: SessionStore): com.pinakes.app.data.repository.StaffTier2Repository = + com.pinakes.app.data.repository.StaffTier2Repository(network, session) + /** Staff PDFs/CSVs land in `cache/exports/`, the directory the FileProvider shares. */ @Provides @Singleton fun staffFilesRepository(@ApplicationContext context: Context, network: NetworkModule, session: SessionStore): StaffFilesRepository = diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt index 8f00670..290a33f 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/PinakesNavHost.kt @@ -188,6 +188,9 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onDesiderata = { navController.navigate(Routes.STAFF_DESIDERATA) }, onArchives = { navController.navigate(Routes.STAFF_ARCHIVES) }, onAuthorities = { navController.navigate(Routes.STAFF_AUTHORITIES) }, + onPeriodicals = { navController.navigate(Routes.STAFF_PERIODICALS) }, + onClubs = { navController.navigate(Routes.STAFF_CLUBS) }, + onWorks = { navController.navigate(Routes.STAFF_WORKS) }, ), ) } @@ -314,6 +317,42 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { ) { StaffAuthorityEditScreen(onNavigateUp = { navController.popBackStack() }) } + // ---- Staff Tier 2 bridges (periodicals desk, book clubs, FRBR works) ---- + composable(Routes.STAFF_PERIODICALS, enterTransition = slideIn, popExitTransition = slideOut) { + com.pinakes.app.ui.screens.staff.tier2.StaffPeriodicalsDeskScreen( + onNavigateUp = { navController.popBackStack() }, onOpenPeriodical = { navController.navigate(Routes.staffPeriodical(it)) }, + ) + } + composable( + Routes.STAFF_PERIODICAL, + arguments = listOf(navArgument(Routes.ARG_PERIODICAL_ID) { type = NavType.IntType }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + com.pinakes.app.ui.screens.staff.tier2.StaffPeriodicalScreen(onNavigateUp = { navController.popBackStack() }) + } + composable(Routes.STAFF_CLUBS, enterTransition = slideIn, popExitTransition = slideOut) { + com.pinakes.app.ui.screens.staff.tier2.StaffClubsScreen(onNavigateUp = { navController.popBackStack() }, onOpen = { navController.navigate(Routes.staffClub(it)) }) + } + composable( + Routes.STAFF_CLUB, + arguments = listOf(navArgument(Routes.ARG_CLUB_ID) { type = NavType.IntType }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + com.pinakes.app.ui.screens.staff.tier2.StaffClubScreen(onNavigateUp = { navController.popBackStack() }) + } + composable(Routes.STAFF_WORKS, enterTransition = slideIn, popExitTransition = slideOut) { + com.pinakes.app.ui.screens.staff.tier2.StaffWorksScreen(onNavigateUp = { navController.popBackStack() }, onOpen = { navController.navigate(Routes.staffWork(it)) }) + } + composable( + Routes.STAFF_WORK, + arguments = listOf(navArgument(Routes.ARG_WORK_ID) { type = NavType.IntType }), + enterTransition = slideIn, popExitTransition = slideOut, + ) { + com.pinakes.app.ui.screens.staff.tier2.StaffWorkScreen( + onNavigateUp = { navController.popBackStack() }, onOpenBook = { navController.navigate(Routes.staffBookEdit(it)) }, + ) + } + composable(Routes.STAFF_COPY_SCAN, enterTransition = slideIn, popExitTransition = slideOut) { StaffCopyScanScreen( onNavigateUp = { navController.popBackStack() }, diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt index ee09d9e..18d045c 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/Routes.kt @@ -113,6 +113,19 @@ object Routes { const val STAFF_AUTHORITY_EDIT = "staff/authorities/{authorityId}/edit" fun staffAuthorityEdit(id: Int = 0): String = "staff/authorities/$id/edit" + // Staff area, Tier 2 bridges (periodicals desk, book clubs, FRBR works) + const val STAFF_PERIODICALS = "staff/periodicals" + const val STAFF_PERIODICAL = "staff/periodicals/{periodicalId}" + fun staffPeriodical(id: Int): String = "staff/periodicals/$id" + const val ARG_CLUB_ID = "clubId" + const val STAFF_CLUBS = "staff/clubs" + const val STAFF_CLUB = "staff/clubs/{clubId}" + fun staffClub(id: Int): String = "staff/clubs/$id" + const val ARG_WORK_ID = "workId" + const val STAFF_WORKS = "staff/works" + const val STAFF_WORK = "staff/works/{workId}" + fun staffWork(id: Int): String = "staff/works/$id" + /** Graph hosting the bottom-nav + nested authed screens. */ const val MAIN_GRAPH = "main" const val DESIDERATA = "desiderata" diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt index d0283d5..48492ac 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/StaffHubScreen.kt @@ -8,6 +8,9 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.LibraryBooks import androidx.compose.material.icons.outlined.Archive +import androidx.compose.material.icons.outlined.AccountTree +import androidx.compose.material.icons.outlined.Groups +import androidx.compose.material.icons.outlined.Newspaper import androidx.compose.material.icons.automirrored.outlined.AssignmentReturn import androidx.compose.material.icons.outlined.AutoAwesome import androidx.compose.material.icons.outlined.Business @@ -55,6 +58,9 @@ data class StaffHubPhase2( val onDesiderata: () -> Unit = {}, val onArchives: () -> Unit = {}, val onAuthorities: () -> Unit = {}, + val onPeriodicals: () -> Unit = {}, + val onClubs: () -> Unit = {}, + val onWorks: () -> Unit = {}, ) /** @@ -141,6 +147,10 @@ fun StaffHubScreen( StaffActionRow(Icons.Outlined.Archive, stringResource(R.string.staff_archives), stringResource(R.string.staff_archives_subtitle), phase2.onArchives) StaffActionRow(Icons.Outlined.Person, stringResource(R.string.staff_authorities), onClick = phase2.onAuthorities) } + if (areas.periodicals) StaffActionRow(Icons.Outlined.Newspaper, stringResource(R.string.staff_periodicals), + stringResource(R.string.staff_periodicals_subtitle), phase2.onPeriodicals) + if (areas.bookClub) StaffActionRow(Icons.Outlined.Groups, stringResource(R.string.staff_clubs), stringResource(R.string.staff_clubs_subtitle), phase2.onClubs) + if (areas.works) StaffActionRow(Icons.Outlined.AccountTree, stringResource(R.string.staff_works), stringResource(R.string.staff_works_subtitle), phase2.onWorks) } } } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/ScannerBox.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/ScannerBox.kt new file mode 100644 index 0000000..94c0011 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/ScannerBox.kt @@ -0,0 +1,99 @@ +package com.pinakes.app.ui.screens.staff.scan + +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.google.zxing.BarcodeFormat +import com.pinakes.app.R +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.theme.LocalPinakesColors +import com.pinakes.app.ui.theme.Spacing + +/** + * The camera viewfinder of the staff scanners (copy labels, periodical issues): asks for the + * camera once, re-checks on resume, explains a refusal with [permissionText], and dims with a + * spinner while [looking]. The manual entry next to it stays the path without a camera. + */ +@Composable +internal fun ScannerBox( + formats: List, + onCode: (String) -> Unit, + looking: Boolean, + permissionText: String, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + fun hasCamera() = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED + var granted by remember { mutableStateOf(hasCamera()) } + var asked by rememberSaveable { mutableStateOf(false) } + var cameraBroken by remember { mutableStateOf(false) } + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted = it; asked = true } + LaunchedEffect(Unit) { if (!granted && !asked) permission.launch(Manifest.permission.CAMERA) } + val owner = LocalLifecycleOwner.current + DisposableEffect(owner) { + val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_RESUME) granted = hasCamera() } + owner.lifecycle.addObserver(observer) + onDispose { owner.lifecycle.removeObserver(observer) } + } + Box( + modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(RoundedCornerShape(16.dp)).background(LocalPinakesColors.current.coverBlank), + contentAlignment = Alignment.Center, + ) { + if (granted && !cameraBroken) { + CameraPreview( + onCode = onCode, torchOn = false, onHasTorch = {}, onUnavailable = { cameraBroken = true }, + accent = LocalPinakesColors.current.accent, modifier = Modifier.fillMaxSize(), formats = formats, + ) + } else { + Column(Modifier.padding(Spacing.xl), horizontalAlignment = Alignment.CenterHorizontally) { + Text( + if (cameraBroken) stringResource(R.string.staff_camera_unavailable) else permissionText, + color = Color.White, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, + ) + if (!granted && !cameraBroken) { + Spacer(Modifier.height(Spacing.md)) + PrimaryButton(stringResource(R.string.staff_scan_permission_allow), onClick = { permission.launch(Manifest.permission.CAMERA) }) + } + } + } + if (looking) { + Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f)), contentAlignment = Alignment.Center) { + CircularProgressIndicator(color = Color.White, strokeWidth = 3.dp) + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt index bc169ab..9d69d25 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/scan/StaffCopyScanScreen.kt @@ -234,20 +234,6 @@ fun StaffCopyScanScreen( StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) LaunchedEffect(state.acceptedTick) { if (state.acceptedTick > 0) haptics.performHapticFeedback(HapticFeedbackType.LongPress) } - var granted by remember { mutableStateOf(ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) } - var asked by rememberSaveable { mutableStateOf(false) } - var cameraBroken by remember { mutableStateOf(false) } - val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted = it; asked = true } - LaunchedEffect(Unit) { if (!granted && !asked) permission.launch(Manifest.permission.CAMERA) } - val owner = LocalLifecycleOwner.current - DisposableEffect(owner) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) granted = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED - } - owner.lifecycle.addObserver(observer) - onDispose { owner.lifecycle.removeObserver(observer) } - } - Scaffold( topBar = { PinakesTopBar( @@ -272,33 +258,10 @@ fun StaffCopyScanScreen( Modifier.fillMaxSize().padding(padding).imePadding().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg, vertical = Spacing.sm), verticalArrangement = Arrangement.spacedBy(Spacing.md), ) { - Box( - Modifier.fillMaxWidth().aspectRatio(4f / 3f).clip(RoundedCornerShape(16.dp)).background(LocalPinakesColors.current.coverBlank), - contentAlignment = Alignment.Center, - ) { - if (granted && !cameraBroken) { - CameraPreview( - onCode = vm::onDecoded, torchOn = false, onHasTorch = {}, onUnavailable = { cameraBroken = true }, - accent = LocalPinakesColors.current.accent, modifier = Modifier.fillMaxSize(), formats = BarcodeAnalyzer.COPY_FORMATS, - ) - } else { - Column(Modifier.padding(Spacing.xl), horizontalAlignment = Alignment.CenterHorizontally) { - Text( - stringResource(if (cameraBroken) R.string.staff_camera_unavailable else R.string.staff_scan_copy_permission), - color = Color.White, style = MaterialTheme.typography.bodyMedium, textAlign = TextAlign.Center, - ) - if (!granted && !cameraBroken) { - Spacer(Modifier.height(Spacing.md)) - PrimaryButton(stringResource(R.string.staff_scan_permission_allow), onClick = { permission.launch(Manifest.permission.CAMERA) }) - } - } - } - if (state.phase is CopyScanPhase.Looking) { - Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.45f)), contentAlignment = Alignment.Center) { - CircularProgressIndicator(color = Color.White, strokeWidth = 3.dp) - } - } - } + ScannerBox( + formats = BarcodeAnalyzer.COPY_FORMATS, onCode = vm::onDecoded, looking = state.phase is CopyScanPhase.Looking, + permissionText = stringResource(R.string.staff_scan_copy_permission), + ) Text(stringResource(R.string.staff_scan_copy_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) PinakesTextField( value = state.manualInput, onValueChange = vm::onManualInput, label = stringResource(R.string.staff_copy_code), diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffBookClubScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffBookClubScreens.kt new file mode 100644 index 0000000..e080b0f --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffBookClubScreens.kt @@ -0,0 +1,504 @@ +package com.pinakes.app.ui.screens.staff.tier2 + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.ArrowDownward +import androidx.compose.material.icons.outlined.ArrowUpward +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.Groups +import androidx.compose.material.icons.outlined.Person +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.ClubMember +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.StaffClub +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffCirculationRepository +import com.pinakes.app.data.repository.StaffTier2Repository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.ClubForm +import com.pinakes.app.data.staff.WorkflowDraft +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.SearchController +import com.pinakes.app.ui.screens.staff.SearchPickerSheet +import com.pinakes.app.ui.screens.staff.StaffActionRow +import com.pinakes.app.ui.screens.staff.StaffFab +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.circulation.toPickItem +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal fun privacyLabel(value: String): Int = when (value) { + "private" -> R.string.staff_club_privacy_private + "invite" -> R.string.staff_club_privacy_invite + "hidden" -> R.string.staff_club_privacy_hidden + else -> R.string.staff_club_privacy_public +} + +internal fun roleLabel(value: String): Int = when (value) { + "owner" -> R.string.staff_club_role_owner + "moderator" -> R.string.staff_club_role_moderator + "guest" -> R.string.staff_club_role_guest + else -> R.string.staff_club_role_member +} + +internal fun memberStatusLabel(value: String): Int = when (value) { + "pending" -> R.string.staff_club_member_pending + "suspended" -> R.string.staff_club_member_suspended + "left" -> R.string.staff_club_member_left + "banned" -> R.string.staff_club_member_banned + else -> R.string.staff_club_member_active +} + +/** The settings sheet shared by create and edit. */ +@Composable +private fun ClubFormSheet(title: String, form: ClubForm, error: StaffMessage?, saving: Boolean, onEdit: ((ClubForm) -> ClubForm) -> Unit, onSave: () -> Unit, onDismiss: () -> Unit) { + FormSheet(title, error, saving, enabled = form.valid, onSave = onSave, onDismiss = onDismiss) { + PinakesTextField(form.name, { v -> onEdit { it.copy(name = v) } }, stringResource(R.string.staff_field_name), maxLength = 190, modifier = Modifier.fillMaxWidth().testTag("club-name")) + PinakesTextField(form.description, { v -> onEdit { it.copy(description = v) } }, stringResource(R.string.staff_field_description), singleLine = false, + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp)) + PinakesTextField(form.rules, { v -> onEdit { it.copy(rules = v) } }, stringResource(R.string.staff_club_rules), singleLine = false, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp)) + LookupField( + label = stringResource(R.string.staff_club_privacy), value = form.privacy, displayValue = stringResource(privacyLabel(form.privacy)), + options = ClubForm.PRIVACIES.map { LookupOption(it, stringResource(privacyLabel(it))) }, editable = false, allowClear = false, + onValueChange = { v -> onEdit { it.copy(privacy = v) } }, modifier = Modifier.fillMaxWidth(), + ) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + PinakesTextField(form.color, { v -> onEdit { it.copy(color = v.take(7)) } }, stringResource(R.string.staff_club_color), isError = !form.colorValid, + errorText = stringResource(R.string.staff_field_invalid), modifier = Modifier.weight(1f)) + PinakesTextField(form.maxMembers, { v -> onEdit { it.copy(maxMembers = v.filter(Char::isDigit)) } }, stringResource(R.string.staff_club_max_members), + isError = !form.maxMembersValid, errorText = stringResource(R.string.staff_field_invalid), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f)) + } + SwitchRow(stringResource(R.string.staff_club_active), form.active, { v -> onEdit { it.copy(active = v) } }) + SwitchRow(stringResource(R.string.staff_club_moderate), form.moderateProposals, { v -> onEdit { it.copy(moderateProposals = v) } }) + } +} + +// ============================================================================================= +// List +// ============================================================================================= + +data class ClubsUiState( + val clubs: List = emptyList(), + val loading: Boolean = true, + val refreshing: Boolean = false, + val error: StaffMessage? = null, + val creating: ClubForm? = null, + val saving: Boolean = false, + val formError: StaffMessage? = null, + val created: Int? = null, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffClubsViewModel @Inject constructor(private val tier2: StaffTier2Repository) : ViewModel() { + private val _state = MutableStateFlow(ClubsUiState()) + val state: StateFlow = _state.asStateFlow() + + fun reload() { + viewModelScope.launch { + val res = tier2.clubs() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(clubs = res.data, loading = false, refreshing = false, error = null) + is ApiResult.Failure -> it.copy(loading = false, refreshing = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun refresh() { _state.update { it.copy(refreshing = true) }; reload() } + fun startCreate() = _state.update { it.copy(creating = ClubForm(), formError = null) } + fun edit(transform: (ClubForm) -> ClubForm) = _state.update { s -> s.copy(creating = s.creating?.let(transform), formError = null) } + fun close() = _state.update { it.copy(creating = null) } + + fun create() { + val form = _state.value.creating ?: return + if (!form.valid || _state.value.saving) return + _state.update { it.copy(saving = true) } + viewModelScope.launch { + val res = tier2.saveClub(null, form.toJson()) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(saving = false, creating = null, created = res.data.id) + is ApiResult.Failure -> it.copy(saving = false, forbidden = res.isForbidden(), formError = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + + fun consumeCreated() = _state.update { it.copy(created = null) } +} + +/** Book clubs: every club, active or not, with a create sheet. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffClubsScreen(onNavigateUp: () -> Unit, onOpen: (Int) -> Unit) { + val vm: StaffClubsViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.bookClub, onNavigateUp) + LaunchedEffect(Unit) { vm.reload() } + LaunchedEffect(state.created) { state.created?.let { vm.consumeCreated(); onOpen(it) } } + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_clubs), onNavigateUp = onNavigateUp) }, + floatingActionButton = { StaffFab(Icons.Outlined.Add, stringResource(R.string.staff_club_new), vm::startCreate) }, + ) { padding -> + PullToRefreshBox(isRefreshing = state.refreshing, onRefresh = vm::refresh, modifier = Modifier.fillMaxSize().padding(padding)) { + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = state.clubs.isEmpty(), + emptyTitle = stringResource(R.string.staff_clubs_empty), emptyIcon = Icons.Outlined.Groups, onRetry = vm::refresh, + ) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp)) { + items(state.clubs, key = { it.id }) { c -> + StaffActionRow( + Icons.Outlined.Groups, c.name, + listOfNotNull( + stringResource(privacyLabel(c.privacy)), + c.memberCount?.let { stringResource(R.string.staff_club_members_count, it) }, + if (c.active) null else stringResource(R.string.staff_club_inactive), + ).joinToString(" · "), + ) { onOpen(c.id) } + } + } + } + } + } + + state.creating?.let { form -> + ClubFormSheet(stringResource(R.string.staff_club_new), form, state.formError, state.saving, vm::edit, vm::create, vm::close) + } +} + +// ============================================================================================= +// Detail +// ============================================================================================= + +data class ClubUiState( + val club: StaffClub? = null, + val loading: Boolean = true, + val loadError: StaffMessage? = null, + val editing: ClubForm? = null, + val workflow: WorkflowDraft? = null, + val member: ClubMember? = null, + val addingMember: Boolean = false, + val addRole: String = "member", + val confirmDelete: Boolean = false, + val busy: Boolean = false, + val formError: StaffMessage? = null, + val message: StaffMessage? = null, + val deleted: Boolean = false, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffClubViewModel @Inject constructor( + private val tier2: StaffTier2Repository, + private val circulation: StaffCirculationRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val clubId: Int = savedState.get(Routes.ARG_CLUB_ID) ?: 0 + private val _state = MutableStateFlow(ClubUiState()) + val state: StateFlow = _state.asStateFlow() + + val users = SearchController(viewModelScope, minLength = 2) { q -> + when (val res = circulation.users(q)) { + is ApiResult.Success -> ApiResult.Success(res.data.map { it.toPickItem().copy(note = null) }) + is ApiResult.Failure -> res + } + }.also { c -> c.onFailure = { f -> if (f.isForbidden()) _state.update { it.copy(forbidden = true) } } } + + init { load() } + + fun load() { + viewModelScope.launch { + val res = tier2.club(clubId) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(club = res.data, loading = false, loadError = null) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), loadError = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun startEdit() = _state.update { s -> s.copy(editing = s.club?.let(ClubForm::from), formError = null) } + fun editForm(transform: (ClubForm) -> ClubForm) = _state.update { s -> s.copy(editing = s.editing?.let(transform), formError = null) } + fun startWorkflow() = _state.update { s -> s.copy(workflow = s.club?.let { WorkflowDraft.from(it.workflow) }, formError = null) } + fun editWorkflow(transform: (WorkflowDraft) -> WorkflowDraft) = _state.update { s -> s.copy(workflow = s.workflow?.let(transform), formError = null) } + fun closeForms() { _state.update { it.copy(editing = null, workflow = null, member = null, addingMember = false, formError = null) }; users.clear() } + + fun saveSettings() { + val form = _state.value.editing ?: return + if (!form.valid) return + mutate(R.string.staff_club_saved) { tier2.saveClub(clubId, form.toJson()) } + } + + fun saveWorkflow() { + val draft = _state.value.workflow ?: return + if (!draft.valid) return + mutate(R.string.staff_club_workflow_saved) { tier2.saveWorkflow(clubId, draft.toJson()) } + } + + private fun mutate(success: Int, call: suspend () -> ApiResult) { + if (_state.value.busy) return + _state.update { it.copy(busy = true, formError = null) } + viewModelScope.launch { + val res = call() + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, club = res.data, editing = null, workflow = null, message = StaffMessage(success)) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), formError = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + + fun openMember(m: ClubMember?) = _state.update { it.copy(member = m) } + + fun updateMember(m: ClubMember, status: String? = null, role: String? = null) { + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = tier2.updateMember(clubId, m.id, status, role) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, member = null, message = StaffMessage(R.string.staff_club_member_saved), + club = it.club?.copy(members = it.club.members.map { x -> if (x.id == res.data.id) res.data else x })) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + + fun startAddMember() = _state.update { it.copy(addingMember = true, addRole = "member") } + fun setAddRole(role: String) = _state.update { it.copy(addRole = role) } + + fun addMember(user: PickItem) { + val role = _state.value.addRole + closeForms() + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = tier2.addMember(clubId, user.id, role) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, message = StaffMessage(R.string.staff_club_member_added, args = listOf(res.data.name)), + club = it.club?.copy(members = it.club.members.filterNot { x -> x.id == res.data.id } + res.data)) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + + fun askDelete() = _state.update { it.copy(confirmDelete = true) } + fun dismissDelete() = _state.update { it.copy(confirmDelete = false) } + + fun delete() { + _state.update { it.copy(confirmDelete = false) } + viewModelScope.launch { + when (val res = tier2.deleteClub(clubId)) { + is ApiResult.Success -> _state.update { it.copy(deleted = true) } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) } + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** One club: settings, the reading workflow and the members (role, status, add). */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun StaffClubScreen(onNavigateUp: () -> Unit) { + val vm: StaffClubViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.bookClub || state.deleted, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { + PinakesTopBar( + title = state.club?.name ?: stringResource(R.string.staff_clubs), onNavigateUp = onNavigateUp, + actions = { if (state.club != null) IconButton(onClick = vm::askDelete) { Icon(Icons.Outlined.DeleteOutline, contentDescription = stringResource(R.string.staff_club_delete)) } }, + ) + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + val club = state.club + when { + state.loading -> LoadingState(modifier = Modifier.padding(padding)) + club == null -> ErrorState(message = state.loadError?.resolve() ?: stringResource(R.string.staff_error_load), onRetry = vm::load, modifier = Modifier.padding(padding)) + else -> LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + item(key = "facts") { + InfoCard(Modifier.padding(top = Spacing.sm)) { + InfoLine(stringResource(R.string.staff_club_privacy), stringResource(privacyLabel(club.privacy))) + InfoLine(stringResource(R.string.staff_club_max_members), club.maxMembers?.toString()) + InfoLine(stringResource(R.string.staff_club_active), stringResource(if (club.active) R.string.staff_yes else R.string.staff_club_inactive)) + if (club.description.isNotBlank()) Text(club.description, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = Spacing.sm)) + } + SecondaryButton(stringResource(R.string.staff_club_edit), onClick = vm::startEdit, modifier = Modifier.fillMaxWidth().padding(top = Spacing.sm)) + } + item(key = "wf-title") { FormSectionTitle(stringResource(R.string.staff_club_workflow)) } + item(key = "wf") { + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + club.workflow.forEach { s -> Text(s.label, style = MaterialTheme.typography.labelLarge, modifier = Modifier.padding(end = Spacing.sm)) } + } + SecondaryButton(stringResource(R.string.staff_club_workflow_edit), onClick = vm::startWorkflow, modifier = Modifier.fillMaxWidth().padding(top = Spacing.sm)) + } + item(key = "members-title") { FormSectionTitle(stringResource(R.string.staff_club_members)) } + items(club.members, key = { "m${it.id}" }) { m -> + StaffActionRow(Icons.Outlined.Person, m.name.ifBlank { m.email }, + listOf(stringResource(roleLabel(m.role)), stringResource(memberStatusLabel(m.status))).joinToString(" · ")) { vm.openMember(m) } + } + item(key = "add-member") { + SecondaryButton(stringResource(R.string.staff_club_member_add), onClick = vm::startAddMember, leadingIcon = Icons.Outlined.Add, modifier = Modifier.fillMaxWidth()) + } + } + } + } + + state.editing?.let { form -> ClubFormSheet(stringResource(R.string.staff_club_edit), form, state.formError, state.busy, vm::editForm, vm::saveSettings, vm::closeForms) } + state.workflow?.let { draft -> + FormSheet(stringResource(R.string.staff_club_workflow_edit), state.formError, state.busy, enabled = draft.valid, onSave = vm::saveWorkflow, onDismiss = vm::closeForms) { + Text(stringResource(R.string.staff_club_workflow_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + draft.states.forEachIndexed { i, s -> + Column(Modifier.fillMaxWidth().padding(vertical = Spacing.xs), verticalArrangement = Arrangement.spacedBy(Spacing.xs)) { + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + PinakesTextField(s.label, { v -> vm.editWorkflow { d -> d.update(i) { it.copy(label = v) } } }, stringResource(R.string.staff_club_state_label), + modifier = Modifier.weight(2f).testTag("state-label-$i")) + PinakesTextField(s.color, { v -> vm.editWorkflow { d -> d.update(i) { it.copy(color = v.take(7)) } } }, stringResource(R.string.staff_club_color), modifier = Modifier.weight(1f)) + } + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + listOf( + Triple(R.string.staff_club_flag_voting, s.voting) { v: Boolean -> vm.editWorkflow { d -> d.update(i) { it.copy(voting = v) } } }, + Triple(R.string.staff_club_flag_current, s.current) { v: Boolean -> vm.editWorkflow { d -> d.update(i) { it.copy(current = v) } } }, + Triple(R.string.staff_club_flag_archived, s.archived) { v: Boolean -> vm.editWorkflow { d -> d.update(i) { it.copy(archived = v) } } }, + ).forEach { (label, on, set) -> + FilterChip(selected = on, onClick = { set(!on) }, label = { Text(stringResource(label)) }, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedLabelColor = MaterialTheme.colorScheme.onPrimaryContainer)) + } + IconButton(onClick = { vm.editWorkflow { it.move(i, -1) } }, enabled = i > 0) { Icon(Icons.Outlined.ArrowUpward, contentDescription = stringResource(R.string.staff_move_up)) } + IconButton(onClick = { vm.editWorkflow { it.move(i, 1) } }, enabled = i < draft.states.size - 1) { Icon(Icons.Outlined.ArrowDownward, contentDescription = stringResource(R.string.staff_move_down)) } + IconButton(onClick = { vm.editWorkflow { it.remove(i) } }, enabled = draft.states.size > 2) { Icon(Icons.Outlined.Close, contentDescription = stringResource(R.string.cd_remove)) } + } + } + } + val newLabel = stringResource(R.string.staff_club_state_new) + SecondaryButton(stringResource(R.string.staff_club_state_add), onClick = { vm.editWorkflow { it.add(newLabel) } }, leadingIcon = Icons.Outlined.Add, modifier = Modifier.fillMaxWidth()) + } + } + state.member?.let { m -> + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { vm.openMember(null) }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding(), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + Text(m.name.ifBlank { m.email }, style = MaterialTheme.typography.headlineSmall) + FormSectionTitle(stringResource(R.string.staff_club_role)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + ClubForm.ROLES.forEach { role -> + FilterChip(selected = m.role == role, onClick = { if (m.role != role) vm.updateMember(m, role = role) }, enabled = !state.busy, + label = { Text(stringResource(roleLabel(role))) }, modifier = Modifier.testTag("role-$role")) + } + } + FormSectionTitle(stringResource(R.string.staff_club_member_status)) + FlowRow(horizontalArrangement = Arrangement.spacedBy(Spacing.xs)) { + ClubForm.MEMBER_STATUSES.forEach { status -> + FilterChip(selected = m.status == status, onClick = { if (m.status != status) vm.updateMember(m, status = status) }, enabled = !state.busy, + label = { Text(stringResource(memberStatusLabel(status))) }, modifier = Modifier.testTag("status-$status")) + } + } + Spacer(Modifier.height(Spacing.lg)) + } + } + } + if (state.addingMember) { + val s by vm.users.state.collectAsStateWithLifecycle() + SearchPickerSheet( + title = stringResource(R.string.staff_club_member_add), placeholder = stringResource(R.string.staff_search_users), state = s, + onQuery = vm.users::onQuery, onPick = vm::addMember, onDismiss = vm::closeForms, + hint = stringResource(R.string.staff_club_member_add_hint, stringResource(roleLabel(state.addRole))), + ) + } + if (state.confirmDelete) { + ConfirmDialog( + title = stringResource(R.string.staff_club_delete), body = stringResource(R.string.staff_club_delete_body, state.club?.name.orEmpty()), + confirmLabel = stringResource(R.string.staff_club_delete), onConfirm = vm::delete, onDismiss = vm::dismissDelete, + ) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffPeriodicalsScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffPeriodicalsScreens.kt new file mode 100644 index 0000000..0731781 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffPeriodicalsScreens.kt @@ -0,0 +1,597 @@ +package com.pinakes.app.ui.screens.staff.tier2 + +import android.os.SystemClock +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.ExpandLess +import androidx.compose.material.icons.outlined.ExpandMore +import androidx.compose.material.icons.outlined.Newspaper +import androidx.compose.material.icons.outlined.Search +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.PeriodicalDetail +import com.pinakes.app.data.model.PeriodicalIssue +import com.pinakes.app.data.model.PeriodicalScan +import com.pinakes.app.data.model.PeriodicalSummary +import com.pinakes.app.data.model.StaffSubscription +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffTier2Repository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.IssueStatus +import com.pinakes.app.data.staff.ScanDebouncer +import com.pinakes.app.data.staff.SubscriptionForm +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SearchField +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.DatePickerField +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.StaffActionRow +import com.pinakes.app.ui.screens.staff.StaffFab +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.SwitchRow +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.scan.BarcodeAnalyzer +import com.pinakes.app.ui.screens.staff.scan.ScannerBox +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** Localized label of a periodical issue status (raw value for states the app does not know). */ +internal fun issueStatusLabel(status: String): Int? = when (status) { + "posseduto" -> R.string.staff_issue_status_owned + "mancante" -> R.string.staff_issue_status_missing + "atteso" -> R.string.staff_issue_status_expected + "smarrito" -> R.string.staff_issue_status_lost + "reclamato" -> R.string.staff_issue_status_claimed + "scartato" -> R.string.staff_issue_status_discarded + else -> null +} + +// ============================================================================================= +// Desk: scan an issue, find a masthead +// ============================================================================================= + +data class PeriodicalsDeskUiState( + val manual: String = "", + val looking: Boolean = false, + val scan: PeriodicalScan? = null, + val scannedCode: String = "", + val busy: Boolean = false, + val query: String = "", + val periodicals: List = emptyList(), + val nextCursor: String? = null, + val loading: Boolean = true, + val error: StaffMessage? = null, + val message: StaffMessage? = null, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffPeriodicalsDeskViewModel @Inject constructor(private val tier2: StaffTier2Repository) : ViewModel() { + private val _state = MutableStateFlow(PeriodicalsDeskUiState()) + val state: StateFlow = _state.asStateFlow() + private val debouncer = ScanDebouncer() + private var searchJob: Job? = null + + init { reload() } + + fun reload() { + viewModelScope.launch { + val res = tier2.periodicals(_state.value.query, null) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(periodicals = res.data.items, nextCursor = res.data.nextCursor, loading = false, error = null) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun loadMore() { + val cursor = _state.value.nextCursor ?: return + val q = _state.value.query + viewModelScope.launch { + val res = tier2.periodicals(q, cursor) + if (res is ApiResult.Success && _state.value.query == q) { + _state.update { it.copy(periodicals = (it.periodicals + res.data.items).distinctBy(PeriodicalSummary::id), nextCursor = res.data.nextCursor) } + } + } + } + + fun onQuery(value: String) { + _state.update { it.copy(query = value) } + searchJob?.cancel() + searchJob = viewModelScope.launch { delay(350); reload() } + } + + fun onDecoded(raw: String) { + if (_state.value.looking || _state.value.scan != null) return + val code = raw.filter(Char::isDigit).takeIf { it.isNotEmpty() } ?: return + val accepted = debouncer.offer(code, SystemClock.elapsedRealtime()) ?: return + lookup(accepted) + } + + fun onManual(value: String) = _state.update { it.copy(manual = value.take(30)) } + + fun submitManual() { + val code = _state.value.manual.trim() + if (code.isEmpty() || _state.value.looking) return + _state.update { it.copy(manual = "") } + lookup(code) + } + + private fun lookup(code: String) { + debouncer.pause() + _state.update { it.copy(looking = true, scannedCode = code) } + viewModelScope.launch { + val res = tier2.scanLookup(code) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(looking = false, scan = res.data) + is ApiResult.Failure -> it.copy(looking = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_load)) + } + } + if (_state.value.scan == null) debouncer.resume(SystemClock.elapsedRealtime()) + } + } + + fun dismissScan() { + _state.update { it.copy(scan = null) } + debouncer.resume(SystemClock.elapsedRealtime()) + } + + /** Receive or claim the scanned issue. */ + fun act(receive: Boolean) { + val issue = _state.value.scan?.issue ?: return + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = if (receive) tier2.receiveIssue(issue.id) else tier2.claimIssue(issue.id) + _state.update { + when (res) { + is ApiResult.Success -> it.copy( + busy = false, scan = null, + message = StaffMessage(if (receive) R.string.staff_issue_received else R.string.staff_issue_claimed, args = listOf(res.data.number, res.data.periodical)), + ) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + if (_state.value.scan == null) debouncer.resume(SystemClock.elapsedRealtime()) + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** The periodicals desk: scan an arriving issue (EAN-13 with its add-on) or type the code, then receive or claim it; mastheads below. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffPeriodicalsDeskScreen(onNavigateUp: () -> Unit, onOpenPeriodical: (Int) -> Unit) { + val vm: StaffPeriodicalsDeskViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.periodicals, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_periodicals), onNavigateUp = onNavigateUp) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + LazyColumn(Modifier.fillMaxSize().padding(padding).imePadding(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + item(key = "scanner") { + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm), modifier = Modifier.padding(top = Spacing.sm)) { + ScannerBox( + formats = BarcodeAnalyzer.ISBN_FORMATS, onCode = vm::onDecoded, looking = state.looking, + permissionText = stringResource(R.string.staff_periodicals_scan_permission), + ) + PinakesTextField( + value = state.manual, onValueChange = vm::onManual, label = stringResource(R.string.staff_periodicals_code), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { vm.submitManual() }), + trailingIcon = { IconButton(onClick = vm::submitManual) { Icon(Icons.Outlined.Search, contentDescription = stringResource(R.string.staff_scan_lookup)) } }, + modifier = Modifier.fillMaxWidth().testTag("issue-code"), + ) + FormSectionTitle(stringResource(R.string.staff_periodicals_mastheads)) + SearchField(query = state.query, onQueryChange = vm::onQuery, placeholder = stringResource(R.string.staff_periodicals_search), modifier = Modifier.fillMaxWidth()) + state.error?.let { InlineErrorBanner(message = it.resolve(), onRetry = vm::reload) } + } + } + items(state.periodicals, key = { it.id }) { p -> + StaffActionRow( + Icons.Outlined.Newspaper, p.title, + listOfNotNull(p.issn, stringResource(R.string.staff_periodicals_counts, p.yearsCount, p.issuesCount)).joinToString(" · "), + ) { onOpenPeriodical(p.id) } + } + if (state.nextCursor != null) item(key = "more") { LaunchedEffect(state.periodicals.size) { vm.loadMore() } } + } + } + + state.scan?.let { scan -> + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { if (!state.busy) vm.dismissScan() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column(Modifier.fillMaxWidth().padding(horizontal = Spacing.lg).navigationBarsPadding().testTag("issue-sheet"), verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + val issue = scan.issue + when { + scan.match == "issue" && issue != null -> { + Text(issue.title, style = MaterialTheme.typography.headlineSmall) + Text(stringResource(R.string.staff_issue_line, issue.number, issue.year), style = MaterialTheme.typography.bodyLarge) + Text(issueStatusLabel(issue.status)?.let { stringResource(it) } ?: issue.status, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary) + if (IssueStatus.receivable(issue.status)) { + PrimaryButton(stringResource(R.string.staff_issue_receive), onClick = { vm.act(receive = true) }, loading = state.busy, + modifier = Modifier.fillMaxWidth().testTag("issue-receive")) + } + if (IssueStatus.claimable(issue.status)) { + SecondaryButton(stringResource(R.string.staff_issue_claim), onClick = { vm.act(receive = false) }, enabled = !state.busy, modifier = Modifier.fillMaxWidth()) + } + SecondaryButton(stringResource(R.string.staff_periodicals_open), onClick = { vm.dismissScan(); onOpenPeriodical(issue.periodicalId) }, modifier = Modifier.fillMaxWidth()) + } + scan.title != null -> { + Text(scan.title.title, style = MaterialTheme.typography.headlineSmall) + Text(scan.message ?: stringResource(R.string.staff_periodicals_title_only), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + PrimaryButton(stringResource(R.string.staff_periodicals_open), onClick = { vm.dismissScan(); onOpenPeriodical(scan.title.id) }, modifier = Modifier.fillMaxWidth()) + } + else -> { + Text(stringResource(if (scan.match == "ambiguous") R.string.staff_periodicals_ambiguous else R.string.staff_periodicals_no_match), style = MaterialTheme.typography.titleMedium) + Text(scan.message ?: state.scannedCode, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + PinakesTextButton(stringResource(R.string.staff_scan_next), onClick = vm::dismissScan, modifier = Modifier.align(Alignment.CenterHorizontally)) + Spacer(Modifier.height(Spacing.lg)) + } + } + } +} + +// ============================================================================================= +// One masthead: years, issues, subscriptions +// ============================================================================================= + +data class IssueDraft(val yearId: Int, val number: String = "", val date: String? = null, val status: String = "atteso") +data class YearDraft(val year: String = "", val volume: String = "", val bound: Boolean = false) + +data class PeriodicalUiState( + val detail: PeriodicalDetail? = null, + val loading: Boolean = true, + val loadError: StaffMessage? = null, + val expanded: Set = emptySet(), + val issues: Map> = emptyMap(), + val subscriptions: List = emptyList(), + val issueDraft: IssueDraft? = null, + val yearDraft: YearDraft? = null, + val subscription: SubscriptionForm? = null, + val busy: Boolean = false, + val formError: StaffMessage? = null, + val message: StaffMessage? = null, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffPeriodicalViewModel @Inject constructor( + private val tier2: StaffTier2Repository, + savedState: SavedStateHandle, +) : ViewModel() { + private val periodicalId: Int = savedState.get(Routes.ARG_PERIODICAL_ID) ?: 0 + private val _state = MutableStateFlow(PeriodicalUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load() } + + fun load() { + _state.update { it.copy(loading = it.detail == null, loadError = null) } + viewModelScope.launch { + val res = tier2.periodical(periodicalId) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(detail = res.data, loading = false) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), loadError = res.toStaffMessage(R.string.staff_error_load)) + } + } + (tier2.subscriptions(periodicalId) as? ApiResult.Success)?.data?.let { subs -> _state.update { it.copy(subscriptions = subs) } } + _state.value.expanded.forEach { loadIssues(it) } + } + } + + fun toggleYear(yearId: Int) { + val open = yearId !in _state.value.expanded + _state.update { s -> s.copy(expanded = if (open) s.expanded + yearId else s.expanded - yearId) } + if (open) loadIssues(yearId) + } + + private fun loadIssues(yearId: Int) { + viewModelScope.launch { + val res = tier2.yearIssues(yearId) + if (res is ApiResult.Success) _state.update { it.copy(issues = it.issues + (yearId to res.data)) } + else if (res is ApiResult.Failure) _state.update { it.copy(forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_load)) } + } + } + + fun issueAction(issue: PeriodicalIssue, yearId: Int, receive: Boolean) { + if (_state.value.busy) return + _state.update { it.copy(busy = true) } + viewModelScope.launch { + val res = if (receive) tier2.receiveIssue(issue.id) else tier2.claimIssue(issue.id) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(busy = false, message = StaffMessage(if (receive) R.string.staff_issue_received else R.string.staff_issue_claimed, args = listOf(res.data.number, res.data.periodical))) + is ApiResult.Failure -> it.copy(busy = false, forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) + } + } + loadIssues(yearId) + } + } + + fun startIssue(yearId: Int) = _state.update { it.copy(issueDraft = IssueDraft(yearId), formError = null) } + fun editIssue(transform: (IssueDraft) -> IssueDraft) = _state.update { s -> s.copy(issueDraft = s.issueDraft?.let(transform), formError = null) } + fun startYear() = _state.update { it.copy(yearDraft = YearDraft(), formError = null) } + fun editYear(transform: (YearDraft) -> YearDraft) = _state.update { s -> s.copy(yearDraft = s.yearDraft?.let(transform), formError = null) } + fun startSubscription(existing: StaffSubscription?) = _state.update { + it.copy(subscription = existing?.let(SubscriptionForm::from) ?: SubscriptionForm(periodicalId = periodicalId), formError = null) + } + fun editSubscription(transform: (SubscriptionForm) -> SubscriptionForm) = _state.update { s -> s.copy(subscription = s.subscription?.let(transform), formError = null) } + fun closeForms() = _state.update { it.copy(issueDraft = null, yearDraft = null, subscription = null, formError = null) } + + fun saveIssue() { + val draft = _state.value.issueDraft ?: return + if (draft.number.isBlank() || _state.value.busy) return + busy { + when (val res = tier2.addIssue(draft.yearId, draft.number, draft.date, draft.status)) { + is ApiResult.Success -> { done(R.string.staff_issue_added); loadIssues(draft.yearId); _state.update { it.copy(expanded = it.expanded + draft.yearId) } } + is ApiResult.Failure -> failed(res) + } + } + } + + fun saveYear() { + val draft = _state.value.yearDraft ?: return + val year = draft.year.trim().toIntOrNull() ?: return + busy { + when (val res = tier2.addYear(periodicalId, year, draft.volume, draft.bound)) { + is ApiResult.Success -> { done(R.string.staff_year_added); load() } + is ApiResult.Failure -> failed(res) + } + } + } + + fun saveSubscription() { + val form = _state.value.subscription ?: return + if (!form.valid) return + busy { + when (val res = tier2.saveSubscription(form.id, form.toJson())) { + is ApiResult.Success -> { + done(R.string.staff_subscription_saved) + (tier2.subscriptions(periodicalId) as? ApiResult.Success)?.data?.let { subs -> _state.update { it.copy(subscriptions = subs) } } + } + is ApiResult.Failure -> failed(res) + } + } + } + + private fun busy(block: suspend () -> Unit) { + if (_state.value.busy) return + _state.update { it.copy(busy = true, formError = null) } + viewModelScope.launch { block() } + } + + private fun done(message: Int) = _state.update { it.copy(busy = false, issueDraft = null, yearDraft = null, subscription = null, message = StaffMessage(message)) } + private fun failed(res: ApiResult.Failure) = _state.update { it.copy(busy = false, forbidden = res.isForbidden(), formError = res.toStaffMessage(R.string.staff_error_save)) } + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffPeriodicalScreen(onNavigateUp: () -> Unit) { + val vm: StaffPeriodicalViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.periodicals, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { PinakesTopBar(title = state.detail?.title ?: stringResource(R.string.staff_periodicals), onNavigateUp = onNavigateUp) }, + floatingActionButton = { if (state.detail != null) StaffFab(Icons.Outlined.Add, stringResource(R.string.staff_year_add), vm::startYear) }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + val detail = state.detail + when { + state.loading -> LoadingState(modifier = Modifier.padding(padding)) + detail == null -> ErrorState(message = state.loadError?.resolve() ?: stringResource(R.string.staff_error_load), onRetry = vm::load, modifier = Modifier.padding(padding)) + else -> LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp)) { + item(key = "facts") { + InfoCard(Modifier.padding(top = Spacing.sm)) { + InfoLine("ISSN", detail.issn) + InfoLine(stringResource(R.string.staff_periodicals_frequency), detail.frequency) + InfoLine(stringResource(R.string.staff_periodicals_holdings), detail.holdings) + } + } + item(key = "years-title") { FormSectionTitle(stringResource(R.string.staff_periodicals_years)) } + detail.years.forEach { year -> + item(key = "y${year.id}") { + Surface( + onClick = { vm.toggleYear(year.id) }, shape = MaterialTheme.shapes.medium, color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("year-${year.id}"), + ) { + Row(Modifier.padding(Spacing.md), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(listOfNotNull(year.year.toString(), year.volume).joinToString(" · "), style = MaterialTheme.typography.bodyLarge) + Text(stringResource(R.string.staff_periodicals_year_counts, year.ownedCount, year.issuesCount), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Icon(if (year.id in state.expanded) Icons.Outlined.ExpandLess else Icons.Outlined.ExpandMore, contentDescription = null) + } + } + } + if (year.id in state.expanded) { + items(state.issues[year.id].orEmpty(), key = { "i${it.id}" }) { issue -> + Row(Modifier.fillMaxWidth().padding(start = Spacing.lg, top = Spacing.xs, bottom = Spacing.xs).testTag("issue-${issue.id}"), + verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.staff_issue_number, issue.number ?: issue.sequence ?: issue.id.toString()), style = MaterialTheme.typography.bodyMedium) + Text(listOfNotNull(issueStatusLabel(issue.status)?.let { stringResource(it) } ?: issue.status, + (issue.coverDate ?: issue.publicationDate)?.let(DateFormat::date)).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (IssueStatus.receivable(issue.status)) { + PinakesTextButton(stringResource(R.string.staff_issue_receive), onClick = { vm.issueAction(issue, year.id, true) }, enabled = !state.busy) + } else if (IssueStatus.claimable(issue.status)) { + PinakesTextButton(stringResource(R.string.staff_issue_claim), onClick = { vm.issueAction(issue, year.id, false) }, enabled = !state.busy) + } + } + } + item(key = "add-${year.id}") { + PinakesTextButton(stringResource(R.string.staff_issue_add), onClick = { vm.startIssue(year.id) }, modifier = Modifier.padding(start = Spacing.md)) + } + } + } + item(key = "subs-title") { FormSectionTitle(stringResource(R.string.staff_subscriptions)) } + items(state.subscriptions, key = { "s${it.id}" }) { s -> + StaffActionRow(Icons.Outlined.Newspaper, s.supplier, + listOfNotNull(s.endDate?.let { stringResource(R.string.staff_subscription_until, DateFormat.date(it)) }, + if (s.active) null else stringResource(R.string.staff_subscription_inactive)).joinToString(" · ").ifBlank { null }) { vm.startSubscription(s) } + } + item(key = "subs-add") { SecondaryButton(stringResource(R.string.staff_subscription_add), onClick = { vm.startSubscription(null) }, modifier = Modifier.fillMaxWidth()) } + } + } + } + + state.issueDraft?.let { draft -> + FormSheet(stringResource(R.string.staff_issue_add), state.formError, state.busy, enabled = draft.number.isNotBlank(), onSave = vm::saveIssue, onDismiss = vm::closeForms) { + PinakesTextField(draft.number, { v -> vm.editIssue { it.copy(number = v) } }, stringResource(R.string.staff_issue_number_label), modifier = Modifier.fillMaxWidth().testTag("issue-number")) + DatePickerField(stringResource(R.string.staff_issue_date), draft.date, { v -> vm.editIssue { it.copy(date = v) } }, allowClear = true) + LookupField( + label = stringResource(R.string.staff_copy_status), value = draft.status, + displayValue = issueStatusLabel(draft.status)?.let { stringResource(it) } ?: draft.status, + options = IssueStatus.ALL.map { LookupOption(it, issueStatusLabel(it)?.let { r -> stringResource(r) } ?: it) }, + editable = false, allowClear = false, onValueChange = { v -> vm.editIssue { it.copy(status = v) } }, modifier = Modifier.fillMaxWidth(), + ) + } + } + state.yearDraft?.let { draft -> + FormSheet(stringResource(R.string.staff_year_add), state.formError, state.busy, enabled = draft.year.trim().toIntOrNull() != null, onSave = vm::saveYear, onDismiss = vm::closeForms) { + PinakesTextField(draft.year, { v -> vm.editYear { it.copy(year = v.filter(Char::isDigit).take(4)) } }, stringResource(R.string.staff_year_label), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.fillMaxWidth().testTag("year-input")) + PinakesTextField(draft.volume, { v -> vm.editYear { it.copy(volume = v) } }, stringResource(R.string.staff_year_volume), modifier = Modifier.fillMaxWidth()) + SwitchRow(stringResource(R.string.staff_year_bound), draft.bound, { v -> vm.editYear { it.copy(bound = v) } }) + } + } + state.subscription?.let { form -> + FormSheet(stringResource(if (form.id > 0) R.string.staff_subscription_edit else R.string.staff_subscription_add), state.formError, state.busy, + enabled = form.valid, onSave = vm::saveSubscription, onDismiss = vm::closeForms) { + PinakesTextField(form.supplier, { v -> vm.editSubscription { it.copy(supplier = v) } }, stringResource(R.string.staff_subscription_supplier), + modifier = Modifier.fillMaxWidth().testTag("subscription-supplier")) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + PinakesTextField(form.cost, { v -> vm.editSubscription { it.copy(cost = v) } }, stringResource(R.string.staff_subscription_cost), + isError = !form.costValid, errorText = stringResource(R.string.staff_field_invalid), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), modifier = Modifier.weight(2f)) + PinakesTextField(form.currency, { v -> vm.editSubscription { it.copy(currency = v.take(3).uppercase()) } }, stringResource(R.string.staff_subscription_currency), modifier = Modifier.weight(1f)) + } + DatePickerField(stringResource(R.string.staff_subscription_start), form.startDate, { v -> vm.editSubscription { it.copy(startDate = v) } }, allowClear = true) + DatePickerField(stringResource(R.string.staff_subscription_end), form.endDate, { v -> vm.editSubscription { it.copy(endDate = v) } }, allowClear = true) + SwitchRow(stringResource(R.string.staff_subscription_auto_renew), form.autoRenew, { v -> vm.editSubscription { it.copy(autoRenew = v) } }) + SwitchRow(stringResource(R.string.staff_subscription_active), form.active, { v -> vm.editSubscription { it.copy(active = v) } }) + PinakesTextField(form.notes, { v -> vm.editSubscription { it.copy(notes = v) } }, stringResource(R.string.staff_field_notes), singleLine = false, + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp)) + } + } +} + +/** A bottom-sheet form with an error banner and a Save button (Tier 2 forms). */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun FormSheet( + title: String, + error: StaffMessage?, + saving: Boolean, + enabled: Boolean, + onSave: () -> Unit, + onDismiss: () -> Unit, + content: @Composable () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ModalBottomSheet(onDismissRequest = { if (!saving) onDismiss() }, sheetState = sheetState, containerColor = MaterialTheme.colorScheme.background) { + Column( + Modifier.fillMaxWidth().verticalScroll(rememberScrollState()).padding(horizontal = Spacing.lg).navigationBarsPadding().imePadding(), + verticalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + Text(title, style = MaterialTheme.typography.headlineSmall, maxLines = 2, overflow = TextOverflow.Ellipsis) + error?.let { InlineErrorBanner(message = it.resolve()) } + content() + PrimaryButton(stringResource(R.string.action_save), onClick = onSave, loading = saving, enabled = enabled, modifier = Modifier.fillMaxWidth().testTag("form-save")) + Spacer(Modifier.height(Spacing.lg)) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffWorksScreens.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffWorksScreens.kt new file mode 100644 index 0000000..ac7af73 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/tier2/StaffWorksScreens.kt @@ -0,0 +1,483 @@ +package com.pinakes.app.ui.screens.staff.tier2 + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.MenuBook +import androidx.compose.material.icons.outlined.Add +import androidx.compose.material.icons.outlined.AccountTree +import androidx.compose.material.icons.outlined.DeleteOutline +import androidx.compose.material.icons.outlined.LinkOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.LookupOption +import com.pinakes.app.data.model.StaffWork +import com.pinakes.app.data.model.WorkExpression +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.StaffRepository +import com.pinakes.app.data.repository.StaffTier2Repository +import com.pinakes.app.data.repository.isForbidden +import com.pinakes.app.data.staff.ExpressionForm +import com.pinakes.app.data.staff.WorkForm +import com.pinakes.app.ui.common.AppViewModel +import com.pinakes.app.ui.components.ConfirmDialog +import com.pinakes.app.ui.components.ErrorState +import com.pinakes.app.ui.components.LoadingState +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PinakesTopBar +import com.pinakes.app.ui.components.SearchField +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.navigation.Routes +import com.pinakes.app.ui.screens.staff.ForbiddenExit +import com.pinakes.app.ui.screens.staff.FormSectionTitle +import com.pinakes.app.ui.screens.staff.InfoCard +import com.pinakes.app.ui.screens.staff.InfoLine +import com.pinakes.app.ui.screens.staff.LeaveWhen +import com.pinakes.app.ui.screens.staff.LookupField +import com.pinakes.app.ui.screens.staff.PickItem +import com.pinakes.app.ui.screens.staff.PickedField +import com.pinakes.app.ui.screens.staff.SearchController +import com.pinakes.app.ui.screens.staff.SearchPickerSheet +import com.pinakes.app.ui.screens.staff.StaffActionRow +import com.pinakes.app.ui.screens.staff.StaffFab +import com.pinakes.app.ui.screens.staff.StaffLoadBody +import com.pinakes.app.ui.screens.staff.StaffMessage +import com.pinakes.app.ui.screens.staff.StaffSnackbarEffect +import com.pinakes.app.ui.screens.staff.resolve +import com.pinakes.app.ui.screens.staff.toStaffMessage +import com.pinakes.app.ui.theme.Spacing +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +internal fun expressionTypeLabel(type: String?): Int = when (type) { + "traduzione" -> R.string.staff_expression_translation + "revisione" -> R.string.staff_expression_revision + "adattamento" -> R.string.staff_expression_adaptation + "edizione_critica" -> R.string.staff_expression_critical + "audio" -> R.string.staff_expression_audio + "altro" -> R.string.staff_expression_other + else -> R.string.staff_expression_text +} + +/** Work form fields shared by create (list) and edit (detail). */ +@Composable +private fun WorkFormSheet(title: String, form: WorkForm, error: StaffMessage?, saving: Boolean, onEdit: ((WorkForm) -> WorkForm) -> Unit, + onPickAuthor: () -> Unit, onSave: () -> Unit, onDismiss: () -> Unit) { + FormSheet(title, error, saving, enabled = form.valid, onSave = onSave, onDismiss = onDismiss) { + PinakesTextField(form.uniformTitle, { v -> onEdit { it.copy(uniformTitle = v) } }, stringResource(R.string.staff_work_uniform_title), maxLength = 255, + modifier = Modifier.fillMaxWidth().testTag("work-title")) + PinakesTextField(form.originalTitle, { v -> onEdit { it.copy(originalTitle = v) } }, stringResource(R.string.staff_work_original_title), modifier = Modifier.fillMaxWidth()) + PickedField(stringResource(R.string.staff_work_main_author), form.mainAuthorName.ifBlank { null }, stringResource(R.string.staff_search_authors), onClick = onPickAuthor, + onClear = { onEdit { it.copy(mainAuthorId = null, mainAuthorName = "") } }) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + PinakesTextField(form.createdFrom, { v -> onEdit { it.copy(createdFrom = v.filter { c -> c.isDigit() || c == '-' }.take(5)) } }, stringResource(R.string.staff_work_created_from), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f)) + PinakesTextField(form.createdTo, { v -> onEdit { it.copy(createdTo = v.filter { c -> c.isDigit() || c == '-' }.take(5)) } }, stringResource(R.string.staff_work_created_to), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f)) + } + PinakesTextField(form.originalLanguage, { v -> onEdit { it.copy(originalLanguage = v) } }, stringResource(R.string.staff_work_language), modifier = Modifier.fillMaxWidth()) + PinakesTextField(form.viafWorkId, { v -> onEdit { it.copy(viafWorkId = v) } }, "VIAF", modifier = Modifier.fillMaxWidth()) + PinakesTextField(form.wikidataId, { v -> onEdit { it.copy(wikidataId = v) } }, "Wikidata", modifier = Modifier.fillMaxWidth()) + PinakesTextField(form.notes, { v -> onEdit { it.copy(notes = v) } }, stringResource(R.string.staff_field_notes), singleLine = false, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp)) + } +} + +// ============================================================================================= +// List +// ============================================================================================= + +data class WorksUiState( + val query: String = "", + val works: List = emptyList(), + val loading: Boolean = true, + val error: StaffMessage? = null, + val creating: WorkForm? = null, + val pickingAuthor: Boolean = false, + val saving: Boolean = false, + val formError: StaffMessage? = null, + val created: Int? = null, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffWorksViewModel @Inject constructor(private val tier2: StaffTier2Repository, private val staff: StaffRepository) : ViewModel() { + private val _state = MutableStateFlow(WorksUiState()) + val state: StateFlow = _state.asStateFlow() + private var job: Job? = null + + val authors = SearchController(viewModelScope, minLength = 2) { q -> + when (val res = staff.authors(q, 20)) { + is ApiResult.Success -> ApiResult.Success(res.data.map { PickItem(it.id, it.display) }) + is ApiResult.Failure -> res + } + } + + fun reload() { + job?.cancel() + job = viewModelScope.launch { + if (_state.value.works.isNotEmpty()) delay(300) + val res = tier2.works(_state.value.query) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(works = res.data, loading = false, error = null) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), error = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun onQuery(value: String) { _state.update { it.copy(query = value) }; reload() } + fun startCreate() = _state.update { it.copy(creating = WorkForm(uniformTitle = it.query.trim()), formError = null) } + fun edit(transform: (WorkForm) -> WorkForm) = _state.update { s -> s.copy(creating = s.creating?.let(transform), formError = null) } + fun pickAuthor(open: Boolean) { _state.update { it.copy(pickingAuthor = open) }; if (!open) authors.clear() } + fun setAuthor(item: PickItem) { edit { it.copy(mainAuthorId = item.id, mainAuthorName = item.title) }; pickAuthor(false) } + fun close() = _state.update { it.copy(creating = null) } + + fun create() { + val form = _state.value.creating ?: return + if (!form.valid || _state.value.saving) return + _state.update { it.copy(saving = true) } + viewModelScope.launch { + val res = tier2.saveWork(null, form.toJson()) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(saving = false, creating = null, created = res.data.id) + is ApiResult.Failure -> it.copy(saving = false, forbidden = res.isForbidden(), formError = res.toStaffMessage(R.string.staff_error_save)) + } + } + } + } + + fun consumeCreated() = _state.update { it.copy(created = null) } +} + +/** FRBR works (the abstract work behind several editions), searchable, with a create sheet. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffWorksScreen(onNavigateUp: () -> Unit, onOpen: (Int) -> Unit) { + val vm: StaffWorksViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.works, onNavigateUp) + LaunchedEffect(Unit) { vm.reload() } + LaunchedEffect(state.created) { state.created?.let { vm.consumeCreated(); onOpen(it) } } + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.staff_works), onNavigateUp = onNavigateUp) }, + floatingActionButton = { StaffFab(Icons.Outlined.Add, stringResource(R.string.staff_work_new), vm::startCreate) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + SearchField(query = state.query, onQueryChange = vm::onQuery, placeholder = stringResource(R.string.staff_works_search), + modifier = Modifier.fillMaxWidth().padding(horizontal = Spacing.lg, vertical = Spacing.sm)) + StaffLoadBody( + loading = state.loading, error = state.error, isEmpty = state.works.isEmpty(), + emptyTitle = stringResource(R.string.staff_works_empty), emptyIcon = Icons.Outlined.AccountTree, onRetry = vm::reload, + ) { + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 96.dp)) { + items(state.works, key = { it.id }) { w -> + StaffActionRow(Icons.Outlined.AccountTree, w.uniformTitle.orEmpty(), + listOfNotNull(w.mainAuthorName, w.editionsCount?.let { stringResource(R.string.staff_work_editions_count, it) }).joinToString(" · ").ifBlank { null }) { onOpen(w.id) } + } + } + } + } + } + state.creating?.let { form -> + WorkFormSheet(stringResource(R.string.staff_work_new), form, state.formError, state.saving, vm::edit, { vm.pickAuthor(true) }, vm::create, vm::close) + } + if (state.pickingAuthor) { + val s by vm.authors.state.collectAsStateWithLifecycle() + SearchPickerSheet(stringResource(R.string.staff_work_main_author), stringResource(R.string.staff_search_authors), s, vm.authors::onQuery, vm::setAuthor, { vm.pickAuthor(false) }) + } +} + +// ============================================================================================= +// Detail +// ============================================================================================= + +enum class WorkPicker { AUTHOR, BOOK } + +data class WorkUiState( + val work: StaffWork? = null, + val loading: Boolean = true, + val loadError: StaffMessage? = null, + val editing: WorkForm? = null, + val expression: ExpressionForm? = null, + val picker: WorkPicker? = null, + val confirmDelete: Boolean = false, + val busy: Boolean = false, + val formError: StaffMessage? = null, + val message: StaffMessage? = null, + val deleted: Boolean = false, + val forbidden: Boolean = false, +) + +@HiltViewModel +class StaffWorkViewModel @Inject constructor( + private val tier2: StaffTier2Repository, + private val staff: StaffRepository, + savedState: SavedStateHandle, +) : ViewModel() { + private val workId: Int = savedState.get(Routes.ARG_WORK_ID) ?: 0 + private val _state = MutableStateFlow(WorkUiState()) + val state: StateFlow = _state.asStateFlow() + + val search = SearchController(viewModelScope, minLength = 2) { q -> + if (_state.value.picker == WorkPicker.BOOK) { + when (val res = staff.books(q, cursor = null, limit = 20)) { + is ApiResult.Success -> ApiResult.Success(res.data.items.map { PickItem(it.id, it.title, it.author) }) + is ApiResult.Failure -> res + } + } else { + when (val res = staff.authors(q, 20)) { + is ApiResult.Success -> ApiResult.Success(res.data.map { PickItem(it.id, it.display) }) + is ApiResult.Failure -> res + } + } + }.also { c -> c.onFailure = { f -> if (f.isForbidden()) _state.update { it.copy(forbidden = true) } } } + + init { load() } + + fun load() { + viewModelScope.launch { + val res = tier2.work(workId) + _state.update { + when (res) { + is ApiResult.Success -> it.copy(work = res.data, loading = false, loadError = null) + is ApiResult.Failure -> it.copy(loading = false, forbidden = res.isForbidden(), loadError = res.toStaffMessage(R.string.staff_error_load)) + } + } + } + } + + fun startEdit() = _state.update { s -> s.copy(editing = s.work?.let(WorkForm::from), formError = null) } + fun editForm(transform: (WorkForm) -> WorkForm) = _state.update { s -> s.copy(editing = s.editing?.let(transform), formError = null) } + fun startExpression(e: WorkExpression?) = _state.update { it.copy(expression = e?.let(ExpressionForm::from) ?: ExpressionForm(), formError = null) } + fun editExpression(transform: (ExpressionForm) -> ExpressionForm) = _state.update { s -> s.copy(expression = s.expression?.let(transform), formError = null) } + fun openPicker(kind: WorkPicker?) { _state.update { it.copy(picker = kind) }; if (kind == null) search.clear() } + fun closeForms() = _state.update { it.copy(editing = null, expression = null, formError = null) } + + fun picked(item: PickItem) { + when (_state.value.picker) { + WorkPicker.AUTHOR -> editForm { it.copy(mainAuthorId = item.id, mainAuthorName = item.title) } + WorkPicker.BOOK -> attach(item.id, workId) + null -> Unit + } + openPicker(null) + } + + fun saveWork() { + val form = _state.value.editing ?: return + if (!form.valid) return + run(R.string.staff_work_saved) { + when (val res = tier2.saveWork(workId, form.toJson())) { + is ApiResult.Success -> { _state.update { it.copy(work = res.data, editing = null) }; null } + is ApiResult.Failure -> res + } + } + } + + fun saveExpression() { + val form = _state.value.expression ?: return + if (!form.valid) return + run(R.string.staff_expression_saved) { + when (val res = tier2.saveExpression(workId, form.id, form.toJson())) { + is ApiResult.Success -> { _state.update { it.copy(expression = null) }; reloadWork(); null } + is ApiResult.Failure -> res + } + } + } + + fun deleteExpression(e: WorkExpression) = run(R.string.staff_expression_deleted) { + when (val res = tier2.deleteExpression(e.id)) { + is ApiResult.Success -> { reloadWork(); null } + is ApiResult.Failure -> res + } + } + + /** Link a book to this work, or unlink it (null). */ + fun attach(bookId: Int, target: Int?) = run(if (target == null) R.string.staff_work_book_detached else R.string.staff_work_book_attached) { + when (val res = tier2.attachBook(bookId, target)) { + is ApiResult.Success -> { reloadWork(); null } + is ApiResult.Failure -> res + } + } + + private suspend fun reloadWork() { + (tier2.work(workId) as? ApiResult.Success)?.data?.let { w -> _state.update { it.copy(work = w) } } + } + + private fun run(success: Int, block: suspend () -> ApiResult.Failure?) { + if (_state.value.busy) return + _state.update { it.copy(busy = true, formError = null) } + viewModelScope.launch { + val failure = block() + _state.update { + if (failure == null) it.copy(busy = false, message = StaffMessage(success)) + else it.copy(busy = false, forbidden = failure.isForbidden(), formError = failure.toStaffMessage(R.string.staff_error_save), + message = if (it.editing == null && it.expression == null) failure.toStaffMessage(R.string.staff_error_save) else null) + } + } + } + + fun askDelete() = _state.update { it.copy(confirmDelete = true) } + fun dismissDelete() = _state.update { it.copy(confirmDelete = false) } + + fun delete() { + _state.update { it.copy(confirmDelete = false) } + viewModelScope.launch { + when (val res = tier2.deleteWork(workId)) { + is ApiResult.Success -> _state.update { it.copy(deleted = true) } + is ApiResult.Failure -> _state.update { it.copy(forbidden = res.isForbidden(), message = res.toStaffMessage(R.string.staff_error_save)) } + } + } + } + + fun consumeMessage() = _state.update { it.copy(message = null) } +} + +/** One work: its data, its expressions (translations, editions…) and the books linked to it. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StaffWorkScreen(onNavigateUp: () -> Unit, onOpenBook: (Int) -> Unit) { + val vm: StaffWorkViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val app: AppViewModel = hiltViewModel() + val areas by app.staffAreas.collectAsStateWithLifecycle() + val snackbar = remember { SnackbarHostState() } + ForbiddenExit(state.forbidden, onNavigateUp) + LeaveWhen(!areas.works || state.deleted, onNavigateUp) + StaffSnackbarEffect(state.message, snackbar, vm::consumeMessage) + + Scaffold( + topBar = { + PinakesTopBar( + title = state.work?.uniformTitle ?: stringResource(R.string.staff_works), onNavigateUp = onNavigateUp, + actions = { if (state.work != null) IconButton(onClick = vm::askDelete) { Icon(Icons.Outlined.DeleteOutline, contentDescription = stringResource(R.string.staff_work_delete)) } }, + ) + }, + snackbarHost = { SnackbarHost(snackbar) }, + ) { padding -> + val w = state.work + when { + state.loading -> LoadingState(modifier = Modifier.padding(padding)) + w == null -> ErrorState(message = state.loadError?.resolve() ?: stringResource(R.string.staff_error_load), onRetry = vm::load, modifier = Modifier.padding(padding)) + else -> LazyColumn(Modifier.fillMaxSize().padding(padding), contentPadding = PaddingValues(start = Spacing.lg, end = Spacing.lg, bottom = 48.dp)) { + item(key = "facts") { + InfoCard(Modifier.padding(top = Spacing.sm)) { + InfoLine(stringResource(R.string.staff_work_original_title), w.originalTitle) + InfoLine(stringResource(R.string.staff_work_main_author), w.mainAuthorName) + InfoLine(stringResource(R.string.staff_work_created), listOfNotNull(w.createdFrom, w.createdTo).distinct().joinToString("–").ifBlank { null }) + InfoLine(stringResource(R.string.staff_work_language), w.originalLanguage) + InfoLine("VIAF", w.viafWorkId) + InfoLine("Wikidata", w.wikidataId) + InfoLine(stringResource(R.string.staff_field_notes), w.notes) + } + SecondaryButton(stringResource(R.string.staff_work_edit), onClick = vm::startEdit, modifier = Modifier.fillMaxWidth().padding(top = Spacing.sm)) + } + item(key = "expr-title") { FormSectionTitle(stringResource(R.string.staff_work_expressions)) } + items(w.expressions, key = { "e${it.id}" }) { e -> + Row(Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("expression-${e.id}"), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(e.title?.takeIf { it.isNotBlank() } ?: stringResource(expressionTypeLabel(e.type)), style = MaterialTheme.typography.bodyLarge) + Text(listOfNotNull(stringResource(expressionTypeLabel(e.type)), e.language, e.year?.toString()).joinToString(" · "), + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + IconButton(onClick = { vm.startExpression(e) }) { Icon(Icons.AutoMirrored.Outlined.MenuBook, contentDescription = stringResource(R.string.staff_expression_edit)) } + IconButton(onClick = { vm.deleteExpression(e) }, enabled = !state.busy) { Icon(Icons.Outlined.DeleteOutline, contentDescription = stringResource(R.string.staff_expression_delete)) } + } + } + item(key = "expr-add") { SecondaryButton(stringResource(R.string.staff_expression_add), onClick = { vm.startExpression(null) }, leadingIcon = Icons.Outlined.Add, modifier = Modifier.fillMaxWidth()) } + item(key = "books-title") { FormSectionTitle(stringResource(R.string.staff_work_books)) } + items(w.editions, key = { "b${it.id}" }) { b -> + Row(Modifier.fillMaxWidth().padding(vertical = Spacing.xs).testTag("edition-${b.id}"), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text(b.title, style = MaterialTheme.typography.bodyLarge) + Text(listOfNotNull(b.year?.toString(), b.isbn13).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + IconButton(onClick = { onOpenBook(b.id) }) { Icon(Icons.AutoMirrored.Outlined.MenuBook, contentDescription = stringResource(R.string.staff_open_book)) } + IconButton(onClick = { vm.attach(b.id, null) }, enabled = !state.busy) { Icon(Icons.Outlined.LinkOff, contentDescription = stringResource(R.string.staff_work_detach)) } + } + } + item(key = "attach") { + SecondaryButton(stringResource(R.string.staff_work_attach), onClick = { vm.openPicker(WorkPicker.BOOK) }, leadingIcon = Icons.Outlined.Add, modifier = Modifier.fillMaxWidth()) + } + } + } + } + + state.editing?.let { form -> + WorkFormSheet(stringResource(R.string.staff_work_edit), form, state.formError, state.busy, vm::editForm, { vm.openPicker(WorkPicker.AUTHOR) }, vm::saveWork, vm::closeForms) + } + state.expression?.let { form -> + FormSheet(stringResource(if (form.id > 0) R.string.staff_expression_edit else R.string.staff_expression_add), state.formError, state.busy, + enabled = form.valid, onSave = vm::saveExpression, onDismiss = vm::closeForms) { + LookupField( + label = stringResource(R.string.staff_expression_type), value = form.type, displayValue = stringResource(expressionTypeLabel(form.type)), + options = ExpressionForm.TYPES.map { LookupOption(it, stringResource(expressionTypeLabel(it))) }, editable = false, allowClear = false, + onValueChange = { v -> vm.editExpression { it.copy(type = v) } }, modifier = Modifier.fillMaxWidth(), + ) + PinakesTextField(form.title, { v -> vm.editExpression { it.copy(title = v) } }, stringResource(R.string.staff_field_title_plain), modifier = Modifier.fillMaxWidth().testTag("expression-title")) + Row(horizontalArrangement = Arrangement.spacedBy(Spacing.sm)) { + PinakesTextField(form.language, { v -> vm.editExpression { it.copy(language = v) } }, stringResource(R.string.staff_work_language), modifier = Modifier.weight(2f)) + PinakesTextField(form.year, { v -> vm.editExpression { it.copy(year = v.filter(Char::isDigit).take(4)) } }, stringResource(R.string.staff_field_year), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), modifier = Modifier.weight(1f)) + } + PinakesTextField(form.notes, { v -> vm.editExpression { it.copy(notes = v) } }, stringResource(R.string.staff_field_notes), singleLine = false, modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp)) + } + } + state.picker?.let { kind -> + val s by vm.search.state.collectAsStateWithLifecycle() + SearchPickerSheet( + title = stringResource(if (kind == WorkPicker.BOOK) R.string.staff_work_attach else R.string.staff_work_main_author), + placeholder = stringResource(if (kind == WorkPicker.BOOK) R.string.staff_books_search else R.string.staff_search_authors), + state = s, onQuery = vm.search::onQuery, onPick = vm::picked, onDismiss = { vm.openPicker(null) }, hint = stringResource(R.string.staff_search_min_chars), + ) + } + if (state.confirmDelete) { + ConfirmDialog( + title = stringResource(R.string.staff_work_delete), body = stringResource(R.string.staff_work_delete_body, state.work?.uniformTitle.orEmpty()), + confirmLabel = stringResource(R.string.staff_work_delete), onConfirm = vm::delete, onDismiss = vm::dismissDelete, + ) + } +} diff --git a/app/src/test/java/com/pinakes/app/staff/Tier2FormsTest.kt b/app/src/test/java/com/pinakes/app/staff/Tier2FormsTest.kt new file mode 100644 index 0000000..29f80d1 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/staff/Tier2FormsTest.kt @@ -0,0 +1,114 @@ +package com.pinakes.app.staff + +import com.pinakes.app.data.model.ClubState +import com.pinakes.app.data.model.PeriodicalScan +import com.pinakes.app.data.model.StaffClub +import com.pinakes.app.data.model.StaffWork +import com.pinakes.app.data.staff.ClubForm +import com.pinakes.app.data.staff.ExpressionForm +import com.pinakes.app.data.staff.IssueStatus +import com.pinakes.app.data.staff.StaffAreas +import com.pinakes.app.data.staff.SubscriptionForm +import com.pinakes.app.data.staff.WorkForm +import com.pinakes.app.data.staff.WorkflowDraft +import com.pinakes.app.data.store.InstanceFeatures +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Tier 2 bridges: tolerant parsing of the real shapes, and the form bodies sent back. */ +class Tier2FormsTest { + + private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; isLenient = true; coerceInputValues = true } + + @Test fun scanLookupShapes() { + val issue = json.decodeFromString( + """{"found":true,"match":"issue","action":"receive","issue":{"id":4,"numero":"12","stato":"atteso","annata_id":2,"anno":2026,"testata_id":16,"titolo":"Internazionale","url":"x"},"message":"m"}""" + ) + assertEquals("12", issue.issue?.number) + assertEquals(16, issue.issue?.periodicalId) + val title = json.decodeFromString("""{"found":true,"match":"title","action":null,"title":{"id":16,"titolo":"Internazionale"}}""") + assertEquals("Internazionale", title.title?.title) + val none = json.decodeFromString("""{"found":false,"match":"none","action":null,"message":"Nessuna corrispondenza"}""") + assertFalse(none.found) + } + + @Test fun issueStatusRules() { + assertTrue(IssueStatus.receivable("atteso") && IssueStatus.receivable("reclamato")) + assertFalse(IssueStatus.receivable("posseduto")) + assertTrue(IssueStatus.claimable("mancante")) + assertFalse(IssueStatus.claimable("posseduto") || IssueStatus.claimable("scartato")) + } + + @Test fun subscriptionBody() { + val form = SubscriptionForm(periodicalId = 16, supplier = " Edicola ", cost = "12,50", startDate = "2026-01-01", endDate = "2026-12-31") + assertTrue(form.valid) + val body = form.toJson() + assertEquals(16, body["periodical_id"]!!.jsonPrimitive.int) + assertEquals("Edicola", body["supplier"]!!.jsonPrimitive.content) + assertEquals(12.5, body["cost"]!!.jsonPrimitive.content.toDouble(), 0.0) + assertFalse(SubscriptionForm(id = 3, periodicalId = 16, supplier = "x").toJson().containsKey("periodical_id")) + assertFalse(form.copy(endDate = "2025-01-01").valid) + assertFalse(form.copy(cost = "abc").valid) + } + + @Test fun clubWorkflowParsesEmptyFlagsAndRoundTrips() { + val club = json.decodeFromString( + """{"id":1,"name":"C","privacy":"public","workflow":[{"key":"proposed","label":"Proposto","color":"#6b7280","flags":{"votable":true}}, + {"key":"voting","label":"In votazione","color":"#8b5cf6","flags":{"voting":true}},{"key":"selected","label":"Scelto","color":"#0ea5e9","flags":[]}], + "members":[{"id":5,"user_id":3,"name":"Ada","email":"a@x","role":"member","role_name":"Membro","status":"active"}]}""" + ) + assertTrue(club.workflow[1].flag("voting")) + assertFalse(club.workflow[2].flag("voting")) + val draft = WorkflowDraft.from(club.workflow) + val moved = draft.move(2, -1) + assertEquals(listOf("proposed", "selected", "voting"), moved.states.map { it.key }) + assertEquals(draft, draft.move(0, -1)) + assertEquals(2, draft.remove(0).states.size) + assertEquals(2, WorkflowDraft(draft.states.take(2)).remove(0).states.size) + val states = draft.add("Nuovo").toJson()["states"]!!.jsonArray + assertEquals(4, states.size) + assertEquals("", states[3].jsonObject["key"]!!.jsonPrimitive.content) + assertTrue(states[1].jsonObject["flags"]!!.jsonObject["voting"]!!.jsonPrimitive.boolean) + assertFalse(WorkflowDraft(draft.states.take(1)).valid) + } + + @Test fun clubFormValidation() { + assertTrue(ClubForm(name = "Lettori").valid) + assertFalse(ClubForm(name = "Lettori", color = "red").valid) + assertFalse(ClubForm(name = "Lettori", maxMembers = "0").valid) + assertEquals(JsonNull, ClubForm(name = "L").toJson()["max_members"]) + assertEquals(12, ClubForm(name = "L", maxMembers = "12").toJson()["max_members"]!!.jsonPrimitive.int) + } + + @Test fun workAndExpressionBodies() { + val w = json.decodeFromString("""{"id":2,"uniform_title":"Odissea","main_author_id":7,"created_from":-725,"expressions":[{"id":3,"work_id":2,"type":"traduzione","language":"ita","year":1963}]}""") + val form = WorkForm.from(w) + assertEquals("-725", form.createdFrom) + assertTrue(form.valid) + val body = form.copy(createdTo = "").toJson() + assertEquals(-725, body["created_from"]!!.jsonPrimitive.int) + assertEquals(JsonNull, body["created_to"]) + assertFalse(WorkForm().valid) + val e = ExpressionForm.from(w.expressions.single()) + assertEquals("traduzione", e.type) + assertEquals(1963, e.toJson()["year"]!!.jsonPrimitive.int) + assertFalse(e.copy(type = "film").valid) + } + + @Test fun tier2AreasNeedTheirFlags() { + val f = InstanceFeatures(staffCatalogAvailable = true, staffPeriodicalsAvailable = true, staffBookClubAvailable = true, staffFrbrAvailable = true) + val a = StaffAreas.of("staff", f, online = true) + assertTrue(a.periodicals && a.bookClub && a.works && a.anyPlugin) + val none = StaffAreas.of("staff", InstanceFeatures(staffCatalogAvailable = true), online = true) + assertFalse(none.periodicals || none.bookClub || none.works || none.anyPlugin) + } +} diff --git a/i18n/de.json b/i18n/de.json index a746aa6..5af0865 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -1307,5 +1307,139 @@ "staff_digital_failed": "Die Datei konnte nicht angehängt werden.", "staff_select_all": "Alle auswählen", "staff_import_status_gone": "Dieser Import wurde anderswo beendet", - "staff_archive_document_too_big": "Das Dokument ist größer als 200 MB." + "staff_archive_document_too_big": "Das Dokument ist größer als 200 MB.", + "staff_periodicals": "Zeitschriftentheke", + "staff_periodicals_subtitle": "Hefte erfassen und reklamieren, Jahrgänge, Abonnements", + "staff_periodicals_scan_permission": "Die Kamera liest den Barcode des Hefts. Du kannst ihn auch unten eingeben.", + "staff_periodicals_code": "Heft-Barcode oder ISSN", + "staff_periodicals_mastheads": "Zeitschriften", + "staff_periodicals_search": "Zeitschrift suchen", + "staff_periodicals_counts": "Jahrgänge: %1$d · Hefte: %2$d", + "staff_periodicals_open": "Zeitschrift öffnen", + "staff_periodicals_title_only": "Kein Heft trägt diesen Code, er gehört aber zu dieser Zeitschrift.", + "staff_periodicals_ambiguous": "Mehrere Hefte tragen diesen Code", + "staff_periodicals_no_match": "Nichts passt zu diesem Code", + "staff_periodicals_frequency": "Erscheinungsweise", + "staff_periodicals_holdings": "Bestand", + "staff_periodicals_years": "Jahrgänge", + "staff_periodicals_year_counts": "Vorhanden: %1$d von %2$d", + "staff_issue_line": "Nr. %1$s · %2$d", + "staff_issue_number": "Nr. %1$s", + "staff_issue_number_label": "Heftnummer *", + "staff_issue_date": "Erscheinungsdatum", + "staff_issue_receive": "Erhalten", + "staff_issue_claim": "Reklamieren", + "staff_issue_add": "Heft hinzufügen", + "staff_issue_added": "Heft hinzugefügt", + "staff_issue_received": "Nr. %1$s von %2$s erhalten", + "staff_issue_claimed": "Nr. %1$s von %2$s reklamiert", + "staff_issue_status_owned": "Vorhanden", + "staff_issue_status_missing": "Fehlt", + "staff_issue_status_expected": "Erwartet", + "staff_issue_status_lost": "Verloren", + "staff_issue_status_claimed": "Reklamiert", + "staff_issue_status_discarded": "Ausgesondert", + "staff_year_add": "Jahrgang hinzufügen", + "staff_year_added": "Jahrgang hinzugefügt", + "staff_year_label": "Jahr *", + "staff_year_volume": "Band", + "staff_year_bound": "In einem Band gebunden", + "staff_subscriptions": "Abonnements", + "staff_subscription_add": "Abonnement hinzufügen", + "staff_subscription_edit": "Abonnement bearbeiten", + "staff_subscription_saved": "Abonnement gespeichert", + "staff_subscription_until": "Bis %1$s", + "staff_subscription_inactive": "Inaktiv", + "staff_subscription_supplier": "Lieferant *", + "staff_subscription_cost": "Kosten", + "staff_subscription_currency": "Währung", + "staff_subscription_start": "Beginn", + "staff_subscription_end": "Ende", + "staff_subscription_auto_renew": "Automatische Verlängerung", + "staff_subscription_active": "Aktiv", + "staff_clubs": "Lesekreise", + "staff_clubs_subtitle": "Einstellungen, Leseablauf und Mitglieder", + "staff_clubs_empty": "Noch keine Lesekreise", + "staff_club_new": "Neuer Lesekreis", + "staff_club_edit": "Einstellungen bearbeiten", + "staff_club_saved": "Lesekreis gespeichert", + "staff_club_delete": "Lesekreis löschen", + "staff_club_delete_body": "„%1$s“ mit Vorschlägen und Diskussionen löschen?", + "staff_club_rules": "Regeln", + "staff_club_privacy": "Sichtbarkeit", + "staff_club_privacy_public": "Öffentlich", + "staff_club_privacy_private": "Privat", + "staff_club_privacy_invite": "Auf Einladung", + "staff_club_privacy_hidden": "Verborgen", + "staff_club_color": "Farbe", + "staff_club_max_members": "Höchstzahl Mitglieder", + "staff_club_active": "Aktiv", + "staff_club_inactive": "Inaktiv", + "staff_club_moderate": "Vorschläge moderieren", + "staff_club_members_count": "Mitglieder: %1$d", + "staff_club_workflow": "Leseablauf", + "staff_club_workflow_edit": "Ablauf bearbeiten", + "staff_club_workflow_hint": "Mindestens zwei Zustände. Markieren, wo abgestimmt wird, was gerade gelesen wird und was archiviert ist.", + "staff_club_workflow_saved": "Ablauf gespeichert", + "staff_club_state_label": "Zustand", + "staff_club_state_add": "Zustand hinzufügen", + "staff_club_state_new": "Neuer Zustand", + "staff_club_flag_voting": "Abstimmung", + "staff_club_flag_current": "Wird gelesen", + "staff_club_flag_archived": "Archiviert", + "staff_move_up": "Nach oben", + "staff_move_down": "Nach unten", + "staff_club_members": "Mitglieder", + "staff_club_role": "Rolle", + "staff_club_role_owner": "Inhaber", + "staff_club_role_moderator": "Moderator", + "staff_club_role_member": "Mitglied", + "staff_club_role_guest": "Gast", + "staff_club_member_status": "Mitgliedschaft", + "staff_club_member_pending": "Ausstehend", + "staff_club_member_active": "Aktiv", + "staff_club_member_suspended": "Gesperrt", + "staff_club_member_left": "Ausgetreten", + "staff_club_member_banned": "Ausgeschlossen", + "staff_club_member_saved": "Mitglied aktualisiert", + "staff_club_member_add": "Mitglied hinzufügen", + "staff_club_member_add_hint": "Tritt bei als: %1$s", + "staff_club_member_added": "%1$s zum Lesekreis hinzugefügt", + "staff_works": "Werke (FRBR)", + "staff_works_subtitle": "Das Werk hinter Ausgaben und Übersetzungen", + "staff_works_search": "Werke suchen", + "staff_works_empty": "Noch keine Werke", + "staff_work_new": "Neues Werk", + "staff_work_edit": "Werk bearbeiten", + "staff_work_saved": "Werk gespeichert", + "staff_work_delete": "Werk löschen", + "staff_work_delete_body": "„%1$s“ löschen? Die Bücher bleiben ohne Werk im Katalog.", + "staff_work_uniform_title": "Einheitstitel *", + "staff_work_original_title": "Originaltitel", + "staff_work_main_author": "Hauptautor", + "staff_work_created_from": "Entstanden ab (Jahr)", + "staff_work_created_to": "Entstanden bis (Jahr)", + "staff_work_created": "Entstehung", + "staff_work_language": "Sprache", + "staff_work_editions_count": "Ausgaben: %1$d", + "staff_work_expressions": "Expressionen", + "staff_work_books": "Bücher dieses Werks", + "staff_work_attach": "Buch verknüpfen", + "staff_work_detach": "Buch lösen", + "staff_work_book_attached": "Buch mit dem Werk verknüpft", + "staff_work_book_detached": "Buch gelöst", + "staff_expression_add": "Expression hinzufügen", + "staff_expression_edit": "Expression bearbeiten", + "staff_expression_delete": "Expression löschen", + "staff_expression_saved": "Expression gespeichert", + "staff_expression_deleted": "Expression gelöscht", + "staff_expression_type": "Typ", + "staff_expression_text": "Text", + "staff_expression_translation": "Übersetzung", + "staff_expression_revision": "Bearbeitung", + "staff_expression_adaptation": "Adaption", + "staff_expression_critical": "Kritische Ausgabe", + "staff_expression_audio": "Audio", + "staff_expression_other": "Andere", + "staff_field_title_plain": "Titel" } diff --git a/i18n/en.json b/i18n/en.json index a8b0892..356b5ed 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1307,5 +1307,139 @@ "staff_digital_failed": "The file could not be attached.", "staff_select_all": "Select all", "staff_import_status_gone": "This import ended elsewhere", - "staff_archive_document_too_big": "The document is larger than 200 MB." + "staff_archive_document_too_big": "The document is larger than 200 MB.", + "staff_periodicals": "Periodicals desk", + "staff_periodicals_subtitle": "Receive and claim issues, years, subscriptions", + "staff_periodicals_scan_permission": "The camera reads the issue barcode. You can also type it below.", + "staff_periodicals_code": "Issue barcode or ISSN", + "staff_periodicals_mastheads": "Periodicals", + "staff_periodicals_search": "Search a periodical", + "staff_periodicals_counts": "Years: %1$d · issues: %2$d", + "staff_periodicals_open": "Open the periodical", + "staff_periodicals_title_only": "No issue carries this code, but it belongs to this periodical.", + "staff_periodicals_ambiguous": "Several issues carry this code", + "staff_periodicals_no_match": "Nothing matches this code", + "staff_periodicals_frequency": "Frequency", + "staff_periodicals_holdings": "Holdings", + "staff_periodicals_years": "Years", + "staff_periodicals_year_counts": "Owned: %1$d of %2$d", + "staff_issue_line": "No. %1$s · %2$d", + "staff_issue_number": "No. %1$s", + "staff_issue_number_label": "Issue number *", + "staff_issue_date": "Publication date", + "staff_issue_receive": "Received", + "staff_issue_claim": "Claim", + "staff_issue_add": "Add an issue", + "staff_issue_added": "Issue added", + "staff_issue_received": "No. %1$s of %2$s received", + "staff_issue_claimed": "No. %1$s of %2$s claimed", + "staff_issue_status_owned": "Owned", + "staff_issue_status_missing": "Missing", + "staff_issue_status_expected": "Expected", + "staff_issue_status_lost": "Lost", + "staff_issue_status_claimed": "Claimed", + "staff_issue_status_discarded": "Discarded", + "staff_year_add": "Add a year", + "staff_year_added": "Year added", + "staff_year_label": "Year *", + "staff_year_volume": "Volume", + "staff_year_bound": "Bound into one volume", + "staff_subscriptions": "Subscriptions", + "staff_subscription_add": "Add a subscription", + "staff_subscription_edit": "Edit subscription", + "staff_subscription_saved": "Subscription saved", + "staff_subscription_until": "Until %1$s", + "staff_subscription_inactive": "Inactive", + "staff_subscription_supplier": "Supplier *", + "staff_subscription_cost": "Cost", + "staff_subscription_currency": "Currency", + "staff_subscription_start": "Start", + "staff_subscription_end": "End", + "staff_subscription_auto_renew": "Renews automatically", + "staff_subscription_active": "Active", + "staff_clubs": "Book clubs", + "staff_clubs_subtitle": "Settings, reading workflow and members", + "staff_clubs_empty": "No book clubs yet", + "staff_club_new": "New club", + "staff_club_edit": "Edit settings", + "staff_club_saved": "Club saved", + "staff_club_delete": "Delete club", + "staff_club_delete_body": "Delete “%1$s” with its proposals and discussions?", + "staff_club_rules": "Rules", + "staff_club_privacy": "Visibility", + "staff_club_privacy_public": "Public", + "staff_club_privacy_private": "Private", + "staff_club_privacy_invite": "By invitation", + "staff_club_privacy_hidden": "Hidden", + "staff_club_color": "Colour", + "staff_club_max_members": "Maximum members", + "staff_club_active": "Active", + "staff_club_inactive": "Inactive", + "staff_club_moderate": "Moderate the proposals", + "staff_club_members_count": "Members: %1$d", + "staff_club_workflow": "Reading workflow", + "staff_club_workflow_edit": "Edit the workflow", + "staff_club_workflow_hint": "At least two states. Mark where members vote, what is being read now, and what is archived.", + "staff_club_workflow_saved": "Workflow saved", + "staff_club_state_label": "State", + "staff_club_state_add": "Add a state", + "staff_club_state_new": "New state", + "staff_club_flag_voting": "Voting", + "staff_club_flag_current": "Reading now", + "staff_club_flag_archived": "Archived", + "staff_move_up": "Move up", + "staff_move_down": "Move down", + "staff_club_members": "Members", + "staff_club_role": "Role", + "staff_club_role_owner": "Owner", + "staff_club_role_moderator": "Moderator", + "staff_club_role_member": "Member", + "staff_club_role_guest": "Guest", + "staff_club_member_status": "Membership", + "staff_club_member_pending": "Pending", + "staff_club_member_active": "Active", + "staff_club_member_suspended": "Suspended", + "staff_club_member_left": "Left", + "staff_club_member_banned": "Banned", + "staff_club_member_saved": "Member updated", + "staff_club_member_add": "Add a member", + "staff_club_member_add_hint": "They join as: %1$s", + "staff_club_member_added": "%1$s added to the club", + "staff_works": "Works (FRBR)", + "staff_works_subtitle": "The work behind its editions and translations", + "staff_works_search": "Search works", + "staff_works_empty": "No works yet", + "staff_work_new": "New work", + "staff_work_edit": "Edit the work", + "staff_work_saved": "Work saved", + "staff_work_delete": "Delete work", + "staff_work_delete_body": "Delete “%1$s”? Its books stay in the catalogue without a work.", + "staff_work_uniform_title": "Uniform title *", + "staff_work_original_title": "Original title", + "staff_work_main_author": "Main author", + "staff_work_created_from": "Created from (year)", + "staff_work_created_to": "Created until (year)", + "staff_work_created": "Created", + "staff_work_language": "Language", + "staff_work_editions_count": "Editions: %1$d", + "staff_work_expressions": "Expressions", + "staff_work_books": "Books of this work", + "staff_work_attach": "Link a book", + "staff_work_detach": "Unlink the book", + "staff_work_book_attached": "Book linked to the work", + "staff_work_book_detached": "Book unlinked", + "staff_expression_add": "Add an expression", + "staff_expression_edit": "Edit expression", + "staff_expression_delete": "Delete expression", + "staff_expression_saved": "Expression saved", + "staff_expression_deleted": "Expression deleted", + "staff_expression_type": "Type", + "staff_expression_text": "Text", + "staff_expression_translation": "Translation", + "staff_expression_revision": "Revision", + "staff_expression_adaptation": "Adaptation", + "staff_expression_critical": "Critical edition", + "staff_expression_audio": "Audio", + "staff_expression_other": "Other", + "staff_field_title_plain": "Title" } diff --git a/i18n/fr.json b/i18n/fr.json index 5f4b133..1cdb18d 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -1307,5 +1307,139 @@ "staff_digital_failed": "Le fichier n’a pas pu être joint.", "staff_select_all": "Tout sélectionner", "staff_import_status_gone": "Cet import s’est terminé ailleurs", - "staff_archive_document_too_big": "Le document dépasse 200 Mo." + "staff_archive_document_too_big": "Le document dépasse 200 Mo.", + "staff_periodicals": "Banque des périodiques", + "staff_periodicals_subtitle": "Recevoir et réclamer des numéros, années, abonnements", + "staff_periodicals_scan_permission": "L’appareil photo lit le code-barres du numéro. Vous pouvez aussi le saisir ci-dessous.", + "staff_periodicals_code": "Code-barres du numéro ou ISSN", + "staff_periodicals_mastheads": "Titres de presse", + "staff_periodicals_search": "Rechercher un titre", + "staff_periodicals_counts": "Années : %1$d · numéros : %2$d", + "staff_periodicals_open": "Ouvrir le titre", + "staff_periodicals_title_only": "Aucun numéro ne porte ce code, mais il appartient à ce titre.", + "staff_periodicals_ambiguous": "Plusieurs numéros portent ce code", + "staff_periodicals_no_match": "Rien ne correspond à ce code", + "staff_periodicals_frequency": "Périodicité", + "staff_periodicals_holdings": "État de collection", + "staff_periodicals_years": "Années", + "staff_periodicals_year_counts": "Possédés : %1$d sur %2$d", + "staff_issue_line": "N° %1$s · %2$d", + "staff_issue_number": "N° %1$s", + "staff_issue_number_label": "Numéro *", + "staff_issue_date": "Date de publication", + "staff_issue_receive": "Reçu", + "staff_issue_claim": "Réclamer", + "staff_issue_add": "Ajouter un numéro", + "staff_issue_added": "Numéro ajouté", + "staff_issue_received": "N° %1$s de %2$s reçu", + "staff_issue_claimed": "N° %1$s de %2$s réclamé", + "staff_issue_status_owned": "Possédé", + "staff_issue_status_missing": "Manquant", + "staff_issue_status_expected": "Attendu", + "staff_issue_status_lost": "Perdu", + "staff_issue_status_claimed": "Réclamé", + "staff_issue_status_discarded": "Écarté", + "staff_year_add": "Ajouter une année", + "staff_year_added": "Année ajoutée", + "staff_year_label": "Année *", + "staff_year_volume": "Volume", + "staff_year_bound": "Reliée en un volume", + "staff_subscriptions": "Abonnements", + "staff_subscription_add": "Ajouter un abonnement", + "staff_subscription_edit": "Modifier l’abonnement", + "staff_subscription_saved": "Abonnement enregistré", + "staff_subscription_until": "Jusqu’au %1$s", + "staff_subscription_inactive": "Inactif", + "staff_subscription_supplier": "Fournisseur *", + "staff_subscription_cost": "Coût", + "staff_subscription_currency": "Devise", + "staff_subscription_start": "Début", + "staff_subscription_end": "Fin", + "staff_subscription_auto_renew": "Renouvellement automatique", + "staff_subscription_active": "Actif", + "staff_clubs": "Clubs de lecture", + "staff_clubs_subtitle": "Réglages, parcours de lecture et membres", + "staff_clubs_empty": "Aucun club de lecture pour l’instant", + "staff_club_new": "Nouveau club", + "staff_club_edit": "Modifier les réglages", + "staff_club_saved": "Club enregistré", + "staff_club_delete": "Supprimer le club", + "staff_club_delete_body": "Supprimer « %1$s » avec ses propositions et discussions ?", + "staff_club_rules": "Règles", + "staff_club_privacy": "Visibilité", + "staff_club_privacy_public": "Public", + "staff_club_privacy_private": "Privé", + "staff_club_privacy_invite": "Sur invitation", + "staff_club_privacy_hidden": "Masqué", + "staff_club_color": "Couleur", + "staff_club_max_members": "Membres maximum", + "staff_club_active": "Actif", + "staff_club_inactive": "Inactif", + "staff_club_moderate": "Modérer les propositions", + "staff_club_members_count": "Membres : %1$d", + "staff_club_workflow": "Parcours de lecture", + "staff_club_workflow_edit": "Modifier le parcours", + "staff_club_workflow_hint": "Au moins deux états. Indiquez où l’on vote, ce qui est lu en ce moment et ce qui est archivé.", + "staff_club_workflow_saved": "Parcours enregistré", + "staff_club_state_label": "État", + "staff_club_state_add": "Ajouter un état", + "staff_club_state_new": "Nouvel état", + "staff_club_flag_voting": "Vote", + "staff_club_flag_current": "En lecture", + "staff_club_flag_archived": "Archivé", + "staff_move_up": "Monter", + "staff_move_down": "Descendre", + "staff_club_members": "Membres", + "staff_club_role": "Rôle", + "staff_club_role_owner": "Propriétaire", + "staff_club_role_moderator": "Modérateur", + "staff_club_role_member": "Membre", + "staff_club_role_guest": "Invité", + "staff_club_member_status": "Adhésion", + "staff_club_member_pending": "En attente", + "staff_club_member_active": "Actif", + "staff_club_member_suspended": "Suspendu", + "staff_club_member_left": "Parti", + "staff_club_member_banned": "Exclu", + "staff_club_member_saved": "Membre mis à jour", + "staff_club_member_add": "Ajouter un membre", + "staff_club_member_add_hint": "Il rejoint comme : %1$s", + "staff_club_member_added": "%1$s ajouté au club", + "staff_works": "Œuvres (FRBR)", + "staff_works_subtitle": "L’œuvre derrière ses éditions et traductions", + "staff_works_search": "Rechercher des œuvres", + "staff_works_empty": "Aucune œuvre pour l’instant", + "staff_work_new": "Nouvelle œuvre", + "staff_work_edit": "Modifier l’œuvre", + "staff_work_saved": "Œuvre enregistrée", + "staff_work_delete": "Supprimer l’œuvre", + "staff_work_delete_body": "Supprimer « %1$s » ? Ses livres restent au catalogue sans œuvre.", + "staff_work_uniform_title": "Titre uniforme *", + "staff_work_original_title": "Titre original", + "staff_work_main_author": "Auteur principal", + "staff_work_created_from": "Créée à partir de (année)", + "staff_work_created_to": "Créée jusqu’à (année)", + "staff_work_created": "Création", + "staff_work_language": "Langue", + "staff_work_editions_count": "Éditions : %1$d", + "staff_work_expressions": "Expressions", + "staff_work_books": "Livres de cette œuvre", + "staff_work_attach": "Lier un livre", + "staff_work_detach": "Délier le livre", + "staff_work_book_attached": "Livre lié à l’œuvre", + "staff_work_book_detached": "Livre délié", + "staff_expression_add": "Ajouter une expression", + "staff_expression_edit": "Modifier l’expression", + "staff_expression_delete": "Supprimer l’expression", + "staff_expression_saved": "Expression enregistrée", + "staff_expression_deleted": "Expression supprimée", + "staff_expression_type": "Type", + "staff_expression_text": "Texte", + "staff_expression_translation": "Traduction", + "staff_expression_revision": "Révision", + "staff_expression_adaptation": "Adaptation", + "staff_expression_critical": "Édition critique", + "staff_expression_audio": "Audio", + "staff_expression_other": "Autre", + "staff_field_title_plain": "Titre" } diff --git a/i18n/it.json b/i18n/it.json index 14d6bfb..88efd3f 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -1307,5 +1307,139 @@ "staff_digital_failed": "Non è stato possibile allegare il file.", "staff_select_all": "Seleziona tutti", "staff_import_status_gone": "Questa importazione è terminata altrove", - "staff_archive_document_too_big": "Il documento supera i 200 MB." + "staff_archive_document_too_big": "Il documento supera i 200 MB.", + "staff_periodicals": "Banco emeroteca", + "staff_periodicals_subtitle": "Ricevi e reclama fascicoli, annate, abbonamenti", + "staff_periodicals_scan_permission": "La fotocamera legge il codice a barre del fascicolo. Puoi anche scriverlo qui sotto.", + "staff_periodicals_code": "Codice a barre del fascicolo o ISSN", + "staff_periodicals_mastheads": "Testate", + "staff_periodicals_search": "Cerca una testata", + "staff_periodicals_counts": "Annate: %1$d · fascicoli: %2$d", + "staff_periodicals_open": "Apri la testata", + "staff_periodicals_title_only": "Nessun fascicolo ha questo codice, ma appartiene a questa testata.", + "staff_periodicals_ambiguous": "Più fascicoli hanno questo codice", + "staff_periodicals_no_match": "Nessuna corrispondenza per questo codice", + "staff_periodicals_frequency": "Periodicità", + "staff_periodicals_holdings": "Consistenza", + "staff_periodicals_years": "Annate", + "staff_periodicals_year_counts": "Possedute: %1$d su %2$d", + "staff_issue_line": "N. %1$s · %2$d", + "staff_issue_number": "N. %1$s", + "staff_issue_number_label": "Numero del fascicolo *", + "staff_issue_date": "Data di pubblicazione", + "staff_issue_receive": "Ricevuto", + "staff_issue_claim": "Reclama", + "staff_issue_add": "Aggiungi un fascicolo", + "staff_issue_added": "Fascicolo aggiunto", + "staff_issue_received": "N. %1$s di %2$s ricevuto", + "staff_issue_claimed": "N. %1$s di %2$s reclamato", + "staff_issue_status_owned": "Posseduto", + "staff_issue_status_missing": "Mancante", + "staff_issue_status_expected": "Atteso", + "staff_issue_status_lost": "Smarrito", + "staff_issue_status_claimed": "Reclamato", + "staff_issue_status_discarded": "Scartato", + "staff_year_add": "Aggiungi un’annata", + "staff_year_added": "Annata aggiunta", + "staff_year_label": "Anno *", + "staff_year_volume": "Volume", + "staff_year_bound": "Rilegata in un volume", + "staff_subscriptions": "Abbonamenti", + "staff_subscription_add": "Aggiungi un abbonamento", + "staff_subscription_edit": "Modifica abbonamento", + "staff_subscription_saved": "Abbonamento salvato", + "staff_subscription_until": "Fino al %1$s", + "staff_subscription_inactive": "Non attivo", + "staff_subscription_supplier": "Fornitore *", + "staff_subscription_cost": "Costo", + "staff_subscription_currency": "Valuta", + "staff_subscription_start": "Inizio", + "staff_subscription_end": "Scadenza", + "staff_subscription_auto_renew": "Rinnovo automatico", + "staff_subscription_active": "Attivo", + "staff_clubs": "Gruppi di lettura", + "staff_clubs_subtitle": "Impostazioni, percorso di lettura e membri", + "staff_clubs_empty": "Ancora nessun gruppo di lettura", + "staff_club_new": "Nuovo gruppo", + "staff_club_edit": "Modifica impostazioni", + "staff_club_saved": "Gruppo salvato", + "staff_club_delete": "Elimina gruppo", + "staff_club_delete_body": "Eliminare «%1$s» con le proposte e le discussioni?", + "staff_club_rules": "Regole", + "staff_club_privacy": "Visibilità", + "staff_club_privacy_public": "Pubblico", + "staff_club_privacy_private": "Privato", + "staff_club_privacy_invite": "Su invito", + "staff_club_privacy_hidden": "Nascosto", + "staff_club_color": "Colore", + "staff_club_max_members": "Membri massimi", + "staff_club_active": "Attivo", + "staff_club_inactive": "Non attivo", + "staff_club_moderate": "Modera le proposte", + "staff_club_members_count": "Membri: %1$d", + "staff_club_workflow": "Percorso di lettura", + "staff_club_workflow_edit": "Modifica il percorso", + "staff_club_workflow_hint": "Almeno due stati. Indica dove si vota, cosa si sta leggendo e cosa è archiviato.", + "staff_club_workflow_saved": "Percorso salvato", + "staff_club_state_label": "Stato", + "staff_club_state_add": "Aggiungi uno stato", + "staff_club_state_new": "Nuovo stato", + "staff_club_flag_voting": "Votazione", + "staff_club_flag_current": "In lettura", + "staff_club_flag_archived": "Archiviato", + "staff_move_up": "Sposta su", + "staff_move_down": "Sposta giù", + "staff_club_members": "Membri", + "staff_club_role": "Ruolo", + "staff_club_role_owner": "Proprietario", + "staff_club_role_moderator": "Moderatore", + "staff_club_role_member": "Membro", + "staff_club_role_guest": "Ospite", + "staff_club_member_status": "Iscrizione", + "staff_club_member_pending": "In attesa", + "staff_club_member_active": "Attivo", + "staff_club_member_suspended": "Sospeso", + "staff_club_member_left": "Uscito", + "staff_club_member_banned": "Escluso", + "staff_club_member_saved": "Membro aggiornato", + "staff_club_member_add": "Aggiungi un membro", + "staff_club_member_add_hint": "Entra come: %1$s", + "staff_club_member_added": "%1$s aggiunto al gruppo", + "staff_works": "Opere (FRBR)", + "staff_works_subtitle": "L’opera dietro edizioni e traduzioni", + "staff_works_search": "Cerca opere", + "staff_works_empty": "Ancora nessuna opera", + "staff_work_new": "Nuova opera", + "staff_work_edit": "Modifica l’opera", + "staff_work_saved": "Opera salvata", + "staff_work_delete": "Elimina opera", + "staff_work_delete_body": "Eliminare «%1$s»? I suoi libri restano in catalogo senza opera.", + "staff_work_uniform_title": "Titolo uniforme *", + "staff_work_original_title": "Titolo originale", + "staff_work_main_author": "Autore principale", + "staff_work_created_from": "Creata dal (anno)", + "staff_work_created_to": "Creata fino al (anno)", + "staff_work_created": "Creazione", + "staff_work_language": "Lingua", + "staff_work_editions_count": "Edizioni: %1$d", + "staff_work_expressions": "Espressioni", + "staff_work_books": "Libri di quest’opera", + "staff_work_attach": "Collega un libro", + "staff_work_detach": "Scollega il libro", + "staff_work_book_attached": "Libro collegato all’opera", + "staff_work_book_detached": "Libro scollegato", + "staff_expression_add": "Aggiungi un’espressione", + "staff_expression_edit": "Modifica espressione", + "staff_expression_delete": "Elimina espressione", + "staff_expression_saved": "Espressione salvata", + "staff_expression_deleted": "Espressione eliminata", + "staff_expression_type": "Tipo", + "staff_expression_text": "Testo", + "staff_expression_translation": "Traduzione", + "staff_expression_revision": "Revisione", + "staff_expression_adaptation": "Adattamento", + "staff_expression_critical": "Edizione critica", + "staff_expression_audio": "Audio", + "staff_expression_other": "Altro", + "staff_field_title_plain": "Titolo" } From 9be8401cc8447c062c983957ced170447442ab4c Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 04:47:29 +0200 Subject: [PATCH 47/62] Extend the live smoke to the Tier 2 bridges and document them The live smoke looks up an unknown code at the periodicals desk (read-only: the bridge offers no deletes), and creates a book club and an FRBR work in the app, removing them in cleanup; long forms scroll to their Save button. STATUS and the Uwe parity notes now describe the periodicals desk, book club management and FRBR works as native, with the 19/19 live run. --- STATUS.md | 8 ++-- docs/UWE-PARITY.md | 2 +- tools/e2e/live_smoke.py | 85 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/STATUS.md b/STATUS.md index 9e5e41a..d9a6cf6 100644 --- a/STATUS.md +++ b/STATUS.md @@ -8,10 +8,10 @@ Requires Mobile API 1.7.0. Every area is gated on its own `/health` flag (`staff - **Copy scanner**: the same CameraX + ZXing pipeline reading CODE 128 and CODE 39 inventory labels (and EAN-13); manual code entry always visible; the sheet shows the copy and its open loan, then returns it with an outcome, lends it, or opens the book or the loan. - **Book tools**: per-copy list in the book editor (state, notes, delete with the server's refusal reasons, a copy with its own inventory code), book and copy label PDFs and the label settings sheet; series tree with search, details, rename (an existing name asks before merging), merge, delete, parent work, book numbers, removal and multi-select assignment; duplicate merges (suggested author groups or manual pick, publishers, genres); CSV and LibraryThing imports picked with the system document picker and run chunk by chunk from the server's progress (out-of-order recovery, busy retry, resumable after process death, pause/resume/cancel, error lines, past imports with their error CSV); missing covers fetched one book at a time with progress and the rate limit honoured; server-side cover sync and enrichment batches on the long-timeout client behind a confirmation; automatic enrichment switch. - **Plugin content**: Desiderata offers by status (accept, reject, receive into the wanted title or a picked record, delete) and wanted titles marked received; Archives units (published or not) created and edited with the plugin's rules checked client-side, publish switch, authorities with roles, cover and document uploads, delete; authority records list and editor. -- Local verification: 369 debug unit tests (new: tolerant 1.7.0 parsing, loan actions, penalty validation, copy codes and export file names, staff areas × flags × connectivity, idempotency keys, series tree and series/label forms, import chunk rules, archive/authority forms and offer actions), zero lint errors (111 warnings, all pre-existing), debug and R8 release builds. -- Device tests: 80 instrumented tests on Android 15 (`staffe2e` with `FakePinakes`). New flows: return as damaged with penalty, invalid penalty not sent, borrower page, 409 message, new loan with user search and Idempotency-Key, ineligible borrower, return by typed copy code, unknown code, reservation cancel and create, circulation hidden offline and restored, desk screen closing offline, no section without flags; copy edit, refused delete, copy with code, taken code; series number and rename clash merge; suggested author merge; import resume with out-of-order recovery and an upload through the real system document picker; covers loop and enrichment switch; desiderata receive (linked and into a picked record) and closed offer; archive publish keeping every field and create after validation; a retried merge already done; digital upload through the system picker and its absence without the flag. -- Live smoke (`tools/e2e/live_smoke.py`) gains: loan created by API with an Idempotency-Key and its replay then returned in the app by typing the copy code, a copy with an explicit code from the book editor, a series created in the app, a two-row CSV import followed to completion, a free Desiderata offer received into a picked record, an unpublished archive unit absent from the public list. Each phase 2 step is skipped while its flag is off; cleanup returns loans the run left open and deletes only the books, series, units and offers the run created. Run on 2026-10-14 against the local server (Mobile API 1.7.0 branch) on an Android 15 emulator: 16 passed, 0 skipped, 0 failed. -- Tier 2: eBook and audiobook upload from the book editor (`staff_digital_library`, streamed, long-timeout client). The periodicals desk, book clubs and FRBR works are not in this build: their contract is frozen only at endpoint level and the server does not offer them yet; their flags are parsed and every entry stays hidden. +- Local verification: 377 debug unit tests (new: tolerant 1.7.0 parsing, loan actions, penalty validation, copy codes and export file names, staff areas × flags × connectivity, idempotency keys, series tree and series/label forms, import chunk rules, archive/authority forms and offer actions), zero lint errors (111 warnings, all pre-existing), debug and R8 release builds. +- Device tests: 92 instrumented tests on Android 15 (`staffe2e` with `FakePinakes`). New flows: return as damaged with penalty, invalid penalty not sent, borrower page, 409 message, new loan with user search and Idempotency-Key, ineligible borrower, return by typed copy code, unknown code, reservation cancel and create, circulation hidden offline and restored, desk screen closing offline, no section without flags; copy edit, refused delete, copy with code, taken code; series number and rename clash merge; suggested author merge; import resume with out-of-order recovery and an upload through the real system document picker; covers loop and enrichment switch; desiderata receive (linked and into a picked record) and closed offer; archive publish keeping every field and create after validation; a retried merge already done; digital upload through the system picker and its absence without the flag; review regressions (bulk recall in batches with Select all, reservation paging, an authority that failed to load cannot be saved, a failed duplicate list with Retry, an import that ended elsewhere is not resumable, exports deleted on sign out); Tier 2 (a typed issue code received, a masthead's issues and a new year, a club created and a member's role changed, a workflow edited, a work created with an expression and a linked book, no Tier 2 rows without their flags). +- Live smoke (`tools/e2e/live_smoke.py`) gains: loan created by API with an Idempotency-Key and its replay then returned in the app by typing the copy code, a copy with an explicit code from the book editor, a series created in the app, a two-row CSV import followed to completion, a free Desiderata offer received into a picked record, an unpublished archive unit absent from the public list. Each phase 2 step is skipped while its flag is off; cleanup returns loans the run left open and deletes only the books, series, units and offers the run created. Tier 2 steps: an unknown code at the periodicals desk (read-only, the bridge has no deletes), a book club and an FRBR work created in the app (deleted in cleanup). Run on 2026-10-14 against the local server (Mobile API 1.7.0 branch, every staff flag on) on an Android 15 emulator: 19 passed, 0 skipped, 0 failed. +- **Tier 2** (each behind its own flag: `staff_periodicals`, `staff_bookclub`, `staff_frbr`, `staff_digital_library`): periodicals desk (scan or type an arriving issue's barcode, then receive or claim it; mastheads with years, issue states, new years and issues, subscriptions); book club management (create, settings, reading workflow with reorderable states and voting/reading/archived marks, members' role and membership state, adding a borrower, delete); FRBR works (search, create, edit, delete, expressions, link and unlink books); eBook and audiobook upload from the book editor (streamed, long-timeout client). The periodicals bridge has no article routes, so articles stay on the website. ## 1.7.0 (versionCode 20): staff catalogue, offline catalogue, loans calendar diff --git a/docs/UWE-PARITY.md b/docs/UWE-PARITY.md index ce6db23..2f4127d 100644 --- a/docs/UWE-PARITY.md +++ b/docs/UWE-PARITY.md @@ -27,7 +27,7 @@ I filtri di disponibilità si applicano ai libri con copie. La ricerca per gener - Backend richiesto: Mobile API 1.5.0 e plugin aggiornati (Desiderata 1.2.0, Emeroteca 1.13.0, Archives 1.5.1). Le nuove sezioni dipendono dalle capability del server. Un server precedente continua a funzionare, ma non offre la nuova API delle raccolte; per i filtri analitici non supportati l’app richiede l’aggiornamento. - La gestione amministrativa e gli upload degli articoli/archivi aprono il PHP protetto nel browser. Non sono nuovi editor CRUD nativi. - Aggiornamento 1.7.0 (Mobile API 1.6.0): per libri, autori, editori, generi e scaffali la gestione staff è ora nativa (lettore ISBN con modalità in serie, scheda manuale per materiale senza ISBN, copie, copertina). Articoli, archivi, utenti, prestiti e impostazioni restano sulle pagine PHP protette. Il catalogo è consultabile offline e i prestiti si possono sottoscrivere come calendario personale. -- Aggiornamento 1.8.0 (Mobile API 1.7.0): diventano native anche la circolazione (banco prestiti con contatori, richieste, ritiri, prestiti attivi e scaduti, storico, restituzione con esito e sanzione anche da codice copia scansionato, rinnovo, proroga e solleciti multipli, ricevuta PDF, nuovi prestiti e prenotazioni con calendario per utente), la gestione per singola copia con etichette, le collane, l’unione dei duplicati, l’importazione CSV e LibraryThing, copertine e arricchimento, le proposte Desiderata e le schede d’archivio con autorità e allegati. La circolazione sparisce senza connessione. Restano sul web: utenti, impostazioni generali, emeroteca e gruppi di lettura lato staff, opere FRBR. +- Aggiornamento 1.8.0 (Mobile API 1.7.0): diventano native anche la circolazione (banco prestiti con contatori, richieste, ritiri, prestiti attivi e scaduti, storico, restituzione con esito e sanzione anche da codice copia scansionato, rinnovo, proroga e solleciti multipli, ricevuta PDF, nuovi prestiti e prenotazioni con calendario per utente), la gestione per singola copia con etichette, le collane, l’unione dei duplicati, l’importazione CSV e LibraryThing, copertine e arricchimento, le proposte Desiderata e le schede d’archivio con autorità e allegati. La circolazione sparisce senza connessione. Anche il banco emeroteca (ricezione e reclamo dei fascicoli da codice a barre, annate, fascicoli, abbonamenti), la gestione dei gruppi di lettura (impostazioni, percorso di lettura, membri) e le opere FRBR (espressioni, libri collegati) sono native. Restano sul web: utenti, impostazioni generali e gli articoli dell’emeroteca. - [#52](https://github.com/fabiodalez-dev/Pinakes/issues/52), import danese FBI/DBC, resta una richiesta separata non implementata: token e documentazione completa richiedono accesso DBC, che non è disponibile in questa sessione. Non è una funzionalità già presente nel server da sincronizzare. - [#57](https://github.com/fabiodalez-dev/Pinakes/issues/57) descrive l’installazione dell’estensione YAZ nel container QNAP; Uwe precisa che non è un difetto Pinakes. Non viene dichiarata risolta modificando il client Android. - Nessuna merge, pubblicazione sugli store o distribuzione in produzione effettuata. Il caso ANR sul vecchio emulatore GUI resta distinto dai test riusciti sull’AVD headless; non si dichiara risolto un ANR su dispositivi reali. diff --git a/tools/e2e/live_smoke.py b/tools/e2e/live_smoke.py index c42f038..8e85a35 100755 --- a/tools/e2e/live_smoke.py +++ b/tools/e2e/live_smoke.py @@ -24,7 +24,9 @@ loan created by API with an Idempotency-Key and its replay, then returned in the app by typing the copy code; a copy with an explicit code from the book editor; a series created in the app; a two-row CSV import job followed to completion; a free Desiderata offer received into a picked record; an -unpublished archive unit created and checked absent from the public list; then airplane-mode +unpublished archive unit created and checked absent from the public list; Tier 2: an unknown +code looked up at the periodicals desk (read-only), a book club and an FRBR work created in the +app; then airplane-mode offline search and detail, calendar link + feed. Cleanup (only what this run created): created books deleted, the calendar link revoked when it did not exist before, device tokens issued during the run revoked, app data cleared, @@ -170,6 +172,10 @@ def encode_multipart(fields): CREATED_SERIES = [] # series ids to delete CREATED_ARCHIVES = [] # archive unit ids to delete CREATED_OFFERS = [] # desiderata offer ids to delete +CREATED_CLUBS = [] # book club ids to delete +CREATED_WORKS = [] # FRBR work ids to delete +CLUB_NAME = f"E2E club {RUN}" +WORK_TITLE = f"E2E work {RUN}" CIRC_TITLE = f"E2E circulation {RUN}" SERIES_NAME = f"E2E series {RUN}" IMPORT_TITLES = (f"E2E import A {RUN}", f"E2E import B {RUN}") @@ -653,6 +659,71 @@ def archive_create_delete(): return f"archive #{found[0]['id']}" +@step("periodicals desk: an unknown code is looked up, nothing is changed") +def periodicals_desk(): + if not staff_flag("staff_periodicals"): + raise SkipStep("staff_periodicals is off on this instance") + code = "0000000000017" + _, expected, _ = API.call("GET", "periodicals/staff/scan-lookup?code=" + code) + assert (expected.get("data") or {}).get("match") == "none", f"code {code} unexpectedly matches: {expected.get('data')}" + back_to_hub() + ui.scroll_to(S["staff_periodicals"]) + ui.tap(S["staff_periodicals"], exact=True) + deny_camera_prompt() + ui.tap(S["staff_periodicals_code"]) + ui.type_text(code) + ui.hide_keyboard() + ui.tap(S["staff_scan_lookup"]) + ui.wait(S["staff_periodicals_no_match"], 60) + ui.back() + back_to_hub() + return f"no match for {code}" + + +@step("book clubs: create a club in the app") +def club_create(): + if not staff_flag("staff_bookclub"): + raise SkipStep("staff_bookclub is off on this instance") + back_to_hub() + ui.scroll_to(S["staff_clubs"]) + ui.tap(S["staff_clubs"], exact=True) + ui.tap(S["staff_club_new"]) + ui.tap(S["staff_field_name"]) + ui.type_text(CLUB_NAME) + ui.hide_keyboard() + ui.scroll_to(S["action_save"], exact=True) + ui.tap(S["action_save"], exact=True) + ui.wait(S["staff_club_workflow_edit"], 60) # the new club's page + _, clubs, _ = API.call("GET", "bookclub/staff/clubs") + found = [c for c in clubs.get("data") or [] if c.get("name") == CLUB_NAME] + CREATED_CLUBS.extend(c["id"] for c in found) + assert len(found) == 1 and found[0].get("privacy") == "public", f"clubs named {CLUB_NAME!r}: {found}" + back_to_hub() + return f"club #{found[0]['id']}" + + +@step("FRBR: create a work in the app") +def work_create(): + if not staff_flag("staff_frbr"): + raise SkipStep("staff_frbr is off on this instance") + back_to_hub() + ui.scroll_to(S["staff_works"]) + ui.tap(S["staff_works"], exact=True) + ui.tap(S["staff_work_new"]) + ui.tap(S["staff_work_uniform_title"]) + ui.type_text(WORK_TITLE) + ui.hide_keyboard() + ui.scroll_to(S["action_save"], exact=True) # the work form is taller than the sheet + ui.tap(S["action_save"], exact=True) + ui.wait(S["staff_work_attach"], 60) # the new work's page + _, works, _ = API.call("GET", "staff/works?q=" + urllib.request.quote(WORK_TITLE)) + found = [w for w in works.get("data") or [] if w.get("uniform_title") == WORK_TITLE] + CREATED_WORKS.extend(w["id"] for w in found) + assert len(found) == 1, f"works titled {WORK_TITLE!r}: {found}" + back_to_hub() + return f"work #{found[0]['id']}" + + # ---------------------------------------------------------------- database (optional) DB_FLAGS = ("show_sesso", "show_cod_fiscale") @@ -789,6 +860,17 @@ def _cleanup_server(): for archive_id in CREATED_ARCHIVES: status, _, _ = API.call("DELETE", f"staff/archives/{archive_id}") print(f" delete archive unit #{archive_id}: HTTP {status}") + # Tier 2 records found by their run-unique names, in case a step failed half-way. + _, clubs, _ = API.call("GET", "bookclub/staff/clubs") + CREATED_CLUBS.extend(c["id"] for c in clubs.get("data") or [] if c.get("name") == CLUB_NAME and c["id"] not in CREATED_CLUBS) + _, works, _ = API.call("GET", "staff/works?q=" + urllib.request.quote(WORK_TITLE)) + CREATED_WORKS.extend(w["id"] for w in works.get("data") or [] if w.get("uniform_title") == WORK_TITLE and w["id"] not in CREATED_WORKS) + for club_id in CREATED_CLUBS: + status, _, _ = API.call("DELETE", f"bookclub/staff/clubs/{club_id}") + print(f" delete book club #{club_id}: HTTP {status}") + for work_id in CREATED_WORKS: + status, _, _ = API.call("DELETE", f"staff/works/{work_id}") + print(f" delete work #{work_id}: HTTP {status}") for offer_id in CREATED_OFFERS: status, _, _ = API.call("DELETE", f"staff/desiderata/offers/{offer_id}") print(f" delete offer #{offer_id}: HTTP {status}") @@ -818,6 +900,7 @@ def main(): if ok and onboarding_and_login(): for fn in (staff_hub, scan_add, scan_duplicate, manual_record, edit_location, circulation_return, copy_with_code, series_roundtrip, csv_import_two_rows, desiderata_receive, archive_create_delete, + periodicals_desk, club_create, work_create, offline, calendar, hidden_profile_fields): fn() finally: From e6c10f9cc389a82d8048328d73e58beae87c5593 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 08:33:40 +0200 Subject: [PATCH 48/62] Send the app language to the server with every request Server labels and error messages followed the installation's language, so an English app showed "Prestato" next to English text. The interceptor now adds Accept-Language with the language picked in Profile (or the device one) unless the request sets it; Mobile API 1.7.0 answers in it when the installation has that language and keeps its own otherwise. --- .../app/data/network/AuthInterceptor.kt | 13 +++++++++- .../pinakes/app/network/AcceptLanguageTest.kt | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/pinakes/app/network/AcceptLanguageTest.kt diff --git a/app/src/main/java/com/pinakes/app/data/network/AuthInterceptor.kt b/app/src/main/java/com/pinakes/app/data/network/AuthInterceptor.kt index d967171..b47dea4 100644 --- a/app/src/main/java/com/pinakes/app/data/network/AuthInterceptor.kt +++ b/app/src/main/java/com/pinakes/app/data/network/AuthInterceptor.kt @@ -1,7 +1,9 @@ package com.pinakes.app.data.network import com.pinakes.app.data.store.SessionStore +import com.pinakes.app.ui.common.AppLocale import okhttp3.Interceptor +import okhttp3.Request import okhttp3.Response /** @@ -12,7 +14,7 @@ import okhttp3.Response class AuthInterceptor(private val session: SessionStore) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { - val original = chain.request() + val original = withLanguage(chain.request()) // Public endpoints carry the no-auth marker — drop it and send as-is. if (original.header(NO_AUTH_HEADER) != null) { @@ -42,5 +44,14 @@ class AuthInterceptor(private val session: SessionStore) : Interceptor { companion object { private const val NO_AUTH_HEADER = "X-Pinakes-No-Auth" + + /** + * Ask the server for labels and messages in the language the app is showing (Profile + * language or the device one); the API falls back to its own when it does not have it. + * A header set explicitly on the request wins. + */ + internal fun withLanguage(request: Request, tag: String = AppLocale.current().toLanguageTag()): Request = + if (request.header("Accept-Language") != null || tag.isBlank() || tag == "und") request + else request.newBuilder().header("Accept-Language", tag).build() } } diff --git a/app/src/test/java/com/pinakes/app/network/AcceptLanguageTest.kt b/app/src/test/java/com/pinakes/app/network/AcceptLanguageTest.kt new file mode 100644 index 0000000..83d7103 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/network/AcceptLanguageTest.kt @@ -0,0 +1,26 @@ +package com.pinakes.app.network + +import com.pinakes.app.data.network.AuthInterceptor +import okhttp3.Request +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** Every API call carries the app language so the server answers labels and errors in it. */ +class AcceptLanguageTest { + private val request = Request.Builder().url("https://library.example/api/v1/health").build() + + @Test fun addsTheAppLanguage() { + assertEquals("it-IT", AuthInterceptor.withLanguage(request, "it-IT").header("Accept-Language")) + } + + @Test fun keepsAnExplicitHeader() { + val explicit = request.newBuilder().header("Accept-Language", "de").build() + assertEquals("de", AuthInterceptor.withLanguage(explicit, "it-IT").header("Accept-Language")) + } + + @Test fun sendsNothingForAnUndefinedLanguage() { + assertNull(AuthInterceptor.withLanguage(request, "und").header("Accept-Language")) + assertNull(AuthInterceptor.withLanguage(request, "").header("Accept-Language")) + } +} From adf1d67ea2aa1a8eb25a3dd91671b3f631906dec Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 08:33:41 +0200 Subject: [PATCH 49/62] Keep the My Library tab labels on one line At 360 dp the Reservations tab wrapped mid-word ("Reservation" / "s"), and German and Italian labels are just as long. The tabs are now a scrollable row with single-line labels, like the circulation desk. --- .../com/pinakes/app/ui/screens/library/LibraryScreen.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt index 6d81f65..a6c2a2d 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt @@ -1,5 +1,6 @@ package com.pinakes.app.ui.screens.library +import androidx.compose.ui.unit.dp import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,7 +15,7 @@ import androidx.compose.material.icons.automirrored.outlined.LibraryBooks import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Tab -import androidx.compose.material3.TabRow +import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -70,12 +71,14 @@ fun LibraryScreen(onBookClick: (Int) -> Unit) { stringResource(R.string.library_tab_history), stringResource(R.string.library_tab_reservations), ) - TabRow(selectedTabIndex = tab, containerColor = MaterialTheme.colorScheme.surface) { + // Scrollable so a long label ("Reservations", "Vormerkungen") never wraps mid-word at + // phone widths. + ScrollableTabRow(selectedTabIndex = tab, edgePadding = 0.dp, containerColor = MaterialTheme.colorScheme.surface) { tabs.forEachIndexed { i, label -> Tab( selected = tab == i, onClick = { tab = i }, - text = { Text(label, style = MaterialTheme.typography.labelLarge) }, + text = { Text(label, style = MaterialTheme.typography.labelLarge, maxLines = 1, softWrap = false) }, ) } } From c4a4dc7b653e06a20c590cd760995f08fee0d8f8 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 14 Oct 2026 08:35:11 +0200 Subject: [PATCH 50/62] Show the staff area in the README Adds 24 screenshots of the 1.8.0 staff area (circulation desk, cataloguing and plugin content), captured on Android 15 at 360 dp against a local instance with demo data and resized to 720 px, and lists the staff area, circulation desk, offline catalogue and loans calendar in the features table. --- README.md | 59 ++++++++++++++++++ docs/screenshots/staff/author-detail.png | Bin 0 -> 159433 bytes docs/screenshots/staff/book-copies.png | Bin 0 -> 145596 bytes docs/screenshots/staff/book-edit.png | Bin 0 -> 108596 bytes docs/screenshots/staff/circulation-desk.png | Bin 0 -> 123834 bytes docs/screenshots/staff/copy-scanner.png | Bin 0 -> 60067 bytes docs/screenshots/staff/isbn-preview.png | Bin 0 -> 148181 bytes .../screenshots/staff/loan-detail-overdue.png | Bin 0 -> 150766 bytes .../screenshots/staff/loan-detail-request.png | Bin 0 -> 159464 bytes docs/screenshots/staff/loans-overdue.png | Bin 0 -> 93693 bytes docs/screenshots/staff/new-loan.png | Bin 0 -> 126037 bytes docs/screenshots/staff/reservations.png | Bin 0 -> 110865 bytes docs/screenshots/staff/series-detail.png | Bin 0 -> 77917 bytes docs/screenshots/staff/staff-archives.png | Bin 0 -> 280331 bytes docs/screenshots/staff/staff-authors.png | Bin 0 -> 60813 bytes docs/screenshots/staff/staff-books.png | Bin 0 -> 246595 bytes docs/screenshots/staff/staff-covers.png | Bin 0 -> 155248 bytes docs/screenshots/staff/staff-desiderata.png | Bin 0 -> 98707 bytes docs/screenshots/staff/staff-genres.png | Bin 0 -> 97043 bytes docs/screenshots/staff/staff-hub-2.png | Bin 0 -> 164607 bytes docs/screenshots/staff/staff-hub.png | Bin 0 -> 140540 bytes docs/screenshots/staff/staff-imports.png | Bin 0 -> 177248 bytes docs/screenshots/staff/staff-merges.png | Bin 0 -> 91234 bytes docs/screenshots/staff/staff-periodicals.png | Bin 0 -> 96579 bytes docs/screenshots/staff/staff-series.png | Bin 0 -> 52789 bytes 25 files changed, 59 insertions(+) create mode 100644 docs/screenshots/staff/author-detail.png create mode 100644 docs/screenshots/staff/book-copies.png create mode 100644 docs/screenshots/staff/book-edit.png create mode 100644 docs/screenshots/staff/circulation-desk.png create mode 100644 docs/screenshots/staff/copy-scanner.png create mode 100644 docs/screenshots/staff/isbn-preview.png create mode 100644 docs/screenshots/staff/loan-detail-overdue.png create mode 100644 docs/screenshots/staff/loan-detail-request.png create mode 100644 docs/screenshots/staff/loans-overdue.png create mode 100644 docs/screenshots/staff/new-loan.png create mode 100644 docs/screenshots/staff/reservations.png create mode 100644 docs/screenshots/staff/series-detail.png create mode 100644 docs/screenshots/staff/staff-archives.png create mode 100644 docs/screenshots/staff/staff-authors.png create mode 100644 docs/screenshots/staff/staff-books.png create mode 100644 docs/screenshots/staff/staff-covers.png create mode 100644 docs/screenshots/staff/staff-desiderata.png create mode 100644 docs/screenshots/staff/staff-genres.png create mode 100644 docs/screenshots/staff/staff-hub-2.png create mode 100644 docs/screenshots/staff/staff-hub.png create mode 100644 docs/screenshots/staff/staff-imports.png create mode 100644 docs/screenshots/staff/staff-merges.png create mode 100644 docs/screenshots/staff/staff-periodicals.png create mode 100644 docs/screenshots/staff/staff-series.png diff --git a/README.md b/README.md index 55f3ca1..47b47dd 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,61 @@ Login, home, catalog, book detail and calendar captures use the 2026 interface o +### Staff area + +Admin and staff accounts get a native staff area when the instance runs Mobile API 1.7.0: cataloguing with ISBN scanning, the whole circulation desk and the content of the Desiderata, Archives, Periodicals, Book Club and FRBR plugins. Captured on Android 15 at 360 dp against a local instance with demo data. + +#### Circulation desk + + + + + + + + + + + + + + +
Circulation desk with counters
Circulation desk
Overdue loans list
Loans by status
Overdue loan with return, edit, reminder and receipt actions
Loan actions
Pending request with approve and reject
Approve a request
New loan form with borrower and book search
New loan
Reservations grouped by title with queue position
Reservations queue
Copy label scanner
Scan a copy label
Staff area hub
Staff area
+ +#### Cataloguing + + + + + + + + + + + + + + + + + + + + +
ISBN lookup preview from the server scrapers
Scan an ISBN
Staff book list
Books
Book editor with cover sources
Edit a record
Copies, location and labels in the book editor
Copies and labels
Author page with books and record details
Author page
Author search
Authors
Genre tree
Genres
Series search
Series
Series detail with its volumes
Series detail
Merge duplicate authors, publishers and genres
Merge duplicates
CSV and LibraryThing import
Import a file
Missing covers and enrichment
Covers and enrichment
+ +#### Plugin content + + + + + + + + +
Donation offers by status
Donation offers
Archival units with publishing filter
Archives
Periodicals desk with issue barcode lookup
Periodicals desk
Staff hub plugin section
Staff tools
+ ## Features | Area | What you get | @@ -73,6 +128,10 @@ Login, home, catalog, book detail and calendar captures use the 2026 interface o | **Profile** | Edit profile, change password, device list, theme switcher, language switcher, logout | | **Notifications** | Loan due/overdue, reservation ready, book available | | **Themes** | 2026 warm neutrals, Geist + Fraunces, complete 3D covers and paired theme colours. Light by default; pick light/dark/system in Profile | +| **Staff area** | For admin and staff accounts: cataloguing (ISBN/EAN scanner with batch mode, manual records, copies with their own codes, series, merge duplicates, label PDFs, CSV and LibraryThing import, covers and enrichment, authors, publishers, genres, shelves) and plugin content (donation offers, archives, periodicals desk, book clubs, FRBR works, digital files) | +| **Circulation desk** | Counters, loans by status, new loan with borrower search and copy scanning, approve/reject, pickup, return with copy outcome, renew, bulk extend, reminders, receipt PDF, reservations queue. Hidden while offline | +| **Offline catalogue** | Full catalogue mirrored on the device with delta sync; search, book pages, My Library and Wishlist work offline, while loan and reservation actions stay hidden until the connection is back | +| **Loans in your calendar** | A private, revocable calendar link with your loans (start to due date) and reservations, for Google Calendar, Apple Calendar, Outlook and others | | **Languages** | Italian, English, French, German, following the device locale or an in-app choice | ## Tech stack diff --git a/docs/screenshots/staff/author-detail.png b/docs/screenshots/staff/author-detail.png new file mode 100644 index 0000000000000000000000000000000000000000..fc4e4f510a29e330b1f3e7fba3ca5cfd46d4ae5a GIT binary patch literal 159433 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⋘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 z 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 M_O3^2Y|mW!9U7GK4gdfE literal 0 HcmV?d00001 diff --git a/docs/screenshots/staff/book-copies.png b/docs/screenshots/staff/book-copies.png new file mode 100644 index 0000000000000000000000000000000000000000..0c3ba75a82010558537464bdb9621d4a237d705f GIT binary patch literal 145596 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�{B(E~<$LFNE5UO9m2F^Vw7Tmp{IR^VVTFLXv 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 LR+K8ZukZICFo~=# literal 0 HcmV?d00001 diff --git a/docs/screenshots/staff/circulation-desk.png b/docs/screenshots/staff/circulation-desk.png new file mode 100644 index 0000000000000000000000000000000000000000..e7526517902b7917f4dfa661faa0ddc9de3ada62 GIT binary patch literal 123834 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≺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±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 IxpeD)0B4cxcK`qY literal 0 HcmV?d00001 diff --git a/docs/screenshots/staff/isbn-preview.png b/docs/screenshots/staff/isbn-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..80a6c14ea23e274a0998232c00d7855946fe6e4c GIT binary patch literal 148181 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

RwZy0Y9+`o_!@3#464d-HvCC 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�L$-RrfF~dJ#gr_?Ha@T9!>(ySMhwoX%dr> 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 c^79h&%i4~TN(Q$i`WR0owNfs0BZRR4*&oF literal 0 HcmV?d00001 diff --git a/docs/screenshots/staff/loan-detail-overdue.png b/docs/screenshots/staff/loan-detail-overdue.png new file mode 100644 index 0000000000000000000000000000000000000000..05d62492dddbe5ab7f335b39e2bfee47aec3d754 GIT binary patch literal 150766 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%0AuT_*V0JDPVww11*|ZK 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 literal 0 HcmV?d00001 diff --git a/docs/screenshots/staff/loan-detail-request.png b/docs/screenshots/staff/loan-detail-request.png new file mode 100644 index 0000000000000000000000000000000000000000..0e8f87722942827b232826ebbcc8d4ee1bd2d1cf GIT binary patch literal 159464 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