From 975043da6d02de8b0687a5db959bcfb4ed90c2bf Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 3 Sep 2026 17:40:43 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Emeroteca=20section=20=E2=80=94=20p?= =?UTF-8?q?eriodicals=20browsing=20gated=20on=20server=20capability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New read-only section for the emeroteca bundled plugin (Pinakes 0.7.77+ with the periodicals mobile bridge). Follows the Book Club pattern for optional server-side plugins end to end: - PeriodicalsApi (separate Retrofit interface) + defensive @Serializable DTOs; PeriodicalsRepository with probeAvailability() (2xx→on, 404→off, other→last known) and instance-switch guard - periodicalsAvailable flag in FeatureStore (default false — the entry point stays hidden until the authenticated probe confirms), probe run in parallel with the book-club one inside refreshHealth() - Screens: mastheads list (debounced search, type filter chips, cursor load-more with dedup) → masthead detail (logo, ISSN, publisher, holdings, volume years) → issues of a year (cover, number, date, status badge) → issue detail (large cover, spoglio table of contents, "Open PDF" only when the server exposes a public pdf_url) - Entry point in Profile next to Book Club (Newspaper icon), nested nav graph with the standard slide-in transitions - 57 new i18n keys in all four locales (en/it/fr/de), appended in identical order - PeriodicalsUiStateTest: 12 pure-function tests (page merge dedup, type filter toggle, status→badge mapping, pdf gating) Verified: testDebugUnitTest 145/145, lintDebug clean, assembleDebug and assembleRelease (R8) build — existing keep rules already cover the new @Serializable DTOs and Retrofit interface. --- .../app/data/model/PeriodicalsModels.kt | 148 ++++++++++ .../pinakes/app/data/network/NetworkModule.kt | 15 + .../app/data/network/PeriodicalsApi.kt | 48 ++++ .../app/data/repository/AuthRepository.kt | 25 +- .../data/repository/PeriodicalsRepository.kt | 95 +++++++ .../pinakes/app/data/store/FeatureStore.kt | 20 +- .../main/java/com/pinakes/app/di/AppModule.kt | 8 +- .../pinakes/app/ui/navigation/MainScaffold.kt | 2 + .../app/ui/navigation/PinakesNavHost.kt | 55 ++++ .../com/pinakes/app/ui/navigation/Routes.kt | 17 ++ .../screens/periodicals/IssueDetailScreen.kt | 211 ++++++++++++++ .../periodicals/IssueDetailViewModel.kt | 58 ++++ .../ui/screens/periodicals/IssueListScreen.kt | 148 ++++++++++ .../screens/periodicals/IssueListViewModel.kt | 61 ++++ .../periodicals/PeriodicalDetailScreen.kt | 232 ++++++++++++++++ .../periodicals/PeriodicalDetailViewModel.kt | 59 ++++ .../screens/periodicals/PeriodicalsScreen.kt | 261 ++++++++++++++++++ .../ui/screens/periodicals/PeriodicalsUi.kt | 63 +++++ .../periodicals/PeriodicalsViewModel.kt | 168 +++++++++++ .../app/ui/screens/profile/ProfileScreen.kt | 9 + .../periodicals/PeriodicalsUiStateTest.kt | 115 ++++++++ i18n/de.json | 58 +++- i18n/en.json | 58 +++- i18n/fr.json | 58 +++- i18n/it.json | 58 +++- 25 files changed, 2032 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt create mode 100644 app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt create mode 100644 app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailViewModel.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailViewModel.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsViewModel.kt create mode 100644 app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt diff --git a/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt b/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt new file mode 100644 index 0000000..cf2104e --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt @@ -0,0 +1,148 @@ +package com.pinakes.app.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Models for the Periodicals ("Emeroteca") plugin's mobile surface (`/api/v1/periodicals/…`). + * + * Unlike the Book Club plugin, this surface uses the CORE `{data, meta, error}` envelope + * ([Envelope]) — so every call goes through the shared `apiCall`, and list pagination reads + * `meta.next_cursor` exactly like the catalog search. + * + * The section is read-only (consultation of the periodicals archive): no write endpoints. + * All fields mirror the server's English snake_case JSON; anything the server may omit or + * null is declared nullable with a default so a lean payload can never fail decoding. + */ + +/** GET periodicals/health → `{data:{status:"ok"}}` when the plugin is on (404 when off). */ +@Serializable +data class PeriodicalsHealth( + val status: String = "", +) + +// ---------- Mastheads list ---------- +// GET periodicals?q=&type=&cursor=&limit= → { data: [PeriodicalSummary], meta: {next_cursor,…} } +@Serializable +data class PeriodicalSummary( + val id: Int = 0, + val title: String = "", + val subtitle: String? = null, + val issn: String? = null, + /** rivista | giornale | magazine | bollettino | fanzine */ + val type: String = "", + /** quotidiano | settimanale | quindicinale | mensile | bimestrale | trimestrale | semestrale | annuale | irregolare */ + val frequency: String? = null, + val publisher: PeriodicalPublisher? = null, + @SerialName("logo_url") val logoUrl: String? = null, + @SerialName("year_start") val yearStart: Int? = null, + @SerialName("year_end") val yearEnd: Int? = null, + @SerialName("collection_status") val collectionStatus: String? = null, + @SerialName("years_count") val yearsCount: Int = 0, + @SerialName("issues_count") val issuesCount: Int = 0, +) + +@Serializable +data class PeriodicalPublisher( + val id: Int = 0, + val name: String = "", +) + +// ---------- Masthead detail ---------- +// GET periodicals/{id} → summary fields + description/place/language/holdings + years +@Serializable +data class PeriodicalDetail( + val id: Int = 0, + val title: String = "", + val subtitle: String? = null, + val issn: String? = null, + val type: String = "", + val frequency: String? = null, + val publisher: PeriodicalPublisher? = null, + @SerialName("logo_url") val logoUrl: String? = null, + @SerialName("year_start") val yearStart: Int? = null, + @SerialName("year_end") val yearEnd: Int? = null, + @SerialName("collection_status") val collectionStatus: String? = null, + @SerialName("years_count") val yearsCount: Int = 0, + @SerialName("issues_count") val issuesCount: Int = 0, + val description: String? = null, + val place: String? = null, + val language: String? = null, + /** Free-text consistency note ("1946-1998, lacune 1953-1955", …). */ + val holdings: String? = null, + val years: List = emptyList(), +) + +@Serializable +data class PeriodicalYear( + val id: Int = 0, + val year: Int = 0, + val volume: String? = null, + /** True when the annata is bound into a single physical volume. */ + val bound: Boolean = false, + @SerialName("cover_url") val coverUrl: String? = null, + @SerialName("issues_count") val issuesCount: Int = 0, + @SerialName("owned_count") val ownedCount: Int = 0, +) + +// ---------- Issues of a year ---------- +// GET periodicals/years/{id}/issues → { data: [PeriodicalIssue] } +@Serializable +data class PeriodicalIssue( + val id: Int = 0, + val number: String? = null, + val sequence: Int? = null, + val title: String? = null, + @SerialName("cover_date") val coverDate: String? = null, + @SerialName("publication_date") val publicationDate: String? = null, + val pages: Int? = null, + /** posseduto | mancante | danneggiato | in_restauro | smarrito | atteso */ + val status: String = "", + @SerialName("cover_url") val coverUrl: String? = null, + @SerialName("has_public_pdf") val hasPublicPdf: Boolean = false, +) + +// ---------- Issue detail ---------- +// GET periodicals/issues/{id} → issue + pdf_url (only when public) + context + spoglio +@Serializable +data class PeriodicalIssueDetail( + val id: Int = 0, + val number: String? = null, + val sequence: Int? = null, + val title: String? = null, + @SerialName("cover_date") val coverDate: String? = null, + @SerialName("publication_date") val publicationDate: String? = null, + val pages: Int? = null, + val status: String = "", + @SerialName("cover_url") val coverUrl: String? = null, + /** Only present when the digitised PDF is public; null → hide the "Open PDF" action. */ + @SerialName("pdf_url") val pdfUrl: String? = null, + val masthead: IssueMasthead? = null, + val year: IssueYear? = null, + val articles: List = emptyList(), +) { + /** The PDF action is offered ONLY for a non-blank public URL (server-authoritative). */ + val canOpenPdf: Boolean get() = !pdfUrl.isNullOrBlank() +} + +@Serializable +data class IssueMasthead( + val id: Int = 0, + val title: String = "", +) + +@Serializable +data class IssueYear( + val id: Int = 0, + val year: Int = 0, + val volume: String? = null, +) + +@Serializable +data class IssueArticle( + val title: String = "", + val authors: String? = null, + @SerialName("page_start") val pageStart: Int? = null, + @SerialName("page_end") val pageEnd: Int? = null, + val type: String? = null, +) 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 a46dfee..5df18e3 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 @@ -66,6 +66,9 @@ class NetworkModule(private val session: SessionStore) { @Volatile private var cachedBookClubApi: BookClubApi? = null + @Volatile + private var cachedPeriodicalsApi: PeriodicalsApi? = null + /** Shared Retrofit for a given base URL, rebuilt only when the instance URL changes. */ @Synchronized private fun retrofit(baseUrl: String?): Retrofit { @@ -85,6 +88,7 @@ class NetworkModule(private val session: SessionStore) { cachedRetrofit = retrofit cachedApi = null cachedBookClubApi = null + cachedPeriodicalsApi = null return retrofit } @@ -109,6 +113,16 @@ class NetworkModule(private val session: SessionStore) { return cachedBookClubApi ?: retrofit.create(BookClubApi::class.java).also { cachedBookClubApi = it } } + /** + * Returns the [PeriodicalsApi] bound to the same instance base URL as [api]. The + * Periodicals plugin lives under `/api/v1/periodicals/…` and reuses the same bearer token. + */ + @Synchronized + fun periodicalsApi(baseUrl: String? = null): PeriodicalsApi { + val retrofit = retrofit(baseUrl) + return cachedPeriodicalsApi ?: retrofit.create(PeriodicalsApi::class.java).also { cachedPeriodicalsApi = it } + } + /** Drop the cached Retrofit so the next [api] call rebuilds against a new instance URL. */ @Synchronized fun invalidate() { @@ -116,6 +130,7 @@ class NetworkModule(private val session: SessionStore) { cachedRetrofit = null cachedApi = null cachedBookClubApi = null + cachedPeriodicalsApi = null } companion object { diff --git a/app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt b/app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt new file mode 100644 index 0000000..1b1a613 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/network/PeriodicalsApi.kt @@ -0,0 +1,48 @@ +package com.pinakes.app.data.network + +import com.pinakes.app.data.model.Envelope +import com.pinakes.app.data.model.PeriodicalDetail +import com.pinakes.app.data.model.PeriodicalIssue +import com.pinakes.app.data.model.PeriodicalIssueDetail +import com.pinakes.app.data.model.PeriodicalSummary +import com.pinakes.app.data.model.PeriodicalsHealth +import retrofit2.http.GET +import retrofit2.http.Path +import retrofit2.http.Query + +/** + * Periodicals ("Emeroteca") plugin endpoints, exposed under `/api/v1/periodicals/…`. + * Base URL is the same instance origin + `/api/v1/` used by [PinakesApi], and every call + * carries the SAME bearer token (injected by [AuthInterceptor]) — the app authenticates + * once via the core Mobile API. + * + * Unlike [BookClubApi], these responses use the CORE `{data, meta, error}` envelope, so they + * are wrapped with the shared `apiCall`. The whole surface is read-only. + */ +interface PeriodicalsApi { + + /** Discovery — 2xx means the section is available; 404 means the plugin is off. */ + @GET("periodicals/health") + suspend fun health(): Envelope + + /** Mastheads list; cursor-paginated like the catalog search (`meta.next_cursor`). */ + @GET("periodicals") + suspend fun periodicals( + @Query("q") q: String? = null, + @Query("type") type: String? = null, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): Envelope> + + /** Masthead detail + its years (annate). */ + @GET("periodicals/{id}") + suspend fun periodical(@Path("id") id: Int): Envelope + + /** Issues (fascicoli) of a year. */ + @GET("periodicals/years/{id}/issues") + suspend fun yearIssues(@Path("id") yearId: Int): Envelope> + + /** Issue detail + spoglio articles + public PDF url when available. */ + @GET("periodicals/issues/{id}") + suspend fun issue(@Path("id") id: Int): 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 15e007d..5450da3 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 @@ -23,6 +23,7 @@ class AuthRepository( private val session: SessionStore, private val features: FeatureStore, private val bookClub: BookClubRepository, + private val periodicals: PeriodicalsRepository, private val catalog: CatalogRepository, ) { @@ -83,10 +84,11 @@ class AuthRepository( suspend fun refreshHealth() { val instance = session.instanceUrl ?: return coroutineScope { - // The core /health and the Book Club plugin probe are independent requests to - // the same instance — run them concurrently so the refresh costs max(RTT), not - // the sum (this path gates the post-login spinner). - val probe = async { bookClub.probeAvailability() } + // The core /health and the plugin probes are independent requests to the same + // instance — run them concurrently so the refresh costs max(RTT), not the sum + // (this path gates the post-login spinner). + val bookClubProbe = async { bookClub.probeAvailability() } + val periodicalsProbe = async { periodicals.probeAvailability() } var appAccessEnabled: Boolean? = null when (val res = apiCall { network.api().health() }) { is ApiResult.Success -> { @@ -95,12 +97,13 @@ class AuthRepository( } is ApiResult.Failure -> { /* keep last-known flags; never lock the user out */ } } - // The plugin's health endpoint is public and answers 2xx even when the instance - // has mobile app access switched off — gate the section on the core flag so it - // hides instead of rendering entries whose calls can only 403. - val probed = probe.await() - val available = if (appAccessEnabled == false) false else probed - bookClub.applyAvailability(available, probedInstanceUrl = instance) + // A plugin's health endpoint may answer 2xx even when the instance has mobile + // app access switched off — gate each section on the core flag so it hides + // instead of rendering entries whose calls can only 403. + val bookClubAvailable = bookClubProbe.await().let { if (appAccessEnabled == false) false else it } + bookClub.applyAvailability(bookClubAvailable, probedInstanceUrl = instance) + val periodicalsAvailable = periodicalsProbe.await().let { if (appAccessEnabled == false) false else it } + periodicals.applyAvailability(periodicalsAvailable, probedInstanceUrl = instance) } } @@ -176,7 +179,7 @@ class AuthRepository( /** Forget the instance entirely (back to onboarding). */ suspend fun forgetInstance() { session.clearAll() - features.clear() // resets the Book Club availability flag too + features.clear() // resets the Book Club + Periodicals availability flags too // 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() diff --git a/app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt new file mode 100644 index 0000000..88664b7 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/PeriodicalsRepository.kt @@ -0,0 +1,95 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.PeriodicalDetail +import com.pinakes.app.data.model.PeriodicalIssue +import com.pinakes.app.data.model.PeriodicalIssueDetail +import com.pinakes.app.data.model.PeriodicalSummary +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.FeatureStore +import com.pinakes.app.data.store.SessionStore + +/** One cursor page of the mastheads list. */ +data class PeriodicalsPage( + val items: List = emptyList(), + val nextCursor: String? = null, +) + +/** + * Periodicals ("Emeroteca") plugin surface (`/api/v1/periodicals/…`): availability discovery + * plus the read-only browse chain — mastheads list, masthead detail (with years), a year's + * issues and the issue detail. + * + * Availability is probed alongside every `/health` refresh and stored as an + * [com.pinakes.app.data.store.InstanceFeatures] flag, so the UI only shows the section when + * the plugin is active for this instance — the same lifecycle as [BookClubRepository]. + */ +class PeriodicalsRepository( + private val network: NetworkModule, + private val features: FeatureStore, + private val session: SessionStore, +) { + + /** + * Probe `GET /periodicals/health`. + * Returns true on 2xx (plugin on), false on an explicit 404 (plugin off), and null on + * any other failure — the caller keeps the last-known flag rather than hiding a working + * section on a network blip. + */ + suspend fun probeAvailability(): Boolean? = + when (val res = apiCall { network.periodicalsApi().health() }) { + is ApiResult.Success -> true + is ApiResult.Failure -> + if (res.httpStatus == 404 || res.code == ErrorCodes.NOT_FOUND) false else null + } + + /** + * Apply a probe result, guarded against instance switches: a late response from the + * previous instance (the user tapped "change library" mid-flight) must not resurrect + * or clobber the flag of the instance now configured. Null keeps the last-known value. + */ + fun applyAvailability(available: Boolean?, probedInstanceUrl: String?): Boolean { + if (available == null) return false + if (probedInstanceUrl == null || session.instanceUrl != probedInstanceUrl) return false + features.setPeriodicalsAvailable(available) + return true + } + + /** One page of mastheads, optionally filtered by free text and/or type. */ + suspend fun periodicals( + query: String? = null, + type: String? = null, + cursor: String? = null, + limit: Int? = null, + ): ApiResult = + when (val res = apiCall { network.periodicalsApi().periodicals(query, type, cursor, limit) }) { + is ApiResult.Success -> + ApiResult.Success(PeriodicalsPage(res.data, res.meta?.nextCursor), res.meta) + is ApiResult.Failure -> res + } + + suspend fun periodical(id: Int): ApiResult = + apiCall { network.periodicalsApi().periodical(id) } + + suspend fun yearIssues(yearId: Int): ApiResult> = + apiCall { network.periodicalsApi().yearIssues(yearId) } + + suspend fun issue(id: Int): ApiResult = + apiCall { network.periodicalsApi().issue(id) } + + /** + * A periodicals endpoint answered 404: re-probe the plugin health and, when the plugin + * is confirmed gone, flip the feature flag so every entry point hides immediately. + * Returns true when the plugin is really unavailable (vs a single missing resource). + */ + suspend fun confirmGone(): Boolean { + val instance = session.instanceUrl + val available = probeAvailability() + // Only treat the plugin as gone when the probe actually applied to the still-current + // instance — a stale 404 from a since-switched instance must not drive pluginGone. + val applied = applyAvailability(available, instance) + return applied && available == false + } +} 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 2fe9b29..a22e8f8 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 @@ -40,6 +40,12 @@ data class InstanceFeatures( * [registrationEnabled] it stays hidden until discovery confirms it. */ val bookClubAvailable: Boolean = false, + /** + * Whether the instance exposes the optional Periodicals ("Emeroteca") plugin's mobile + * surface. Same lifecycle as [bookClubAvailable]: sourced from a separate + * `GET /api/v1/periodicals/health` probe (2xx → on, 404 → off), hidden until confirmed. + */ + val periodicalsAvailable: Boolean = false, ) { /** Library tab (loans + reservations) is shown only when at least one of them is enabled. */ val showLibrary: Boolean get() = loans || reservations @@ -77,8 +83,9 @@ class FeatureStore(context: Context) { val features: StateFlow = _features.asStateFlow() /** - * Persist + publish the flags from a fresh `/health` payload. The Book Club flag has - * its own probe lifecycle ([setBookClubAvailable]) and is preserved, not clobbered. + * Persist + publish the flags from a fresh `/health` payload. The Book Club and + * Periodicals flags have their own probe lifecycle ([setBookClubAvailable], + * [setPeriodicalsAvailable]) and are preserved, not clobbered. */ fun update(health: HealthPayload) { val f = health.features @@ -94,6 +101,7 @@ class FeatureStore(context: Context) { reviews = f.reviews, registrationEnabled = health.registrationEnabled, bookClubAvailable = prefs.getBoolean(KEY_BOOK_CLUB, false), + periodicalsAvailable = prefs.getBoolean(KEY_PERIODICALS, false), ) prefs.edit() .putBoolean(KEY_KNOWN, true) @@ -117,6 +125,12 @@ class FeatureStore(context: Context) { _features.update { it.copy(bookClubAvailable = available) } } + /** Persist + publish the Periodicals plugin availability (from its own health probe). */ + fun setPeriodicalsAvailable(available: Boolean) { + prefs.edit().putBoolean(KEY_PERIODICALS, available).apply() + _features.update { it.copy(periodicalsAvailable = available) } + } + /** Reset to all-enabled (e.g. when forgetting the instance). */ fun clear() { prefs.edit().clear().apply() @@ -140,6 +154,7 @@ class FeatureStore(context: Context) { reviews = prefs.getBoolean(KEY_REVIEWS, true), registrationEnabled = prefs.getBoolean(KEY_REGISTRATION_ENABLED, false), bookClubAvailable = prefs.getBoolean(KEY_BOOK_CLUB, false), + periodicalsAvailable = prefs.getBoolean(KEY_PERIODICALS, false), ) } @@ -157,5 +172,6 @@ class FeatureStore(context: Context) { private const val KEY_REVIEWS = "f_reviews" private const val KEY_REGISTRATION_ENABLED = "registration_enabled" private const val KEY_BOOK_CLUB = "f_book_club" + private const val KEY_PERIODICALS = "f_periodicals" } } 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 7bdae89..bdc9d6f 100644 --- a/app/src/main/java/com/pinakes/app/di/AppModule.kt +++ b/app/src/main/java/com/pinakes/app/di/AppModule.kt @@ -10,6 +10,7 @@ import com.pinakes.app.data.repository.CatalogRepository import com.pinakes.app.data.repository.LibraryRepository import com.pinakes.app.data.repository.MessagesRepository 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.WishlistRepository @@ -57,14 +58,19 @@ object AppModule { fun bookClubRepository(network: NetworkModule, features: FeatureStore, session: SessionStore): BookClubRepository = BookClubRepository(network, features, session) + @Provides @Singleton + fun periodicalsRepository(network: NetworkModule, features: FeatureStore, session: SessionStore): PeriodicalsRepository = + PeriodicalsRepository(network, features, session) + @Provides @Singleton fun authRepository( network: NetworkModule, session: SessionStore, features: FeatureStore, bookClub: BookClubRepository, + periodicals: PeriodicalsRepository, catalog: CatalogRepository, - ): AuthRepository = AuthRepository(network, session, features, bookClub, catalog) + ): AuthRepository = AuthRepository(network, session, features, bookClub, periodicals, catalog) @Provides @Singleton fun libraryRepository(network: NetworkModule): LibraryRepository = LibraryRepository(network) diff --git a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt index bf87a1d..354c20e 100644 --- a/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt +++ b/app/src/main/java/com/pinakes/app/ui/navigation/MainScaffold.kt @@ -40,6 +40,7 @@ fun MainScaffold( onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, onOpenBookClub: () -> Unit, + onOpenPeriodicals: () -> Unit, ) { val app: AppViewModel = hiltViewModel() val features by app.features.collectAsStateWithLifecycle() @@ -97,6 +98,7 @@ fun MainScaffold( onOpenContact = onOpenContact, onOpenMyReviews = onOpenMyReviews, onOpenBookClub = onOpenBookClub, + onOpenPeriodicals = onOpenPeriodicals, ) } } 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 91d0d51..2c16489 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 @@ -27,6 +27,10 @@ import com.pinakes.app.ui.screens.login.LoginScreen import com.pinakes.app.ui.screens.login.RegisterScreen import com.pinakes.app.ui.screens.notifications.NotificationsScreen import com.pinakes.app.ui.screens.onboarding.OnboardingScreen +import com.pinakes.app.ui.screens.periodicals.IssueDetailScreen +import com.pinakes.app.ui.screens.periodicals.IssueListScreen +import com.pinakes.app.ui.screens.periodicals.PeriodicalDetailScreen +import com.pinakes.app.ui.screens.periodicals.PeriodicalsScreen import com.pinakes.app.ui.screens.reviews.MyReviewsScreen /** @@ -98,6 +102,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onOpenContact = { navController.navigate(Routes.CONTACT) }, onOpenMyReviews = { navController.navigate(Routes.MY_REVIEWS) }, onOpenBookClub = { navController.navigate(Routes.BOOK_CLUB) }, + onOpenPeriodicals = { navController.navigate(Routes.PERIODICALS) }, ) } @@ -163,5 +168,55 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { ) { ClubDetailScreen(onNavigateUp = { navController.popBackStack() }) } + + // ---- Periodicals / Emeroteca (optional plugin) ---- + composable( + Routes.PERIODICALS, + enterTransition = slideIn, + popExitTransition = slideOut, + ) { + PeriodicalsScreen( + onNavigateUp = { navController.popBackStack() }, + onOpenPeriodical = { id -> navController.navigate(Routes.periodicalDetail(id)) }, + ) + } + + composable( + route = Routes.PERIODICAL_DETAIL, + arguments = listOf(navArgument(Routes.ARG_PERIODICAL_ID) { type = NavType.IntType }), + enterTransition = slideIn, + popExitTransition = slideOut, + ) { + PeriodicalDetailScreen( + onNavigateUp = { navController.popBackStack() }, + onOpenYear = { yearId, year -> + navController.navigate(Routes.periodicalYearIssues(yearId, year)) + }, + ) + } + + composable( + route = Routes.PERIODICAL_YEAR_ISSUES, + arguments = listOf( + navArgument(Routes.ARG_PERIODICAL_YEAR_ID) { type = NavType.IntType }, + navArgument(Routes.ARG_PERIODICAL_YEAR) { type = NavType.IntType }, + ), + enterTransition = slideIn, + popExitTransition = slideOut, + ) { + IssueListScreen( + onNavigateUp = { navController.popBackStack() }, + onOpenIssue = { id -> navController.navigate(Routes.periodicalIssue(id)) }, + ) + } + + composable( + route = Routes.PERIODICAL_ISSUE, + arguments = listOf(navArgument(Routes.ARG_PERIODICAL_ISSUE_ID) { type = NavType.IntType }), + enterTransition = slideIn, + popExitTransition = slideOut, + ) { + IssueDetailScreen(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 c8ad98d..4e834ed 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 @@ -31,6 +31,23 @@ object Routes { fun clubDetail(slug: String): String = "book-club/${Uri.encode(slug)}" const val ARG_CLUB_SLUG = "slug" + // Periodicals / Emeroteca (optional plugin) + const val PERIODICALS = "periodicals" + const val PERIODICAL_DETAIL = "periodicals/{periodicalId}" + fun periodicalDetail(id: Int): String = "periodicals/$id" + const val ARG_PERIODICAL_ID = "periodicalId" + + // The display year rides along as a nav arg so the issues screen can title itself + // ("Year 1998") without re-fetching the masthead detail. + const val PERIODICAL_YEAR_ISSUES = "periodicals/years/{yearId}/{year}" + fun periodicalYearIssues(yearId: Int, year: Int): String = "periodicals/years/$yearId/$year" + const val ARG_PERIODICAL_YEAR_ID = "yearId" + const val ARG_PERIODICAL_YEAR = "year" + + const val PERIODICAL_ISSUE = "periodicals/issues/{issueId}" + fun periodicalIssue(id: Int): String = "periodicals/issues/$id" + const val ARG_PERIODICAL_ISSUE_ID = "issueId" + /** Graph hosting the bottom-nav + nested authed screens. */ const val MAIN_GRAPH = "main" } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt new file mode 100644 index 0000000..5f39bb7 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailScreen.kt @@ -0,0 +1,211 @@ +package com.pinakes.app.ui.screens.periodicals + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +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.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.pinakes.app.R +import com.pinakes.app.data.model.IssueArticle +import com.pinakes.app.data.model.PeriodicalIssueDetail +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.AvailabilityChip +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.screens.bookclub.openWeb +import com.pinakes.app.ui.theme.Spacing + +/** + * Issue (fascicolo) detail: large cover, metadata + status badge, the spoglio articles and — + * ONLY when the server exposes a public `pdf_url` — an "Open PDF" action (external viewer, + * same [openWeb] deep-link path the app already uses for web-only flows). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IssueDetailScreen(onNavigateUp: () -> Unit) { + val vm: IssueDetailViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val context = LocalContext.current + + val title = (state.content as? UiState.Success)?.data?.masthead?.title + ?: stringResource(R.string.periodicals_issue_fallback) + + Scaffold( + topBar = { PinakesTopBar(title = title, onNavigateUp = onNavigateUp) }, + ) { padding -> + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = vm::refresh, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + when (val content = state.content) { + is UiState.Loading -> LoadingState(label = stringResource(R.string.periodicals_issue_loading)) + is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Success -> { + val issue = content.data + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + item { IssueHeader(issue) } + item { + // pdf_url is nullable and only present when the PDF is public: + // the action is rendered exclusively behind that gate. + if (issue.canOpenPdf) { + PrimaryButton( + label = stringResource(R.string.periodicals_open_pdf), + onClick = { openWeb(context, issue.pdfUrl.orEmpty()) }, + modifier = Modifier.fillMaxWidth(), + leadingIcon = Icons.AutoMirrored.Outlined.OpenInNew, + ) + } + } + if (issue.articles.isNotEmpty()) { + item { + Text( + stringResource(R.string.periodicals_articles_section), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = Spacing.sm), + ) + } + items(issue.articles.size) { index -> + ArticleRow(issue.articles[index]) + } + } + } + } + } + } + } +} + +@Composable +private fun IssueHeader(issue: PeriodicalIssueDetail) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Spacing.lg), horizontalAlignment = Alignment.CenterHorizontally) { + PeriodicalLogo( + url = issue.coverUrl, + contentDescription = issue.title ?: issue.number.orEmpty(), + modifier = Modifier + .size(width = 160.dp, height = 220.dp) + .clip(MaterialTheme.shapes.medium), + ) + Spacer(Modifier.height(Spacing.md)) + Text( + issueHeading(issue.number, issue.title), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + val yearLine = issue.year?.let { y -> + listOfNotNull( + y.year.toString(), + y.volume?.takeIf { it.isNotBlank() } + ?.let { stringResource(R.string.periodicals_year_volume, it) }, + ).joinToString(" · ") + } + yearLine?.let { + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + val date = issue.coverDate?.takeIf { it.isNotBlank() } + ?: issue.publicationDate?.takeIf { it.isNotBlank() } + val meta = listOfNotNull( + date?.let { DateFormat.date(it) }, + issue.pages?.let { stringResource(R.string.periodicals_issue_pages, it) }, + ).joinToString(" · ") + if (meta.isNotBlank()) { + Text( + meta, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(Spacing.sm)) + AvailabilityChip( + status = issueStatusBadge(issue.status), + label = stringResource(issueStatusLabelRes(issue.status)), + ) + } + } +} + +@Composable +private fun ArticleRow(article: IssueArticle) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(Spacing.lg), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + article.title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + article.authors?.takeIf { it.isNotBlank() }?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + articlePagesLabel(article.pageStart, article.pageEnd)?.let { + Spacer(Modifier.width(Spacing.md)) + Text( + it, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +/** "pp. 12–18" for a range, "p. 12" for a single page, null when the server has no data. */ +@Composable +private fun articlePagesLabel(start: Int?, end: Int?): String? = when { + start != null && end != null && end != start -> + stringResource(R.string.periodicals_article_pages, start, end) + start != null -> stringResource(R.string.periodicals_article_page, start) + else -> null +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailViewModel.kt new file mode 100644 index 0000000..1d697b6 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueDetailViewModel.kt @@ -0,0 +1,58 @@ +package com.pinakes.app.ui.screens.periodicals + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.PeriodicalIssueDetail +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.PeriodicalsRepository +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.navigation.Routes +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 IssueDetailUiState( + val content: UiState = UiState.Loading, + val refreshing: Boolean = false, +) + +@HiltViewModel +class IssueDetailViewModel @Inject constructor( + private val repo: PeriodicalsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val issueId: Int = savedStateHandle.get(Routes.ARG_PERIODICAL_ISSUE_ID) ?: 0 + + private val _state = MutableStateFlow(IssueDetailUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load(initial = true) } + + fun refresh() = load(initial = false) + + private fun load(initial: Boolean) { + if (initial) _state.update { it.copy(content = UiState.Loading) } + else _state.update { it.copy(refreshing = true) } + viewModelScope.launch { + when (val res = repo.issue(issueId)) { + is ApiResult.Success -> _state.update { + it.copy(content = UiState.Success(res.data), refreshing = 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.periodicals_issue_error), + refreshing = false, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt new file mode 100644 index 0000000..b7b47de --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListScreen.kt @@ -0,0 +1,148 @@ +package com.pinakes.app.ui.screens.periodicals + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Newspaper +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.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.compose.collectAsStateWithLifecycle +import com.pinakes.app.R +import com.pinakes.app.data.model.PeriodicalIssue +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.AvailabilityChip +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.PinakesTopBar +import com.pinakes.app.ui.theme.Spacing + +/** Issues (fascicoli) of one year: cover, number, date and a status badge per row. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IssueListScreen( + onNavigateUp: () -> Unit, + onOpenIssue: (Int) -> Unit, +) { + val vm: IssueListViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + PinakesTopBar( + title = stringResource(R.string.periodicals_issues_title, vm.year), + onNavigateUp = onNavigateUp, + ) + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = vm::refresh, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + when (val content = state.content) { + is UiState.Loading -> LoadingState(label = stringResource(R.string.periodicals_issues_loading)) + is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Success -> + if (content.data.isEmpty()) { + EmptyState( + title = stringResource(R.string.periodicals_issues_empty_title), + subtitle = stringResource(R.string.periodicals_issues_empty_subtitle), + icon = Icons.Outlined.Newspaper, + ) + } else { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + items(content.data, key = { it.id }) { issue -> + IssueRow(issue = issue, onClick = { onOpenIssue(issue.id) }) + } + } + } + } + } + } +} + +/** "No. 12 · Title" heading, or just the localized fallback when both are absent. */ +@Composable +internal fun issueHeading(number: String?, title: String?): String { + val parts = listOfNotNull( + number?.takeIf { it.isNotBlank() }?.let { stringResource(R.string.periodicals_issue_number, it) }, + title?.takeIf { it.isNotBlank() }, + ) + return if (parts.isEmpty()) stringResource(R.string.periodicals_issue_fallback) + else parts.joinToString(" · ") +} + +@Composable +private fun IssueRow(issue: PeriodicalIssue, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(Spacing.lg), verticalAlignment = Alignment.CenterVertically) { + PeriodicalLogo( + url = issue.coverUrl, + contentDescription = issue.title ?: issue.number.orEmpty(), + modifier = Modifier + .size(width = 48.dp, height = 64.dp) + .clip(MaterialTheme.shapes.medium), + ) + Spacer(Modifier.width(Spacing.md)) + Column(Modifier.weight(1f)) { + Text( + issueHeading(issue.number, issue.title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val date = issue.coverDate?.takeIf { it.isNotBlank() } + ?: issue.publicationDate?.takeIf { it.isNotBlank() } + date?.let { + Text( + DateFormat.date(it), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.height(Spacing.xs)) + AvailabilityChip( + status = issueStatusBadge(issue.status), + label = stringResource(issueStatusLabelRes(issue.status)), + ) + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt new file mode 100644 index 0000000..94b7099 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/IssueListViewModel.kt @@ -0,0 +1,61 @@ +package com.pinakes.app.ui.screens.periodicals + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.PeriodicalIssue +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.PeriodicalsRepository +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.navigation.Routes +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 IssueListUiState( + val content: UiState> = UiState.Loading, + val refreshing: Boolean = false, +) + +@HiltViewModel +class IssueListViewModel @Inject constructor( + private val repo: PeriodicalsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + private val yearId: Int = savedStateHandle.get(Routes.ARG_PERIODICAL_YEAR_ID) ?: 0 + + /** Display year ("1998"), carried as a nav argument so the title needs no extra fetch. */ + val year: Int = savedStateHandle.get(Routes.ARG_PERIODICAL_YEAR) ?: 0 + + private val _state = MutableStateFlow(IssueListUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load(initial = true) } + + fun refresh() = load(initial = false) + + private fun load(initial: Boolean) { + if (initial) _state.update { it.copy(content = UiState.Loading) } + else _state.update { it.copy(refreshing = true) } + viewModelScope.launch { + when (val res = repo.yearIssues(yearId)) { + is ApiResult.Success -> _state.update { + it.copy(content = UiState.Success(res.data), refreshing = 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.periodicals_issues_error), + refreshing = false, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt new file mode 100644 index 0000000..dca022b --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailScreen.kt @@ -0,0 +1,232 @@ +package com.pinakes.app.ui.screens.periodicals + +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +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.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.compose.collectAsStateWithLifecycle +import com.pinakes.app.R +import com.pinakes.app.data.model.PeriodicalDetail +import com.pinakes.app.data.model.PeriodicalYear +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.common.resolvedMessage +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.theme.Spacing + +/** Masthead detail: header (logo, ISSN, publisher, coverage, holdings) + the years list. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PeriodicalDetailScreen( + onNavigateUp: () -> Unit, + onOpenYear: (yearId: Int, year: Int) -> Unit, +) { + val vm: PeriodicalDetailViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + + val title = (state.content as? UiState.Success)?.data?.title + ?: stringResource(R.string.periodicals_title) + + Scaffold( + topBar = { PinakesTopBar(title = title, onNavigateUp = onNavigateUp) }, + ) { padding -> + PullToRefreshBox( + isRefreshing = state.refreshing, + onRefresh = vm::refresh, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + when (val content = state.content) { + is UiState.Loading -> LoadingState(label = stringResource(R.string.periodicals_detail_loading)) + is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::refresh) + is UiState.Success -> { + val detail = content.data + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + item { PeriodicalHeader(detail) } + if (detail.years.isNotEmpty()) { + item { + Text( + stringResource(R.string.periodicals_years_section), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = Spacing.sm), + ) + } + items(detail.years, key = { it.id }) { year -> + YearRow(year = year, onClick = { onOpenYear(year.id, year.year) }) + } + } + } + } + } + } + } +} + +@Composable +private fun PeriodicalHeader(detail: PeriodicalDetail) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Spacing.lg)) { + Row(verticalAlignment = Alignment.CenterVertically) { + PeriodicalLogo( + url = detail.logoUrl, + contentDescription = detail.title, + modifier = Modifier + .size(width = 64.dp, height = 84.dp) + .clip(MaterialTheme.shapes.medium), + ) + Spacer(Modifier.width(Spacing.md)) + Column(Modifier.weight(1f)) { + Text( + detail.title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + detail.subtitle?.takeIf { it.isNotBlank() }?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + val meta = listOfNotNull( + stringResource(periodicalTypeLabelRes(detail.type)), + periodicalFrequencyLabelRes(detail.frequency)?.let { stringResource(it) }, + ).joinToString(" · ") + Text( + meta, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(Modifier.height(Spacing.md)) + + detail.publisher?.let { InfoRow(stringResource(R.string.periodicals_label_publisher), it.name) } + detail.issn?.takeIf { it.isNotBlank() }?.let { InfoRow("ISSN", it) } + val place = listOfNotNull( + detail.place?.takeIf { it.isNotBlank() }, + detail.language?.takeIf { it.isNotBlank() }, + ).joinToString(" · ") + if (place.isNotBlank()) InfoRow(stringResource(R.string.periodicals_label_place), place) + coverageLabel(detail.yearStart, detail.yearEnd)?.let { + InfoRow(stringResource(R.string.periodicals_label_years), it) + } + detail.holdings?.takeIf { it.isNotBlank() }?.let { + InfoRow(stringResource(R.string.periodicals_label_holdings), it) + } + + detail.description?.takeIf { it.isNotBlank() }?.let { + Spacer(Modifier.height(Spacing.sm)) + Text( + it, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } +} + +/** "1946–1998", "Since 1946", or null when the server has no coverage data. */ +@Composable +private fun coverageLabel(start: Int?, end: Int?): String? = when { + start != null && end != null -> "$start–$end" + start != null -> stringResource(R.string.periodicals_year_since, start) + else -> null +} + +@Composable +private fun InfoRow(label: String, value: String) { + Row(Modifier.fillMaxWidth().padding(vertical = Spacing.xxs)) { + Text( + label, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.width(96.dp), + ) + Text( + value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } +} + +@Composable +private fun YearRow(year: PeriodicalYear, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(Spacing.lg), verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + val heading = listOfNotNull( + year.year.toString(), + year.volume?.takeIf { it.isNotBlank() } + ?.let { stringResource(R.string.periodicals_year_volume, it) }, + ).joinToString(" · ") + Text( + heading, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + val subtitle = listOfNotNull( + stringResource(R.string.periodicals_year_issues_owned, year.ownedCount, year.issuesCount), + if (year.bound) stringResource(R.string.periodicals_year_bound) else null, + ).joinToString(" · ") + Text( + subtitle, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailViewModel.kt new file mode 100644 index 0000000..42bf2f7 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalDetailViewModel.kt @@ -0,0 +1,59 @@ +package com.pinakes.app.ui.screens.periodicals + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.PeriodicalDetail +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.PeriodicalsRepository +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.navigation.Routes +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 PeriodicalDetailUiState( + val content: UiState = UiState.Loading, + val refreshing: Boolean = false, +) + +@HiltViewModel +class PeriodicalDetailViewModel @Inject constructor( + private val repo: PeriodicalsRepository, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + + // The masthead id arrives as a navigation argument; Hilt populates SavedStateHandle. + private val periodicalId: Int = savedStateHandle.get(Routes.ARG_PERIODICAL_ID) ?: 0 + + private val _state = MutableStateFlow(PeriodicalDetailUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load(initial = true) } + + fun refresh() = load(initial = false) + + private fun load(initial: Boolean) { + if (initial) _state.update { it.copy(content = UiState.Loading) } + else _state.update { it.copy(refreshing = true) } + viewModelScope.launch { + when (val res = repo.periodical(periodicalId)) { + is ApiResult.Success -> _state.update { + it.copy(content = UiState.Success(res.data), refreshing = 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.periodicals_detail_error), + refreshing = false, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt new file mode 100644 index 0000000..aa926b7 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsScreen.kt @@ -0,0 +1,261 @@ +package com.pinakes.app.ui.screens.periodicals + +import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll +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.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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.outlined.Newspaper +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +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.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +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.compose.collectAsStateWithLifecycle +import coil.compose.SubcomposeAsyncImage +import com.pinakes.app.R +import com.pinakes.app.data.model.PeriodicalSummary +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.PinakesTopBar +import com.pinakes.app.ui.components.SearchField +import com.pinakes.app.ui.theme.Spacing + +/** + * Emeroteca landing: searchable, type-filterable list of the library's periodical + * mastheads, cursor-paginated with load-more on scroll (mirrors the catalog search). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PeriodicalsScreen( + onNavigateUp: () -> Unit, + onOpenPeriodical: (Int) -> Unit, +) { + val vm: PeriodicalsViewModel = hiltViewModel() + val state by vm.state.collectAsStateWithLifecycle() + val listState = rememberLazyListState() + + // Infinite scroll: load the next page when nearing the end. + val shouldLoadMore by remember { + derivedStateOf { + val layout = listState.layoutInfo + val last = layout.visibleItemsInfo.lastOrNull()?.index ?: 0 + last >= layout.totalItemsCount - 4 && state.hasMore + } + } + LaunchedEffect(shouldLoadMore) { + if (shouldLoadMore) vm.loadMore() + } + + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.periodicals_title), onNavigateUp = onNavigateUp) }, + ) { padding -> + Column(Modifier.fillMaxSize().padding(padding)) { + Column(Modifier.padding(horizontal = Spacing.lg).padding(top = Spacing.sm)) { + SearchField( + query = state.query, + onQueryChange = vm::onQueryChange, + onSearch = vm::submitSearch, + placeholder = stringResource(R.string.periodicals_search_placeholder), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(Spacing.sm)) + TypeFilterRow(selected = state.type, onToggle = vm::onTypeToggled) + Spacer(Modifier.height(Spacing.sm)) + } + + PullToRefreshBox( + isRefreshing = state.loading && state.items.isNotEmpty(), + onRefresh = vm::refresh, + modifier = Modifier.fillMaxSize(), + ) { + when { + state.loading && state.items.isEmpty() -> + LoadingState(label = stringResource(R.string.periodicals_loading)) + state.error != null && state.items.isEmpty() -> + // Plugin deactivated server-side: a friendly terminal state, not a + // retryable error (the feature flag is already flipped off). + if (state.pluginGone) EmptyState( + title = stringResource(R.string.periodicals_gone_title), + subtitle = stringResource(R.string.periodicals_gone_subtitle), + ) else ErrorState( + message = stringResource(R.string.periodicals_error_load), + onRetry = vm::refresh, + ) + state.items.isEmpty() -> EmptyState( + title = stringResource(R.string.periodicals_empty_title), + subtitle = stringResource(R.string.periodicals_empty_subtitle), + icon = Icons.Outlined.Newspaper, + ) + else -> LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = Spacing.lg, vertical = Spacing.sm), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + items(state.items, key = { it.id }) { p -> + PeriodicalCard(periodical = p, onClick = { onOpenPeriodical(p.id) }) + } + if (state.loadingMore) { + item { + Box( + Modifier.fillMaxWidth().padding(Spacing.lg), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.height(24.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } + } +} + +@Composable +private fun TypeFilterRow(selected: String?, onToggle: (String) -> Unit) { + Row( + Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + ) { + PERIODICAL_TYPES.forEach { type -> + FilterChip( + selected = type == selected, + onClick = { onToggle(type) }, + label = { Text(stringResource(periodicalTypeLabelRes(type))) }, + ) + } + } +} + +@Composable +private fun PeriodicalCard(periodical: PeriodicalSummary, onClick: () -> Unit) { + Surface( + onClick = onClick, + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(Spacing.lg), verticalAlignment = Alignment.CenterVertically) { + PeriodicalLogo( + url = periodical.logoUrl, + contentDescription = periodical.title, + modifier = Modifier + .size(width = 56.dp, height = 72.dp) + .clip(MaterialTheme.shapes.medium), + ) + Spacer(Modifier.width(Spacing.md)) + Column(Modifier.weight(1f)) { + Text( + periodical.title, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + periodical.subtitle?.takeIf { it.isNotBlank() }?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + val meta = listOfNotNull( + stringResource(periodicalTypeLabelRes(periodical.type)), + periodicalFrequencyLabelRes(periodical.frequency)?.let { stringResource(it) }, + ).joinToString(" · ") + Text( + meta, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + stringResource(R.string.periodicals_years_count, periodical.yearsCount) + + " · " + + stringResource(R.string.periodicals_issues_count, periodical.issuesCount), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** Masthead logo / issue cover thumbnail with the same placeholder styling as book covers. */ +@Composable +fun PeriodicalLogo( + url: String?, + contentDescription: String, + modifier: Modifier = Modifier, +) { + SubcomposeAsyncImage( + model = url, + contentDescription = contentDescription, + modifier = modifier, + contentScale = ContentScale.Crop, + loading = { PeriodicalLogoPlaceholder() }, + error = { PeriodicalLogoPlaceholder() }, + ) +} + +@Composable +private fun PeriodicalLogoPlaceholder() { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Newspaper, + contentDescription = null, + tint = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.height(28.dp), + ) + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt new file mode 100644 index 0000000..0d73181 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUi.kt @@ -0,0 +1,63 @@ +package com.pinakes.app.ui.screens.periodicals + +import androidx.annotation.StringRes +import com.pinakes.app.R +import com.pinakes.app.ui.components.AvailabilityStatus + +/** + * Pure helpers for the Periodicals screens: enum → localized label lookups and the + * issue-status → badge mapping. Kept free of Compose so they are unit-testable + * (see PeriodicalsUiStateTest). + */ + +/** The masthead types the server may emit, in filter-chip order. */ +val PERIODICAL_TYPES = listOf("rivista", "giornale", "magazine", "bollettino", "fanzine") + +/** Localized label for a masthead type. Unknown values fall back to the generic "rivista". */ +@StringRes +fun periodicalTypeLabelRes(type: String): Int = when (type) { + "giornale" -> R.string.periodicals_type_giornale + "magazine" -> R.string.periodicals_type_magazine + "bollettino" -> R.string.periodicals_type_bollettino + "fanzine" -> R.string.periodicals_type_fanzine + else -> R.string.periodicals_type_rivista +} + +/** Localized label for a publication frequency, or null for unknown/absent values. */ +@StringRes +fun periodicalFrequencyLabelRes(frequency: String?): Int? = when (frequency) { + "quotidiano" -> R.string.periodicals_freq_quotidiano + "settimanale" -> R.string.periodicals_freq_settimanale + "quindicinale" -> R.string.periodicals_freq_quindicinale + "mensile" -> R.string.periodicals_freq_mensile + "bimestrale" -> R.string.periodicals_freq_bimestrale + "trimestrale" -> R.string.periodicals_freq_trimestrale + "semestrale" -> R.string.periodicals_freq_semestrale + "annuale" -> R.string.periodicals_freq_annuale + "irregolare" -> R.string.periodicals_freq_irregolare + else -> null +} + +/** Localized label for an issue status. Unknown values read as "expected" (neutral). */ +@StringRes +fun issueStatusLabelRes(status: String): Int = when (status) { + "posseduto" -> R.string.periodicals_status_posseduto + "mancante" -> R.string.periodicals_status_mancante + "danneggiato" -> R.string.periodicals_status_danneggiato + "in_restauro" -> R.string.periodicals_status_in_restauro + "smarrito" -> R.string.periodicals_status_smarrito + else -> R.string.periodicals_status_atteso +} + +/** + * Issue status → badge tone, reusing [AvailabilityStatus] so the chip colours stay + * consistent with the rest of the app: + * posseduto = ok (green) · mancante/smarrito = error (red) · danneggiato/in_restauro = + * warning (amber) · atteso and anything unknown = neutral (grey). + */ +fun issueStatusBadge(status: String): AvailabilityStatus = when (status) { + "posseduto" -> AvailabilityStatus.Available + "mancante", "smarrito" -> AvailabilityStatus.Overdue + "danneggiato", "in_restauro" -> AvailabilityStatus.DueSoon + else -> AvailabilityStatus.Returned +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsViewModel.kt new file mode 100644 index 0000000..5ff747c --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsViewModel.kt @@ -0,0 +1,168 @@ +package com.pinakes.app.ui.screens.periodicals + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.pinakes.app.data.model.PeriodicalSummary +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.ErrorCodes +import com.pinakes.app.data.repository.PeriodicalsRepository +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 PeriodicalsUiState( + val query: String = "", + /** Active type filter chip (server `type` param), or null for all types. */ + val type: String? = null, + val items: List = emptyList(), + val nextCursor: String? = null, + val loading: Boolean = true, // first page + val loadingMore: Boolean = false, // pagination + val error: String? = null, + /** The plugin was deactivated server-side (confirmed via health re-probe). */ + val pluginGone: Boolean = false, +) { + val hasMore: Boolean get() = nextCursor != null +} + +/** Apply a query edit (pagination is reset by the reload the ViewModel triggers). */ +internal fun PeriodicalsUiState.withQuery(value: String): PeriodicalsUiState = + copy(query = value) + +/** Toggle a type filter chip: tapping the active chip clears the filter. */ +internal fun PeriodicalsUiState.withTypeToggled(value: String): PeriodicalsUiState = + copy(type = if (type == value) null else value) + +/** + * Append the next cursor page, dropping any item whose id is already listed. Cursor + * pagination can hand back a boundary row twice (an insert/delete shifts the window + * between requests) and LazyColumn keys must stay unique. + */ +internal fun PeriodicalsUiState.appendPage( + page: List, + cursor: String?, +): PeriodicalsUiState { + val seen = items.mapTo(HashSet()) { it.id } + return copy( + items = items + page.filter { seen.add(it.id) }, + nextCursor = cursor, + loadingMore = false, + ) +} + +@HiltViewModel +class PeriodicalsViewModel @Inject constructor( + private val repo: PeriodicalsRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(PeriodicalsUiState()) + val state: StateFlow = _state.asStateFlow() + + private var searchJob: Job? = null + + /** + * Monotonic request generation, bumped on every reset (query/type change, refresh). + * In-flight coroutines capture it at launch and drop their result when superseded, so + * a slow first page or loadMore can never append stale-filter rows (same pattern as + * SearchViewModel). + */ + private var generation = 0 + + init { load(reset = true) } + + fun refresh() = load(reset = true) + + fun onQueryChange(value: String) { + _state.update { it.withQuery(value) } + // Debounced auto-search as the user types (mirrors the catalog search). + searchJob?.cancel() + searchJob = viewModelScope.launch { + delay(350) + load(reset = true) + } + } + + fun submitSearch() { + searchJob?.cancel() + load(reset = true) + } + + fun onTypeToggled(value: String) { + _state.update { it.withTypeToggled(value) } + searchJob?.cancel() + load(reset = true) + } + + fun loadMore() { + val s = _state.value + if (s.loading || s.loadingMore || !s.hasMore) return + _state.update { it.copy(loadingMore = true) } + val gen = generation + viewModelScope.launch { + val res = repo.periodicals( + query = s.query.takeIf { it.isNotBlank() }, + type = s.type, + cursor = s.nextCursor, + ) + // A reset superseded this page mid-flight: drop it (the reset cleared loadingMore). + if (gen != generation) return@launch + when (res) { + is ApiResult.Success -> _state.update { it.appendPage(res.data.items, res.data.nextCursor) } + is ApiResult.Failure -> _state.update { it.copy(loadingMore = false) } + } + } + } + + private fun load(reset: Boolean) { + if (reset) generation++ + val gen = generation + _state.update { + it.copy( + loading = true, + error = null, + items = if (reset) emptyList() else it.items, + nextCursor = null, + loadingMore = false, + ) + } + val s = _state.value + viewModelScope.launch { + val res = repo.periodicals( + query = s.query.takeIf { q -> q.isNotBlank() }, + type = s.type, + ) + if (gen != generation) return@launch + when (res) { + is ApiResult.Success -> _state.update { + it.copy( + items = res.data.items, + nextCursor = res.data.nextCursor, + loading = false, + error = null, + ) + } + is ApiResult.Failure -> { + // 404 usually means the plugin was deactivated: confirm via the health + // probe (which also flips the feature flag so the Profile entry hides) + // and degrade to a friendly "gone" state instead of a retryable error. + val gone = (res.httpStatus == 404 || res.code == ErrorCodes.NOT_FOUND) && + repo.confirmGone() + if (gen != generation) return@launch + _state.update { + it.copy( + loading = false, + error = res.message.ifBlank { res.code }, + pluginGone = gone, + ) + } + } + } + } + } +} 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 4c1f56d..ec232da 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 @@ -28,6 +28,7 @@ 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.Lock +import androidx.compose.material.icons.outlined.Newspaper import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.PhoneAndroid import androidx.compose.material.icons.outlined.RateReview @@ -91,6 +92,7 @@ fun ProfileScreen( onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, onOpenBookClub: () -> Unit, + onOpenPeriodicals: () -> Unit, ) { val app: AppViewModel = hiltViewModel() val vm: ProfileViewModel = hiltViewModel() @@ -120,10 +122,12 @@ fun ProfileScreen( onOpenContact = onOpenContact, onOpenMyReviews = onOpenMyReviews, onOpenBookClub = onOpenBookClub, + onOpenPeriodicals = onOpenPeriodicals, showNotifications = features.notifications, showContact = features.messages, showReviews = features.showReviews, showBookClub = features.bookClubAvailable, + showPeriodicals = features.periodicalsAvailable, ) } } @@ -187,10 +191,12 @@ private fun ProfileContent( onOpenContact: () -> Unit, onOpenMyReviews: () -> Unit, onOpenBookClub: () -> Unit, + onOpenPeriodicals: () -> Unit, showNotifications: Boolean, showContact: Boolean, showReviews: Boolean, showBookClub: Boolean, + showPeriodicals: Boolean, ) { Column( Modifier @@ -261,6 +267,9 @@ private fun ProfileContent( if (showBookClub) { ActionRow(Icons.Outlined.Groups, stringResource(R.string.profile_action_book_club), onClick = onOpenBookClub) } + if (showPeriodicals) { + ActionRow(Icons.Outlined.Newspaper, stringResource(R.string.profile_action_periodicals), onClick = onOpenPeriodicals) + } if (showNotifications) { ActionRow(Icons.Outlined.Notifications, stringResource(R.string.profile_action_notifications), onClick = onOpenNotifications) } diff --git a/app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt b/app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt new file mode 100644 index 0000000..5d57731 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/ui/screens/periodicals/PeriodicalsUiStateTest.kt @@ -0,0 +1,115 @@ +package com.pinakes.app.ui.screens.periodicals + +import com.pinakes.app.data.model.PeriodicalIssueDetail +import com.pinakes.app.data.model.PeriodicalSummary +import com.pinakes.app.ui.components.AvailabilityStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Regression guards for the pure Periodicals UI-state functions (pattern: SearchSortStateTest — + * the ViewModels are not tested directly). + */ +class PeriodicalsUiStateTest { + + private fun summary(id: Int, title: String = "Testata $id") = + PeriodicalSummary(id = id, title = title, type = "rivista") + + // ---- Pagination merge ---- + + @Test fun appendPageAddsNewItemsAndKeepsCursor() { + val state = PeriodicalsUiState( + items = listOf(summary(1), summary(2)), + nextCursor = "c1", + loadingMore = true, + ) + + val next = state.appendPage(listOf(summary(3), summary(4)), cursor = "c2") + + assertEquals(listOf(1, 2, 3, 4), next.items.map { it.id }) + assertEquals("c2", next.nextCursor) + assertFalse(next.loadingMore) + assertTrue(next.hasMore) + } + + @Test fun appendPageDropsDuplicateIdsFromTheBoundary() { + val state = PeriodicalsUiState(items = listOf(summary(1), summary(2)), nextCursor = "c1") + + // Cursor windows can overlap on a boundary row: the repeated id must not re-appear + // (LazyColumn keys are the ids and must stay unique). + val next = state.appendPage(listOf(summary(2), summary(3)), cursor = null) + + assertEquals(listOf(1, 2, 3), next.items.map { it.id }) + assertNull(next.nextCursor) + assertFalse(next.hasMore) + } + + @Test fun appendPageWithOnlyDuplicatesLeavesTheListUnchanged() { + val state = PeriodicalsUiState(items = listOf(summary(1)), nextCursor = "c1") + + val next = state.appendPage(listOf(summary(1)), cursor = null) + + assertEquals(listOf(1), next.items.map { it.id }) + } + + // ---- Type filter ---- + + @Test fun togglingATypeSelectsIt() { + val next = PeriodicalsUiState().withTypeToggled("giornale") + + assertEquals("giornale", next.type) + } + + @Test fun togglingTheActiveTypeClearsTheFilter() { + val state = PeriodicalsUiState(type = "giornale") + + assertNull(state.withTypeToggled("giornale").type) + } + + @Test fun togglingADifferentTypeReplacesTheFilter() { + val state = PeriodicalsUiState(type = "giornale") + + assertEquals("fanzine", state.withTypeToggled("fanzine").type) + } + + @Test fun queryEditKeepsTheTypeFilter() { + val state = PeriodicalsUiState(type = "rivista").withQuery("domenica") + + assertEquals("domenica", state.query) + assertEquals("rivista", state.type) + } + + // ---- Issue status → badge mapping ---- + + @Test fun ownedMapsToTheOkBadge() { + assertEquals(AvailabilityStatus.Available, issueStatusBadge("posseduto")) + } + + @Test fun missingAndLostMapToTheErrorBadge() { + assertEquals(AvailabilityStatus.Overdue, issueStatusBadge("mancante")) + assertEquals(AvailabilityStatus.Overdue, issueStatusBadge("smarrito")) + } + + @Test fun damagedAndUnderRestorationMapToTheWarningBadge() { + assertEquals(AvailabilityStatus.DueSoon, issueStatusBadge("danneggiato")) + assertEquals(AvailabilityStatus.DueSoon, issueStatusBadge("in_restauro")) + } + + @Test fun expectedAndUnknownStatusesMapToTheNeutralBadge() { + assertEquals(AvailabilityStatus.Returned, issueStatusBadge("atteso")) + assertEquals(AvailabilityStatus.Returned, issueStatusBadge("")) + assertEquals(AvailabilityStatus.Returned, issueStatusBadge("qualcosa_di_nuovo")) + } + + // ---- PDF action gating ---- + + @Test fun pdfActionIsOfferedOnlyForANonBlankPublicUrl() { + assertTrue(PeriodicalIssueDetail(id = 1, pdfUrl = "https://example.org/f/1.pdf").canOpenPdf) + assertFalse(PeriodicalIssueDetail(id = 1, pdfUrl = null).canOpenPdf) + assertFalse(PeriodicalIssueDetail(id = 1, pdfUrl = "").canOpenPdf) + assertFalse(PeriodicalIssueDetail(id = 1, pdfUrl = " ").canOpenPdf) + } +} diff --git a/i18n/de.json b/i18n/de.json index f23a5e4..e3398b5 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -435,5 +435,61 @@ "book_club_privacy_hidden": "Verborgen", "book_club_error_dashboard": "Dein Lesebereich konnte nicht geladen werden. Zum Aktualisieren ziehen.", "register_schema_failed": "Zusätzliche Registrierungsfelder konnten nicht geladen werden — du kannst dich trotzdem registrieren oder es erneut versuchen.", - "action_clear": "Löschen" + "action_clear": "Löschen", + "profile_action_periodicals": "Zeitschriften", + "periodicals_title": "Zeitschriften", + "periodicals_search_placeholder": "Zeitschriften durchsuchen", + "periodicals_loading": "Zeitschriften werden geladen…", + "periodicals_error_load": "Die Zeitschriften konnten nicht geladen werden.", + "periodicals_gone_title": "Zeitschriften nicht verfügbar", + "periodicals_gone_subtitle": "Der Zeitschriftenbereich ist in dieser Bibliothek nicht mehr aktiv.", + "periodicals_empty_title": "Keine Zeitschriften gefunden", + "periodicals_empty_subtitle": "Versuchen Sie eine andere Suche oder einen anderen Filter.", + "periodicals_years_count": "%1$d Jahrgänge", + "periodicals_issues_count": "%1$d Hefte", + "periodicals_detail_loading": "Zeitschrift wird geladen…", + "periodicals_detail_error": "Diese Zeitschrift konnte nicht geladen werden.", + "periodicals_label_publisher": "Verlag", + "periodicals_label_place": "Erscheinen", + "periodicals_label_years": "Zeitraum", + "periodicals_label_holdings": "Bestand", + "periodicals_year_since": "Seit %1$d", + "periodicals_years_section": "Jahrgänge", + "periodicals_year_volume": "Bd. %1$s", + "periodicals_year_bound": "Gebunden", + "periodicals_year_issues_owned": "%1$d von %2$d Heften vorhanden", + "periodicals_issues_title": "Jahrgang %1$d", + "periodicals_issues_loading": "Hefte werden geladen…", + "periodicals_issues_error": "Die Hefte konnten nicht geladen werden.", + "periodicals_issues_empty_title": "Keine Hefte", + "periodicals_issues_empty_subtitle": "Für diesen Jahrgang sind noch keine Hefte erfasst.", + "periodicals_issue_fallback": "Heft", + "periodicals_issue_number": "Nr. %1$s", + "periodicals_issue_pages": "%1$d Seiten", + "periodicals_issue_loading": "Heft wird geladen…", + "periodicals_issue_error": "Dieses Heft konnte nicht geladen werden.", + "periodicals_articles_section": "Inhalt", + "periodicals_article_pages": "S. %1$d–%2$d", + "periodicals_article_page": "S. %1$d", + "periodicals_open_pdf": "PDF öffnen", + "periodicals_type_rivista": "Zeitschrift", + "periodicals_type_giornale": "Zeitung", + "periodicals_type_magazine": "Magazin", + "periodicals_type_bollettino": "Bulletin", + "periodicals_type_fanzine": "Fanzine", + "periodicals_freq_quotidiano": "Täglich", + "periodicals_freq_settimanale": "Wöchentlich", + "periodicals_freq_quindicinale": "Vierzehntäglich", + "periodicals_freq_mensile": "Monatlich", + "periodicals_freq_bimestrale": "Zweimonatlich", + "periodicals_freq_trimestrale": "Vierteljährlich", + "periodicals_freq_semestrale": "Halbjährlich", + "periodicals_freq_annuale": "Jährlich", + "periodicals_freq_irregolare": "Unregelmäßig", + "periodicals_status_posseduto": "Vorhanden", + "periodicals_status_mancante": "Fehlend", + "periodicals_status_danneggiato": "Beschädigt", + "periodicals_status_in_restauro": "In Restaurierung", + "periodicals_status_smarrito": "Verloren", + "periodicals_status_atteso": "Erwartet" } diff --git a/i18n/en.json b/i18n/en.json index 9d14a36..1254535 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -435,5 +435,61 @@ "book_club_privacy_hidden": "Hidden", "book_club_error_dashboard": "Couldn't load your reading section. Pull to refresh.", "register_schema_failed": "Couldn't load the extra registration fields — you can still sign up, or retry.", - "action_clear": "Clear" + "action_clear": "Clear", + "profile_action_periodicals": "Periodicals", + "periodicals_title": "Periodicals", + "periodicals_search_placeholder": "Search periodicals", + "periodicals_loading": "Loading periodicals…", + "periodicals_error_load": "Couldn't load the periodicals.", + "periodicals_gone_title": "Periodicals unavailable", + "periodicals_gone_subtitle": "The periodicals section is no longer active on this library.", + "periodicals_empty_title": "No periodicals found", + "periodicals_empty_subtitle": "Try a different search or filter.", + "periodicals_years_count": "%1$d years", + "periodicals_issues_count": "%1$d issues", + "periodicals_detail_loading": "Loading periodical…", + "periodicals_detail_error": "Couldn't load this periodical.", + "periodicals_label_publisher": "Publisher", + "periodicals_label_place": "Publication", + "periodicals_label_years": "Coverage", + "periodicals_label_holdings": "Holdings", + "periodicals_year_since": "Since %1$d", + "periodicals_years_section": "Years", + "periodicals_year_volume": "Vol. %1$s", + "periodicals_year_bound": "Bound", + "periodicals_year_issues_owned": "%1$d of %2$d issues owned", + "periodicals_issues_title": "Year %1$d", + "periodicals_issues_loading": "Loading issues…", + "periodicals_issues_error": "Couldn't load the issues.", + "periodicals_issues_empty_title": "No issues", + "periodicals_issues_empty_subtitle": "This year has no catalogued issues yet.", + "periodicals_issue_fallback": "Issue", + "periodicals_issue_number": "No. %1$s", + "periodicals_issue_pages": "%1$d pages", + "periodicals_issue_loading": "Loading issue…", + "periodicals_issue_error": "Couldn't load this issue.", + "periodicals_articles_section": "Contents", + "periodicals_article_pages": "pp. %1$d–%2$d", + "periodicals_article_page": "p. %1$d", + "periodicals_open_pdf": "Open PDF", + "periodicals_type_rivista": "Journal", + "periodicals_type_giornale": "Newspaper", + "periodicals_type_magazine": "Magazine", + "periodicals_type_bollettino": "Bulletin", + "periodicals_type_fanzine": "Fanzine", + "periodicals_freq_quotidiano": "Daily", + "periodicals_freq_settimanale": "Weekly", + "periodicals_freq_quindicinale": "Fortnightly", + "periodicals_freq_mensile": "Monthly", + "periodicals_freq_bimestrale": "Bimonthly", + "periodicals_freq_trimestrale": "Quarterly", + "periodicals_freq_semestrale": "Half-yearly", + "periodicals_freq_annuale": "Yearly", + "periodicals_freq_irregolare": "Irregular", + "periodicals_status_posseduto": "Owned", + "periodicals_status_mancante": "Missing", + "periodicals_status_danneggiato": "Damaged", + "periodicals_status_in_restauro": "Under restoration", + "periodicals_status_smarrito": "Lost", + "periodicals_status_atteso": "Expected" } diff --git a/i18n/fr.json b/i18n/fr.json index ae2cd9f..5163bae 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -435,5 +435,61 @@ "book_club_privacy_hidden": "Masqué", "book_club_error_dashboard": "Impossible de charger votre section de lecture. Tirez pour actualiser.", "register_schema_failed": "Impossible de charger les champs d'inscription supplémentaires — vous pouvez quand même vous inscrire, ou réessayer.", - "action_clear": "Effacer" + "action_clear": "Effacer", + "profile_action_periodicals": "Périodiques", + "periodicals_title": "Périodiques", + "periodicals_search_placeholder": "Rechercher des périodiques", + "periodicals_loading": "Chargement des périodiques…", + "periodicals_error_load": "Impossible de charger les périodiques.", + "periodicals_gone_title": "Périodiques indisponibles", + "periodicals_gone_subtitle": "La section des périodiques n'est plus active dans cette bibliothèque.", + "periodicals_empty_title": "Aucun périodique trouvé", + "periodicals_empty_subtitle": "Essayez une autre recherche ou un autre filtre.", + "periodicals_years_count": "%1$d années", + "periodicals_issues_count": "%1$d fascicules", + "periodicals_detail_loading": "Chargement du périodique…", + "periodicals_detail_error": "Impossible de charger ce périodique.", + "periodicals_label_publisher": "Éditeur", + "periodicals_label_place": "Publication", + "periodicals_label_years": "Période", + "periodicals_label_holdings": "État de collection", + "periodicals_year_since": "Depuis %1$d", + "periodicals_years_section": "Années", + "periodicals_year_volume": "Vol. %1$s", + "periodicals_year_bound": "Reliée", + "periodicals_year_issues_owned": "%1$d fascicules possédés sur %2$d", + "periodicals_issues_title": "Année %1$d", + "periodicals_issues_loading": "Chargement des fascicules…", + "periodicals_issues_error": "Impossible de charger les fascicules.", + "periodicals_issues_empty_title": "Aucun fascicule", + "periodicals_issues_empty_subtitle": "Cette année n'a pas encore de fascicules catalogués.", + "periodicals_issue_fallback": "Fascicule", + "periodicals_issue_number": "N° %1$s", + "periodicals_issue_pages": "%1$d pages", + "periodicals_issue_loading": "Chargement du fascicule…", + "periodicals_issue_error": "Impossible de charger ce fascicule.", + "periodicals_articles_section": "Sommaire", + "periodicals_article_pages": "pp. %1$d–%2$d", + "periodicals_article_page": "p. %1$d", + "periodicals_open_pdf": "Ouvrir le PDF", + "periodicals_type_rivista": "Revue", + "periodicals_type_giornale": "Journal", + "periodicals_type_magazine": "Magazine", + "periodicals_type_bollettino": "Bulletin", + "periodicals_type_fanzine": "Fanzine", + "periodicals_freq_quotidiano": "Quotidien", + "periodicals_freq_settimanale": "Hebdomadaire", + "periodicals_freq_quindicinale": "Bimensuel", + "periodicals_freq_mensile": "Mensuel", + "periodicals_freq_bimestrale": "Bimestriel", + "periodicals_freq_trimestrale": "Trimestriel", + "periodicals_freq_semestrale": "Semestriel", + "periodicals_freq_annuale": "Annuel", + "periodicals_freq_irregolare": "Irrégulier", + "periodicals_status_posseduto": "Possédé", + "periodicals_status_mancante": "Manquant", + "periodicals_status_danneggiato": "Endommagé", + "periodicals_status_in_restauro": "En restauration", + "periodicals_status_smarrito": "Perdu", + "periodicals_status_atteso": "Attendu" } diff --git a/i18n/it.json b/i18n/it.json index 2231fae..9c6e0ea 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -435,5 +435,61 @@ "book_club_privacy_hidden": "Nascosto", "book_club_error_dashboard": "Impossibile caricare la sezione delle tue letture. Trascina per aggiornare.", "register_schema_failed": "Impossibile caricare i campi di registrazione aggiuntivi — puoi comunque registrarti, oppure riprovare.", - "action_clear": "Cancella" + "action_clear": "Cancella", + "profile_action_periodicals": "Emeroteca", + "periodicals_title": "Emeroteca", + "periodicals_search_placeholder": "Cerca nell'emeroteca", + "periodicals_loading": "Caricamento dell'emeroteca…", + "periodicals_error_load": "Impossibile caricare l'emeroteca.", + "periodicals_gone_title": "Emeroteca non disponibile", + "periodicals_gone_subtitle": "L'emeroteca non è più attiva in questa biblioteca.", + "periodicals_empty_title": "Nessun periodico trovato", + "periodicals_empty_subtitle": "Prova con un'altra ricerca o un altro filtro.", + "periodicals_years_count": "%1$d annate", + "periodicals_issues_count": "%1$d fascicoli", + "periodicals_detail_loading": "Caricamento della testata…", + "periodicals_detail_error": "Impossibile caricare questa testata.", + "periodicals_label_publisher": "Editore", + "periodicals_label_place": "Pubblicazione", + "periodicals_label_years": "Periodo", + "periodicals_label_holdings": "Consistenza", + "periodicals_year_since": "Dal %1$d", + "periodicals_years_section": "Annate", + "periodicals_year_volume": "Vol. %1$s", + "periodicals_year_bound": "Rilegata", + "periodicals_year_issues_owned": "%1$d fascicoli posseduti su %2$d", + "periodicals_issues_title": "Annata %1$d", + "periodicals_issues_loading": "Caricamento dei fascicoli…", + "periodicals_issues_error": "Impossibile caricare i fascicoli.", + "periodicals_issues_empty_title": "Nessun fascicolo", + "periodicals_issues_empty_subtitle": "Questa annata non ha ancora fascicoli catalogati.", + "periodicals_issue_fallback": "Fascicolo", + "periodicals_issue_number": "N. %1$s", + "periodicals_issue_pages": "%1$d pagine", + "periodicals_issue_loading": "Caricamento del fascicolo…", + "periodicals_issue_error": "Impossibile caricare questo fascicolo.", + "periodicals_articles_section": "Spoglio", + "periodicals_article_pages": "pp. %1$d–%2$d", + "periodicals_article_page": "p. %1$d", + "periodicals_open_pdf": "Apri PDF", + "periodicals_type_rivista": "Rivista", + "periodicals_type_giornale": "Giornale", + "periodicals_type_magazine": "Magazine", + "periodicals_type_bollettino": "Bollettino", + "periodicals_type_fanzine": "Fanzine", + "periodicals_freq_quotidiano": "Quotidiano", + "periodicals_freq_settimanale": "Settimanale", + "periodicals_freq_quindicinale": "Quindicinale", + "periodicals_freq_mensile": "Mensile", + "periodicals_freq_bimestrale": "Bimestrale", + "periodicals_freq_trimestrale": "Trimestrale", + "periodicals_freq_semestrale": "Semestrale", + "periodicals_freq_annuale": "Annuale", + "periodicals_freq_irregolare": "Irregolare", + "periodicals_status_posseduto": "Posseduto", + "periodicals_status_mancante": "Mancante", + "periodicals_status_danneggiato": "Danneggiato", + "periodicals_status_in_restauro": "In restauro", + "periodicals_status_smarrito": "Smarrito", + "periodicals_status_atteso": "Atteso" } From 6f83e73c4ae1e1e6553eb273e00e1107053b4541 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 3 Sep 2026 17:54:16 +0200 Subject: [PATCH 2/2] fix(android): align periodical sequence type with backend contract --- .../main/java/com/pinakes/app/data/model/PeriodicalsModels.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt b/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt index cf2104e..6c6a1dd 100644 --- a/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/PeriodicalsModels.kt @@ -91,7 +91,7 @@ data class PeriodicalYear( data class PeriodicalIssue( val id: Int = 0, val number: String? = null, - val sequence: Int? = null, + val sequence: String? = null, val title: String? = null, @SerialName("cover_date") val coverDate: String? = null, @SerialName("publication_date") val publicationDate: String? = null, @@ -108,7 +108,7 @@ data class PeriodicalIssue( data class PeriodicalIssueDetail( val id: Int = 0, val number: String? = null, - val sequence: Int? = null, + val sequence: String? = null, val title: String? = null, @SerialName("cover_date") val coverDate: String? = null, @SerialName("publication_date") val publicationDate: String? = null,