From 26e73eab6da8b56cbb5a5c9504d192cd164f8c2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 2 Jul 2026 08:02:45 +0000 Subject: [PATCH 1/2] feat(reviews): book reviews with stars + text (API, UI, My reviews page) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the website's book-review feature to the Android app: a borrower can leave a 1–5 star rating with optional text on a title they've had on loan, everyone can read other users' reviews on the book detail, and each user has a "My reviews" page listing their own reviews. App: - Models: BookReviews (aggregate + own + others), Review, ReviewRequest, MyReview; `reviews` feature flag on HealthFeatures/InstanceFeatures. - API: GET/PUT/DELETE catalog/books/{id}/reviews and GET me/reviews, plus ReviewsRepository (cursor-paginated), wired into the ServiceLocator. - UI: reusable StarRating (read-only, half stars) and StarRatingInput (tappable) components matching the app's Material theme; a reviews section on the book detail with the aggregate rating, an inline create/edit/delete composer (gated on having borrowed the title + the feature flag), and other users' reviews; a My Reviews screen reachable from Profile. - i18n: review strings in all four locales (it/en/fr/de). Contract: - Document the endpoints and schemas in openapi.json, the endpoint manifest and MOBILE_API_SPEC.md for the backend to implement. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01CXHZkQnAVpxkJRQ7QqY3Ab --- _contract/MOBILE_API_SPEC.md | 5 + _contract/endpoint-manifest.spec.js | 5 + _contract/openapi.json | 390 ++++++++++++++++++ .../java/com/pinakes/app/data/model/Models.kt | 52 +++ .../pinakes/app/data/network/PinakesApi.kt | 31 ++ .../app/data/repository/ReviewsRepository.kt | 57 +++ .../pinakes/app/data/store/FeatureStore.kt | 8 + .../java/com/pinakes/app/di/ServiceLocator.kt | 2 + .../com/pinakes/app/ui/components/Rating.kt | 86 ++++ .../pinakes/app/ui/navigation/MainScaffold.kt | 2 + .../app/ui/navigation/PinakesNavHost.kt | 13 + .../com/pinakes/app/ui/navigation/Routes.kt | 1 + .../app/ui/screens/detail/BookDetailScreen.kt | 18 + .../ui/screens/detail/BookReviewsSection.kt | 327 +++++++++++++++ .../ui/screens/detail/BookReviewsViewModel.kt | 123 ++++++ .../app/ui/screens/profile/ProfileScreen.kt | 9 + .../app/ui/screens/reviews/MyReviewsScreen.kt | 216 ++++++++++ .../ui/screens/reviews/MyReviewsViewModel.kt | 90 ++++ i18n/de.json | 25 +- i18n/en.json | 25 +- i18n/fr.json | 25 +- i18n/it.json | 25 +- 22 files changed, 1531 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/pinakes/app/data/repository/ReviewsRepository.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/components/Rating.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsSection.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsViewModel.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt create mode 100644 app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsViewModel.kt diff --git a/_contract/MOBILE_API_SPEC.md b/_contract/MOBILE_API_SPEC.md index cb7a0fe..53ec7bb 100644 --- a/_contract/MOBILE_API_SPEC.md +++ b/_contract/MOBILE_API_SPEC.md @@ -51,6 +51,7 @@ stock). The app stores the instance URL + a long-lived per-device token. - `mobile_push_subscriptions` — `id`, `user_id`, `token_id` (FK → mobile_app_tokens, cascade), `provider` (`unifiedpush`|`fcm`), `endpoint`/`registration_id`, `public_key`/`auth` (for UnifiedPush WebPush), `created_at`, `last_ok_at`, `failure_count`. - `mobile_push_prefs` — `user_id` (PK), `loan_due` bool, `loan_overdue` bool, `reservation_ready` bool, `new_message` bool, `book_available` bool, `quiet_start` time NULL, `quiet_end` time NULL. - `mobile_availability_watchers` — `id`, `user_id`, `libro_id`, `created_at` — who to notify when a title is loanable again (from wishlist/reservation intent). +- `recensioni` (existing web reviews table, reused) — `id`, `libro_id` (FK), `utente_id` (FK), `voto` (1..5), `testo` NULL, `created_at`, `updated_at`. One review per (user, book) — unique `(libro_id, utente_id)`. The API only lets a user create/edit/delete **their own** review and only for a title they have borrowed (a past/present loan row). Follow the soft-delete rule on the joined `libri`. All FKs respect existing `utenti`/`libri` schema. Follow the soft-delete rule on every `libri` query. @@ -71,6 +72,10 @@ All FKs respect existing `utenti`/`libri` schema. Follow the soft-delete rule on - `GET /catalog/search` — filters: `q`, `author`, `publisher`, `genre` (cascade id), `language`, `available` (bool); cursor pagination. - `GET /catalog/books/{id}` — full detail + personal history. - `GET /catalog/books/{id}/availability` — per-day availability calendar for the loan/reservation date picker. +- `GET /catalog/books/{id}/reviews` — aggregate rating (`average`, `count`, `distribution`) + the user's own review (`mine`) + `can_review` (has the user borrowed the title) + cursor-paginated `items` (other users' reviews). Same feature as the web review view. +- `PUT /catalog/books/{id}/reviews` — upsert the current user's review `{ rating: 1..5, text? }`. Allowed only if the user has borrowed the title (else `403 forbidden`). Idempotent (creates or edits). +- `DELETE /catalog/books/{id}/reviews` — delete the current user's review (idempotent). +- `GET /me/reviews` — the user's own reviews across all titles (`book_id`, `book_title`, `book_author`, `cover_url`, `rating`, `text`, timestamps); cursor pagination. - `GET /catalog/genres` — genre cascade tree (for filter UI). - `GET /me/loans` — own loans (active + history). `GET /me/reservations`. - `POST /reservations` — request a loan/reservation (honor existing overlap/availability rules). `DELETE /reservations/{id}` — cancel own pending reservation. diff --git a/_contract/endpoint-manifest.spec.js b/_contract/endpoint-manifest.spec.js index 04e5ac3..c62677b 100644 --- a/_contract/endpoint-manifest.spec.js +++ b/_contract/endpoint-manifest.spec.js @@ -141,6 +141,11 @@ const ENDPOINTS = [ { name: 'GET /catalog/search', method: 'GET', path: '/catalog/search', auth: true, kind: 'etag' }, { name: 'GET /catalog/books/{bookId}', method: 'GET', path: '/catalog/books/{bookId}', auth: true, kind: 'etag' }, { name: 'GET /catalog/books/{bookId}/availability', method: 'GET', path: '/catalog/books/{bookId}/availability', auth: true, kind: 'safeGet' }, + { name: 'GET /catalog/books/{bookId}/reviews', method: 'GET', path: '/catalog/books/{bookId}/reviews', auth: true, kind: 'safeGet' }, + { name: 'PUT /catalog/books/{bookId}/reviews', method: 'PUT', path: '/catalog/books/{bookId}/reviews', auth: true, kind: 'write2xx', + body: { rating: 5, text: 'Idempotency probe.' } }, + { name: 'DELETE /catalog/books/{bookId}/reviews', method: 'DELETE', path: '/catalog/books/{bookId}/reviews', auth: true, kind: 'gone2' }, + { name: 'GET /me/reviews', method: 'GET', path: '/me/reviews', auth: true, kind: 'safeGet' }, { name: 'GET /catalog/genres', method: 'GET', path: '/catalog/genres', auth: true, kind: 'etag' }, { name: 'GET /me/loans', method: 'GET', path: '/me/loans', auth: true, kind: 'safeGet' }, { name: 'GET /me/reservations', method: 'GET', path: '/me/reservations', auth: true, kind: 'safeGet' }, diff --git a/_contract/openapi.json b/_contract/openapi.json index c2ce4dc..7db58db 100644 --- a/_contract/openapi.json +++ b/_contract/openapi.json @@ -67,6 +67,142 @@ } }, "schemas": { + "Review": { + "type": "object", + "description": "A single book review (star rating + optional text).", + "properties": { + "id": { + "type": "integer" + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "text": { + "type": "string", + "nullable": true + }, + "user_name": { + "type": "string", + "description": "Display name of the reviewer." + }, + "is_mine": { + "type": "boolean", + "description": "True when this review belongs to the requesting user." + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, + "ReviewRequest": { + "type": "object", + "description": "Body to create or update the current user's review.", + "required": [ + "rating" + ], + "properties": { + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "text": { + "type": "string", + "maxLength": 2000, + "nullable": true + } + } + }, + "BookReviews": { + "type": "object", + "description": "Aggregate rating + the user's own review + a page of other users' reviews.", + "properties": { + "average": { + "type": "number", + "format": "float", + "description": "Mean rating across all reviews (0 when none)." + }, + "count": { + "type": "integer" + }, + "distribution": { + "type": "object", + "description": "Star -> number of reviews, keys \"1\"..\"5\".", + "additionalProperties": { + "type": "integer" + } + }, + "can_review": { + "type": "boolean", + "description": "True when the user is eligible to review (has borrowed the title)." + }, + "mine": { + "allOf": [ + { + "$ref": "#/components/schemas/Review" + } + ], + "nullable": true, + "description": "The user's own review, when present." + }, + "items": { + "type": "array", + "description": "Other users' reviews (excludes the user's own).", + "items": { + "$ref": "#/components/schemas/Review" + } + } + } + }, + "MyReview": { + "type": "object", + "description": "One of the current user's reviews, with book info for rendering + navigation.", + "properties": { + "id": { + "type": "integer" + }, + "book_id": { + "type": "integer" + }, + "book_title": { + "type": "string" + }, + "book_author": { + "type": "string", + "nullable": true + }, + "cover_url": { + "type": "string", + "nullable": true + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "text": { + "type": "string", + "nullable": true + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time", + "nullable": true + } + } + }, "Envelope": { "type": "object", "description": "Standard API envelope. Every response is one of these two shapes.", @@ -970,6 +1106,10 @@ }, "push": { "type": "boolean" + }, + "reviews": { + "type": "boolean", + "description": "Star + text book reviews enabled for this instance." } } }, @@ -1840,6 +1980,256 @@ } } }, + "/catalog/books/{id}/reviews": { + "get": { + "tags": [ + "catalog" + ], + "summary": "Book reviews", + "description": "Aggregate rating (average, count, star distribution) + the requesting user's own review (`mine`, if any) + `can_review` (true when the user has borrowed the title) + cursor-paginated `items` (other users' reviews, excluding the user's own). Soft-deleted books return 404.", + "operationId": "getCatalogBookReviews", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Book ID" + }, + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Opaque pagination cursor for the other-users list." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "Reviews for the book.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + } + ], + "properties": { + "data": { + "$ref": "#/components/schemas/BookReviews" + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "tags": [ + "catalog" + ], + "summary": "Create or update the user's review", + "description": "Upsert the current user's review for the book. Allowed only when the user has borrowed the title (a loan row exists); otherwise 403. Idempotent — a repeat with the same body returns the same stored review.", + "operationId": "putCatalogBookReview", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Book ID" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReviewRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The stored review.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + } + ], + "properties": { + "data": { + "$ref": "#/components/schemas/Review" + } + } + } + } + } + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "tags": [ + "catalog" + ], + "summary": "Delete the user's review", + "description": "Delete the current user's review for the book. Idempotent — deleting a review that no longer exists still returns success.", + "operationId": "deleteCatalogBookReview", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Book ID" + } + ], + "responses": { + "200": { + "description": "Review deleted (or already absent)." + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/me/reviews": { + "get": { + "tags": [ + "profile" + ], + "summary": "The user's own reviews", + "description": "All reviews written by the current user, across every title, with enough book info to render a row and navigate to the book. Cursor-paginated.", + "operationId": "getMeReviews", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "cursor", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 20 + } + } + ], + "responses": { + "200": { + "description": "The user's reviews.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + } + ], + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MyReview" + } + } + } + } + } + } + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, "/catalog/genres": { "get": { "tags": [ diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index 1fdfc6d..bad397f 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -51,6 +51,8 @@ data class HealthFeatures( val messages: Boolean = false, val notifications: Boolean = false, val push: Boolean = false, + // Star + text book reviews (a borrower can review a title; everyone reads them). + val reviews: Boolean = false, ) // ---------- Auth ---------- @@ -367,3 +369,53 @@ data class PushPrefs( @SerialName("quiet_start") val quietStart: String? = null, // "HH:MM" @SerialName("quiet_end") val quietEnd: String? = null, // "HH:MM" ) + +// ---------- Reviews ---------- +// Mirrors GET /catalog/books/{id}/reviews: aggregate rating + the current user's own +// review (if any) + the page of other users' reviews. Same feature as the website: a user +// who has borrowed a title can leave a 1–5 star rating with optional text; everyone reads them. +@Serializable +data class BookReviews( + val average: Double = 0.0, + val count: Int = 0, + // Star distribution "1".."5" -> how many reviews gave that many stars. + val distribution: Map = emptyMap(), + // The authenticated user's own review for this book, when present (edit/delete target). + val mine: Review? = null, + // Whether the user is eligible to review (server-authoritative: has borrowed the book). + @SerialName("can_review") val canReview: Boolean = false, + // Other users' reviews (excludes [mine]); paginated via meta.next_cursor. + val items: List = emptyList(), +) + +@Serializable +data class Review( + val id: Int = 0, + val rating: Int = 0, // 1..5 + val text: String? = null, + @SerialName("user_name") val userName: String = "", + @SerialName("is_mine") val isMine: Boolean = false, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, +) + +/** Body for PUT /catalog/books/{id}/reviews — upsert the current user's review. */ +@Serializable +data class ReviewRequest( + val rating: Int, // 1..5 + val text: String? = null, // optional free text, max 2000 +) + +/** One row of GET /me/reviews — the user's own review with enough book info to render + navigate. */ +@Serializable +data class MyReview( + val id: Int = 0, + @SerialName("book_id") val bookId: Int = 0, + @SerialName("book_title") val bookTitle: String = "", + @SerialName("book_author") val bookAuthor: String? = null, + @SerialName("cover_url") val coverUrl: String? = null, + val rating: Int = 0, + val text: String? = null, + @SerialName("created_at") val createdAt: String? = null, + @SerialName("updated_at") val updatedAt: String? = null, +) diff --git a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt index 317ff6f..be07ebe 100644 --- a/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt +++ b/app/src/main/java/com/pinakes/app/data/network/PinakesApi.kt @@ -2,6 +2,7 @@ package com.pinakes.app.data.network import com.pinakes.app.data.model.AvailabilityCalendar import com.pinakes.app.data.model.BookDetail +import com.pinakes.app.data.model.BookReviews import com.pinakes.app.data.model.BookSummary import com.pinakes.app.data.model.ChangePasswordRequest import com.pinakes.app.data.model.DeviceItem @@ -14,7 +15,10 @@ import com.pinakes.app.data.model.LoansData import com.pinakes.app.data.model.LoginRequest import com.pinakes.app.data.model.LoginResponse import com.pinakes.app.data.model.MessageRequest +import com.pinakes.app.data.model.MyReview import com.pinakes.app.data.model.NotificationItem +import com.pinakes.app.data.model.Review +import com.pinakes.app.data.model.ReviewRequest import com.pinakes.app.data.model.PushPrefs import com.pinakes.app.data.model.PushSubscribeRequest import com.pinakes.app.data.model.RegisterRequest @@ -107,6 +111,33 @@ interface PinakesApi { @GET("catalog/genres") suspend fun genres(): Envelope> + // ---- Reviews ---- + /** Aggregate rating + the user's own review + a page of other users' reviews for a book. */ + @GET("catalog/books/{id}/reviews") + suspend fun bookReviews( + @Path("id") id: Int, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, // 1..50, default 20 + ): Envelope + + /** Upsert the current user's review for a book (create or edit). Requires a past loan. */ + @PUT("catalog/books/{id}/reviews") + suspend fun submitReview( + @Path("id") id: Int, + @Body body: ReviewRequest, + ): Envelope + + /** Delete the current user's review for a book (idempotent). */ + @DELETE("catalog/books/{id}/reviews") + suspend fun deleteReview(@Path("id") id: Int): Envelope + + /** The current user's own reviews across all books (for the "My reviews" page). */ + @GET("me/reviews") + suspend fun myReviews( + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, // 1..50, default 20 + ): Envelope> + // ---- Loans / reservations ---- @GET("me/loans") suspend fun loans(): Envelope diff --git a/app/src/main/java/com/pinakes/app/data/repository/ReviewsRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/ReviewsRepository.kt new file mode 100644 index 0000000..3ac6fb0 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/data/repository/ReviewsRepository.kt @@ -0,0 +1,57 @@ +package com.pinakes.app.data.repository + +import com.pinakes.app.data.model.BookReviews +import com.pinakes.app.data.model.MyReview +import com.pinakes.app.data.model.Review +import com.pinakes.app.data.model.ReviewRequest +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.network.NetworkModule +import com.pinakes.app.data.network.apiCall + +/** One page of the user's own reviews plus the cursor for the next page. */ +data class MyReviewsPage( + val items: List, + val nextCursor: String?, +) { + val hasMore: Boolean get() = nextCursor != null +} + +/** + * Book reviews (star + text). Mirrors the website's review feature: + * - read a title's aggregate rating + everyone's reviews, + * - a borrower can create/edit/delete their own review, + * - list all of the user's own reviews for the "My reviews" page. + */ +class ReviewsRepository(private val network: NetworkModule) { + + /** Aggregate + the user's own review + a page of other reviews for [bookId]. */ + suspend fun bookReviews(bookId: Int, cursor: String? = null, limit: Int = 20): ApiResult { + val api = network.api() + return apiCall { api.bookReviews(bookId, cursor = cursor, limit = limit.coerceIn(1, 50)) } + } + + /** Create or edit the current user's review for [bookId]. */ + suspend fun submit(bookId: Int, rating: Int, text: String?): ApiResult { + val api = network.api() + val body = ReviewRequest(rating = rating.coerceIn(1, 5), text = text?.trim()?.takeIf { it.isNotEmpty() }) + return apiCall { api.submitReview(bookId, body) } + } + + /** Delete the current user's review for [bookId] (idempotent). */ + suspend fun delete(bookId: Int): ApiResult { + val api = network.api() + return apiCall { api.deleteReview(bookId) } + } + + /** One cursor-paginated page of the user's own reviews. */ + suspend fun myReviews(cursor: String? = null, limit: Int = 20): ApiResult { + val api = network.api() + return when (val res = apiCall { api.myReviews(cursor = cursor, limit = limit.coerceIn(1, 50)) }) { + is ApiResult.Success -> ApiResult.Success( + MyReviewsPage(items = res.data, nextCursor = res.meta?.nextCursor), + res.meta, + ) + is ApiResult.Failure -> res + } + } +} 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 bd16551..b8d8a9d 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 @@ -30,6 +30,7 @@ data class InstanceFeatures( val messages: Boolean = true, val notifications: Boolean = true, val push: Boolean = true, + val reviews: Boolean = true, val registrationEnabled: Boolean = false, ) { /** Library tab (loans + reservations) is shown only when at least one of them is enabled. */ @@ -40,6 +41,9 @@ data class InstanceFeatures( val showWishlist: Boolean get() = wishlist + /** Book reviews (read + write) surfaced on book detail and the "My reviews" page. */ + val showReviews: Boolean get() = reviews + companion object { /** Safe default before/without `/health`: app features enabled, registration hidden. */ val AllEnabled = InstanceFeatures() @@ -76,6 +80,7 @@ class FeatureStore(context: Context) { messages = f.messages, notifications = f.notifications, push = f.push, + reviews = f.reviews, registrationEnabled = health.registrationEnabled, ) prefs.edit() @@ -88,6 +93,7 @@ class FeatureStore(context: Context) { .putBoolean(KEY_MESSAGES, value.messages) .putBoolean(KEY_NOTIFICATIONS, value.notifications) .putBoolean(KEY_PUSH, value.push) + .putBoolean(KEY_REVIEWS, value.reviews) .putBoolean(KEY_REGISTRATION_ENABLED, value.registrationEnabled) .apply() _features.value = value @@ -113,6 +119,7 @@ class FeatureStore(context: Context) { messages = prefs.getBoolean(KEY_MESSAGES, true), notifications = prefs.getBoolean(KEY_NOTIFICATIONS, true), push = prefs.getBoolean(KEY_PUSH, true), + reviews = prefs.getBoolean(KEY_REVIEWS, true), registrationEnabled = prefs.getBoolean(KEY_REGISTRATION_ENABLED, false), ) } @@ -128,6 +135,7 @@ class FeatureStore(context: Context) { private const val KEY_MESSAGES = "f_messages" private const val KEY_NOTIFICATIONS = "f_notifications" private const val KEY_PUSH = "f_push" + private const val KEY_REVIEWS = "f_reviews" private const val KEY_REGISTRATION_ENABLED = "registration_enabled" } } diff --git a/app/src/main/java/com/pinakes/app/di/ServiceLocator.kt b/app/src/main/java/com/pinakes/app/di/ServiceLocator.kt index bcb24d8..9d86c8d 100644 --- a/app/src/main/java/com/pinakes/app/di/ServiceLocator.kt +++ b/app/src/main/java/com/pinakes/app/di/ServiceLocator.kt @@ -8,6 +8,7 @@ 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.ProfileRepository +import com.pinakes.app.data.repository.ReviewsRepository import com.pinakes.app.data.repository.WishlistRepository import com.pinakes.app.data.store.FeatureStore import com.pinakes.app.data.store.SessionStore @@ -34,6 +35,7 @@ class ServiceLocator(context: Context) { val catalogRepository: CatalogRepository by lazy { CatalogRepository(network) } val libraryRepository: LibraryRepository by lazy { LibraryRepository(network) } val wishlistRepository: WishlistRepository by lazy { WishlistRepository(network) } + val reviewsRepository: ReviewsRepository by lazy { ReviewsRepository(network) } val profileRepository: ProfileRepository by lazy { ProfileRepository(network) } val notificationsRepository: NotificationsRepository by lazy { NotificationsRepository(network) } val messagesRepository: MessagesRepository by lazy { MessagesRepository(network) } diff --git a/app/src/main/java/com/pinakes/app/ui/components/Rating.kt b/app/src/main/java/com/pinakes/app/ui/components/Rating.kt new file mode 100644 index 0000000..1438304 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/components/Rating.kt @@ -0,0 +1,86 @@ +package com.pinakes.app.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.selection.selectable +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Star +import androidx.compose.material.icons.filled.StarBorder +import androidx.compose.material.icons.filled.StarHalf +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.pinakes.app.R +import com.pinakes.app.ui.theme.Spacing +import kotlin.math.roundToInt + +/** + * Read-only star row for an average/individual rating. Renders full / half / empty stars for a + * fractional [rating] in 0..5 (half-star resolution, matching the website's average display). + */ +@Composable +fun StarRating( + rating: Double, + modifier: Modifier = Modifier, + starSize: Dp = 18.dp, + tint: Color = MaterialTheme.colorScheme.primary, +) { + // Round to the nearest half star so an average like 3.7 shows 3.5 (3 full + 1 half). + val halves = (rating.coerceIn(0.0, 5.0) * 2).roundToInt() + Row(modifier) { + for (i in 1..5) { + val icon = when { + halves >= i * 2 -> Icons.Filled.Star + halves == i * 2 - 1 -> Icons.Filled.StarHalf + else -> Icons.Filled.StarBorder + } + Icon( + imageVector = icon, + contentDescription = null, + tint = tint, + modifier = Modifier.size(starSize), + ) + } + } +} + +/** + * Interactive 1–5 star picker for composing a review. Tapping a star sets [rating]; the row is + * accessible (each star is a selectable with a "N stars" label). + */ +@Composable +fun StarRatingInput( + rating: Int, + onRatingChange: (Int) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + starSize: Dp = 36.dp, +) { + Row(modifier) { + for (i in 1..5) { + val label = stringResource(R.string.review_rating_stars, i) + Icon( + imageVector = if (i <= rating) Icons.Filled.Star else Icons.Filled.StarBorder, + contentDescription = label, + tint = if (i <= rating) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.outline, + modifier = Modifier + .padding(end = Spacing.xs) + .size(starSize) + .selectable( + selected = i == rating, + enabled = enabled, + role = Role.RadioButton, + onClick = { onRatingChange(i) }, + ), + ) + } + } +} 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 3550629..d2c9f06 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 @@ -37,6 +37,7 @@ fun MainScaffold( onOpenBook: (Int) -> Unit, onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, + onOpenMyReviews: () -> Unit, ) { val services = LocalServices.current val features by services.features.features.collectAsStateWithLifecycle() @@ -92,6 +93,7 @@ fun MainScaffold( onLoggedOut = onLoggedOut, onOpenNotifications = onOpenNotifications, onOpenContact = onOpenContact, + onOpenMyReviews = onOpenMyReviews, ) } } 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 03f9c39..b8a1ee6 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 @@ -24,6 +24,7 @@ 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.reviews.MyReviewsScreen /** * Root navigation. The high-level [AuthState] decides the start destination; within the @@ -92,6 +93,7 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { onOpenBook = { id -> navController.navigate(Routes.bookDetail(id)) }, onOpenNotifications = { navController.navigate(Routes.NOTIFICATIONS) }, onOpenContact = { navController.navigate(Routes.CONTACT) }, + onOpenMyReviews = { navController.navigate(Routes.MY_REVIEWS) }, ) } @@ -127,5 +129,16 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { ) { ContactScreen(onNavigateUp = { navController.popBackStack() }) } + + composable( + Routes.MY_REVIEWS, + enterTransition = slideIn, + popExitTransition = slideOut, + ) { + MyReviewsScreen( + onNavigateUp = { navController.popBackStack() }, + onOpenBook = { id -> navController.navigate(Routes.bookDetail(id)) }, + ) + } } } 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 f930ce3..01afe6d 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 @@ -17,6 +17,7 @@ object Routes { // Nested const val NOTIFICATIONS = "notifications" const val CONTACT = "contact" + const val MY_REVIEWS = "my-reviews" const val BOOK_DETAIL = "book/{bookId}" fun bookDetail(bookId: Int): String = "book/$bookId" diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index ed0e270..7ae1804 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -82,6 +83,7 @@ import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import java.util.Locale +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -101,6 +103,7 @@ fun BookDetailScreen( ) val state by vm.state.collectAsStateWithLifecycle() val snackbarHost = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() val snackbarMessage = state.snackbar ?: state.snackbarRes?.let { stringResource(it) } LaunchedEffect(snackbarMessage) { @@ -153,8 +156,10 @@ fun BookDetailScreen( reserveBusy = state.reserveBusy, canBorrow = features.canBorrow, showWishlist = features.showWishlist, + showReviews = features.showReviews, onReserve = vm::openLoanSheet, onToggleWishlist = vm::toggleWishlist, + onShowMessage = { msg -> scope.launch { snackbarHost.showSnackbar(msg) } }, ) } } @@ -299,8 +304,10 @@ private fun DetailContent( reserveBusy: Boolean, canBorrow: Boolean, showWishlist: Boolean, + showReviews: Boolean, onReserve: () -> Unit, onToggleWishlist: () -> Unit, + onShowMessage: (String) -> Unit, ) { val context = LocalContext.current var showCover by remember { mutableStateOf(false) } @@ -495,6 +502,17 @@ private fun DetailContent( book.locationLabel?.let { MetadataRow(stringResource(R.string.book_meta_shelf), it) } MetadataRow(stringResource(R.string.book_meta_copies), stringResource(R.string.book_copies_value, book.copiesAvailable, book.copiesTotal)) + // Reviews — aggregate rating, the user's own review (borrowers can write/edit/delete), + // and other users' reviews. Gated by the instance `reviews` feature flag. + if (showReviews) { + Spacer(Modifier.height(Spacing.xl)) + BookReviewsSection( + bookId = book.id, + canReviewFallback = book.personalHistory?.hasRead == true, + onShowMessage = onShowMessage, + ) + } + Spacer(Modifier.height(Spacing.xxl)) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsSection.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsSection.kt new file mode 100644 index 0000000..7c42c0b --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsSection.kt @@ -0,0 +1,327 @@ +package com.pinakes.app.ui.screens.detail + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.RateReview +import com.pinakes.app.R +import com.pinakes.app.data.model.BookReviews +import com.pinakes.app.data.model.Review +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.common.LocalServices +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.common.resolvedMessage +import com.pinakes.app.ui.components.InlineErrorBanner +import com.pinakes.app.ui.components.PinakesTextButton +import com.pinakes.app.ui.components.PinakesTextField +import com.pinakes.app.ui.components.PrimaryButton +import com.pinakes.app.ui.components.SecondaryButton +import com.pinakes.app.ui.components.StarRating +import com.pinakes.app.ui.components.StarRatingInput +import com.pinakes.app.ui.theme.Spacing +import java.util.Locale + +private const val REVIEW_TEXT_MAX = 2000 + +/** + * Book-detail reviews block: aggregate rating, the user's own review with an inline composer + * (create / edit / delete), and the list of other users' reviews. Hosts its own + * [BookReviewsViewModel] so book detail stays focused on catalog data. + * + * [canReviewFallback] comes from personal history (has the user borrowed this title) and gates the + * composer optimistically until the server's authoritative `can_review` is loaded. + */ +@Composable +fun BookReviewsSection( + bookId: Int, + canReviewFallback: Boolean, + onShowMessage: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val services = LocalServices.current + val vm: BookReviewsViewModel = viewModel( + key = "reviews_$bookId", + factory = BookReviewsViewModel.Factory(bookId, services.reviewsRepository), + ) + val state by vm.state.collectAsStateWithLifecycle() + + val message = state.snackbar ?: state.snackbarRes?.let { stringResource(it) } + LaunchedEffect(message) { + message?.let { onShowMessage(it); vm.consumeSnackbar() } + } + + Column(modifier.fillMaxWidth()) { + Text( + stringResource(R.string.reviews_section_title), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(Spacing.sm)) + + when (val content = state.content) { + is UiState.Loading -> Row( + Modifier.fillMaxWidth().padding(vertical = Spacing.md), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(Spacing.md)) + Text( + stringResource(R.string.reviews_loading), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + is UiState.Error -> InlineErrorBanner(message = content.resolvedMessage(), onRetry = vm::load) + + is UiState.Success -> ReviewsBody( + data = content.data, + canReviewFallback = canReviewFallback, + state = state, + vm = vm, + ) + } + } +} + +@Composable +private fun ReviewsBody( + data: BookReviews, + canReviewFallback: Boolean, + state: BookReviewsUiState, + vm: BookReviewsViewModel, +) { + // Aggregate header + if (data.count > 0) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + String.format(Locale.getDefault(), "%.1f", data.average), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.width(Spacing.md)) + Column { + StarRating(rating = data.average) + Text( + stringResource(R.string.reviews_count, data.count), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + Text( + stringResource(R.string.reviews_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + val canReview = data.canReview || canReviewFallback + + // Composer / own review + if (state.editorOpen) { + Spacer(Modifier.height(Spacing.lg)) + ReviewEditor( + rating = state.draftRating, + text = state.draftText, + submitting = state.submitting, + deleting = state.deleting, + canDelete = data.mine != null, + onRating = vm::onDraftRating, + onText = vm::onDraftText, + onSubmit = vm::submit, + onDelete = vm::delete, + onCancel = vm::dismissEditor, + ) + } else if (data.mine != null) { + Spacer(Modifier.height(Spacing.lg)) + OwnReviewCard(review = data.mine, onEdit = vm::openEditor) + } else if (canReview) { + Spacer(Modifier.height(Spacing.lg)) + SecondaryButton( + label = stringResource(R.string.reviews_write), + onClick = vm::openEditor, + leadingIcon = Icons.Outlined.RateReview, + modifier = Modifier.fillMaxWidth(), + ) + } + + // Other users' reviews + if (data.items.isNotEmpty()) { + Spacer(Modifier.height(Spacing.lg)) + Text( + stringResource(R.string.reviews_others_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(Spacing.sm)) + Column(verticalArrangement = Arrangement.spacedBy(Spacing.sm)) { + data.items.forEach { review -> ReviewCard(review) } + } + } +} + +/** The user's own saved review, with an Edit affordance. */ +@Composable +private fun OwnReviewCard(review: Review, onEdit: () -> Unit) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Spacing.lg)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringResource(R.string.reviews_your_review), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + PinakesTextButton(label = stringResource(R.string.reviews_edit), onClick = onEdit) + } + Spacer(Modifier.height(Spacing.xs)) + StarRating(rating = review.rating.toDouble()) + if (!review.text.isNullOrBlank()) { + Spacer(Modifier.height(Spacing.sm)) + Text(review.text, style = MaterialTheme.typography.bodyMedium) + } + review.updatedAt?.let { + Spacer(Modifier.height(Spacing.xs)) + Text( + DateFormat.date(it), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f), + ) + } + } + } +} + +/** Inline star + text composer. */ +@Composable +private fun ReviewEditor( + rating: Int, + text: String, + submitting: Boolean, + deleting: Boolean, + canDelete: Boolean, + onRating: (Int) -> Unit, + onText: (String) -> Unit, + onSubmit: () -> Unit, + onDelete: () -> Unit, + onCancel: () -> Unit, +) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Spacing.lg)) { + Text( + stringResource(R.string.reviews_your_rating), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(Spacing.sm)) + StarRatingInput(rating = rating, onRatingChange = onRating, enabled = !submitting && !deleting) + Spacer(Modifier.height(Spacing.md)) + PinakesTextField( + value = text, + onValueChange = onText, + label = stringResource(R.string.reviews_text_label), + placeholder = stringResource(R.string.reviews_text_placeholder), + modifier = Modifier.fillMaxWidth().height(120.dp), + singleLine = false, + maxLength = REVIEW_TEXT_MAX, + ) + Spacer(Modifier.height(Spacing.md)) + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Spacing.sm), + verticalAlignment = Alignment.CenterVertically, + ) { + if (canDelete) { + PinakesTextButton( + label = stringResource(R.string.reviews_delete), + onClick = onDelete, + enabled = !submitting && !deleting, + ) + } + Spacer(Modifier.weight(1f)) + PinakesTextButton( + label = stringResource(R.string.action_cancel), + onClick = onCancel, + enabled = !submitting && !deleting, + ) + PrimaryButton( + label = stringResource(R.string.action_save), + onClick = onSubmit, + loading = submitting, + enabled = rating in 1..5 && !deleting, + ) + } + } + } +} + +/** A single other-user review row. */ +@Composable +fun ReviewCard(review: Review, modifier: Modifier = Modifier) { + Surface( + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(Spacing.lg)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + review.userName.ifBlank { stringResource(R.string.reviews_anonymous) }, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + review.createdAt?.let { + Text( + DateFormat.date(it), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.height(Spacing.xs)) + StarRating(rating = review.rating.toDouble(), starSize = 16.dp) + if (!review.text.isNullOrBlank()) { + Spacer(Modifier.height(Spacing.sm)) + Text( + review.text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsViewModel.kt new file mode 100644 index 0000000..34a3ca2 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsViewModel.kt @@ -0,0 +1,123 @@ +package com.pinakes.app.ui.screens.detail + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.BookReviews +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.ReviewsRepository +import com.pinakes.app.ui.common.UiState +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 BookReviewsUiState( + val content: UiState = UiState.Loading, + // Composer (open when the user is editing/writing their review). + val editorOpen: Boolean = false, + val draftRating: Int = 0, + val draftText: String = "", + val submitting: Boolean = false, + val deleting: Boolean = false, + val snackbarRes: Int? = null, + val snackbar: String? = null, +) + +/** + * Reviews for a single book: loads the aggregate + the user's own review + other users' reviews, + * and drives the create/edit/delete composer. Lives alongside [BookDetailViewModel] but is scoped + * to the reviews section so book detail stays focused on catalog data. + */ +class BookReviewsViewModel( + private val bookId: Int, + private val reviews: ReviewsRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(BookReviewsUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load() } + + fun load() { + _state.update { it.copy(content = UiState.Loading) } + viewModelScope.launch { + when (val res = reviews.bookReviews(bookId)) { + is ApiResult.Success -> _state.update { it.copy(content = UiState.Success(res.data)) } + is ApiResult.Failure -> _state.update { + it.copy(content = UiState.Error(res.message, res.code, R.string.reviews_error_load)) + } + } + } + } + + /** Open the composer seeded with the user's existing review (edit) or empty (new). */ + fun openEditor() { + val mine = (_state.value.content as? UiState.Success)?.data?.mine + _state.update { + it.copy( + editorOpen = true, + draftRating = mine?.rating ?: 0, + draftText = mine?.text.orEmpty(), + ) + } + } + + fun dismissEditor() = _state.update { it.copy(editorOpen = false) } + + fun onDraftRating(rating: Int) = _state.update { it.copy(draftRating = rating) } + + fun onDraftText(text: String) = _state.update { it.copy(draftText = text) } + + fun submit() { + val s = _state.value + if (s.submitting || s.draftRating !in 1..5) return + _state.update { it.copy(submitting = true) } + viewModelScope.launch { + when (val res = reviews.submit(bookId, s.draftRating, s.draftText)) { + is ApiResult.Success -> { + _state.update { + it.copy(submitting = false, editorOpen = false, snackbar = null, snackbarRes = R.string.reviews_saved) + } + load() + } + is ApiResult.Failure -> _state.update { + if (res.message.isNotBlank()) it.copy(submitting = false, snackbar = res.message, snackbarRes = null) + else it.copy(submitting = false, snackbar = null, snackbarRes = R.string.reviews_save_error) + } + } + } + } + + fun delete() { + if (_state.value.deleting) return + _state.update { it.copy(deleting = true) } + viewModelScope.launch { + when (val res = reviews.delete(bookId)) { + is ApiResult.Success -> { + _state.update { + it.copy(deleting = false, editorOpen = false, snackbar = null, snackbarRes = R.string.reviews_deleted) + } + load() + } + is ApiResult.Failure -> _state.update { + if (res.message.isNotBlank()) it.copy(deleting = false, snackbar = res.message, snackbarRes = null) + else it.copy(deleting = false, snackbar = null, snackbarRes = R.string.reviews_save_error) + } + } + } + } + + fun consumeSnackbar() = _state.update { it.copy(snackbar = null, snackbarRes = null) } + + class Factory( + private val bookId: Int, + private val reviews: ReviewsRepository, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + BookReviewsViewModel(bookId, reviews) as T + } +} 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 69db99b..0f9a03c 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileScreen.kt @@ -27,6 +27,7 @@ import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Lock import androidx.compose.material.icons.outlined.Notifications import androidx.compose.material.icons.outlined.PhoneAndroid +import androidx.compose.material.icons.outlined.RateReview import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -73,6 +74,7 @@ fun ProfileScreen( onLoggedOut: () -> Unit, onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, + onOpenMyReviews: () -> Unit, ) { val services = LocalServices.current val vm: ProfileViewModel = viewModel( @@ -102,8 +104,10 @@ fun ProfileScreen( onLogout = { vm.logout(onLoggedOut) }, onOpenNotifications = onOpenNotifications, onOpenContact = onOpenContact, + onOpenMyReviews = onOpenMyReviews, showNotifications = features.notifications, showContact = features.messages, + showReviews = features.showReviews, ) } } @@ -148,8 +152,10 @@ private fun ProfileContent( onLogout: () -> Unit, onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, + onOpenMyReviews: () -> Unit, showNotifications: Boolean, showContact: Boolean, + showReviews: Boolean, ) { Column( Modifier @@ -197,6 +203,9 @@ private fun ProfileContent( // Actions ActionRow(Icons.Outlined.Edit, stringResource(R.string.profile_action_edit), onClick = onEdit) ActionRow(Icons.Outlined.Lock, stringResource(R.string.profile_action_change_password), onClick = onChangePassword) + if (showReviews) { + ActionRow(Icons.Outlined.RateReview, stringResource(R.string.profile_action_my_reviews), onClick = onOpenMyReviews) + } if (showNotifications) { ActionRow(Icons.Outlined.Notifications, stringResource(R.string.profile_action_notifications), onClick = onOpenNotifications) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt new file mode 100644 index 0000000..a350a12 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsScreen.kt @@ -0,0 +1,216 @@ +package com.pinakes.app.ui.screens.reviews + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.MenuBook +import androidx.compose.material.icons.outlined.RateReview +import androidx.compose.material3.CircularProgressIndicator +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.LaunchedEffect +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.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.SubcomposeAsyncImage +import com.pinakes.app.R +import com.pinakes.app.data.model.MyReview +import com.pinakes.app.ui.common.DateFormat +import com.pinakes.app.ui.common.LocalServices +import com.pinakes.app.ui.common.UiState +import com.pinakes.app.ui.common.resolvedMessage +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.SecondaryButton +import com.pinakes.app.ui.components.StarRating +import com.pinakes.app.ui.theme.Spacing + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MyReviewsScreen( + onNavigateUp: () -> Unit, + onOpenBook: (Int) -> Unit, +) { + val services = LocalServices.current + val vm: MyReviewsViewModel = viewModel(factory = MyReviewsViewModel.Factory(services.reviewsRepository)) + val state by vm.state.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + PinakesTopBar( + title = stringResource(R.string.title_my_reviews), + 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.reviews_loading)) + is UiState.Error -> ErrorState(message = content.resolvedMessage(), onRetry = vm::load) + is UiState.Success -> { + if (content.data.isEmpty()) { + EmptyState( + title = stringResource(R.string.my_reviews_empty_title), + subtitle = stringResource(R.string.my_reviews_empty_subtitle), + icon = Icons.Outlined.RateReview, + ) + } else { + LazyColumn( + Modifier.fillMaxSize(), + contentPadding = PaddingValues(Spacing.lg), + verticalArrangement = Arrangement.spacedBy(Spacing.md), + ) { + items(content.data, key = { it.id }) { review -> + MyReviewRow( + review = review, + onClick = { onOpenBook(review.bookId) }, + modifier = Modifier.animateItem(), + ) + } + if (state.hasMore) { + item(key = "load_more") { + Box(Modifier.fillMaxWidth().padding(Spacing.md), contentAlignment = Alignment.Center) { + if (state.loadingMore) { + CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) + } else { + SecondaryButton( + label = stringResource(R.string.action_load_more), + onClick = vm::loadMore, + ) + } + } + } + } + } + } + } + } + } + } +} + +@Composable +private fun MyReviewRow( + review: MyReview, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceContainerLow, + ) { + Row( + Modifier.clickable { onClick() }.padding(Spacing.md), + ) { + Box( + Modifier + .width(48.dp) + .height(72.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant), + contentAlignment = Alignment.Center, + ) { + if (review.coverUrl != null) { + SubcomposeAsyncImage( + model = review.coverUrl, + contentDescription = review.bookTitle, + contentScale = ContentScale.Crop, + modifier = Modifier.size(width = 48.dp, height = 72.dp), + error = { + Icon( + Icons.AutoMirrored.Outlined.MenuBook, + contentDescription = null, + tint = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.size(20.dp), + ) + }, + ) + } else { + Icon( + Icons.AutoMirrored.Outlined.MenuBook, + contentDescription = null, + tint = MaterialTheme.colorScheme.outlineVariant, + modifier = Modifier.size(20.dp), + ) + } + } + Spacer(Modifier.width(Spacing.md)) + Column(Modifier.weight(1f)) { + Text( + review.bookTitle, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + if (!review.bookAuthor.isNullOrBlank()) { + Text( + review.bookAuthor, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.height(Spacing.xs)) + Row(verticalAlignment = Alignment.CenterVertically) { + StarRating(rating = review.rating.toDouble(), starSize = 16.dp) + val date = review.updatedAt ?: review.createdAt + if (date != null) { + Spacer(Modifier.width(Spacing.sm)) + Text( + DateFormat.date(date), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (!review.text.isNullOrBlank()) { + Spacer(Modifier.height(Spacing.xs)) + Text( + review.text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsViewModel.kt new file mode 100644 index 0000000..b0a3f22 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/reviews/MyReviewsViewModel.kt @@ -0,0 +1,90 @@ +package com.pinakes.app.ui.screens.reviews + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.pinakes.app.R +import com.pinakes.app.data.model.MyReview +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.ReviewsRepository +import com.pinakes.app.ui.common.UiState +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 MyReviewsUiState( + val content: UiState> = UiState.Loading, + val refreshing: Boolean = false, + val nextCursor: String? = null, + val loadingMore: Boolean = false, +) { + val hasMore: Boolean get() = nextCursor != null +} + +/** The user's own reviews across all books, cursor-paginated (load-more). */ +class MyReviewsViewModel( + private val reviews: ReviewsRepository, +) : ViewModel() { + + private val _state = MutableStateFlow(MyReviewsUiState()) + val state: StateFlow = _state.asStateFlow() + + init { load() } + + fun load() { + _state.update { it.copy(content = UiState.Loading) } + fetchFirstPage() + } + + fun refresh() { + _state.update { it.copy(refreshing = true) } + fetchFirstPage() + } + + private fun fetchFirstPage() { + viewModelScope.launch { + when (val res = reviews.myReviews()) { + is ApiResult.Success -> _state.update { + it.copy( + content = UiState.Success(res.data.items), + nextCursor = res.data.nextCursor, + refreshing = false, + ) + } + is ApiResult.Failure -> _state.update { + // Keep any already-loaded list on a refresh failure; otherwise show the error. + if (it.content is UiState.Success) it.copy(refreshing = false) + else it.copy(content = UiState.Error(res.message, res.code, R.string.reviews_error_load), refreshing = false) + } + } + } + } + + fun loadMore() { + val s = _state.value + val cursor = s.nextCursor ?: return + if (s.loadingMore) return + val current = (s.content as? UiState.Success)?.data ?: return + _state.update { it.copy(loadingMore = true) } + viewModelScope.launch { + when (val res = reviews.myReviews(cursor = cursor)) { + is ApiResult.Success -> _state.update { + it.copy( + content = UiState.Success(current + res.data.items), + nextCursor = res.data.nextCursor, + loadingMore = false, + ) + } + is ApiResult.Failure -> _state.update { it.copy(loadingMore = false) } + } + } + } + + class Factory(private val reviews: ReviewsRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + MyReviewsViewModel(reviews) as T + } +} diff --git a/i18n/de.json b/i18n/de.json index bd7b189..c861d60 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -314,5 +314,28 @@ "onboarding_error_not_found": "Das sieht nicht nach einer Pinakes-Bibliothek aus (keine API gefunden).", "onboarding_error_generic": "Verbindung zu dieser Bibliothek nicht möglich.", "availability_reserved": "Vorgemerkt", - "availability_unavailable": "Nicht verfügbar" + "availability_unavailable": "Nicht verfügbar", + "reviews_section_title": "Rezensionen", + "reviews_loading": "Rezensionen werden geladen…", + "reviews_error_load": "Rezensionen konnten nicht geladen werden.", + "reviews_count": "%1$d Rezensionen", + "reviews_empty": "Noch keine Rezensionen. Sei der Erste!", + "reviews_write": "Rezension schreiben", + "reviews_others_title": "Was andere Leser sagen", + "reviews_your_review": "Deine Rezension", + "reviews_edit": "Bearbeiten", + "reviews_delete": "Löschen", + "reviews_your_rating": "Deine Bewertung", + "reviews_text_label": "Deine Rezension", + "reviews_text_placeholder": "Teile deine Meinung (optional)", + "reviews_anonymous": "Leser", + "reviews_saved": "Rezension gespeichert", + "reviews_save_error": "Rezension konnte nicht gespeichert werden.", + "reviews_deleted": "Rezension gelöscht", + "review_rating_stars": "%1$d Sterne", + "title_my_reviews": "Meine Rezensionen", + "profile_action_my_reviews": "Meine Rezensionen", + "my_reviews_empty_title": "Noch keine Rezensionen", + "my_reviews_empty_subtitle": "Rezensionen, die du zu Büchern hinterlässt, erscheinen hier.", + "action_load_more": "Mehr laden" } diff --git a/i18n/en.json b/i18n/en.json index d5985e9..f3e3924 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -314,5 +314,28 @@ "onboarding_error_not_found": "This doesn't look like a Pinakes library (no API found there).", "onboarding_error_generic": "Couldn't connect to that library.", "availability_reserved": "Reserved", - "availability_unavailable": "Unavailable" + "availability_unavailable": "Unavailable", + "reviews_section_title": "Reviews", + "reviews_loading": "Loading reviews…", + "reviews_error_load": "Couldn't load reviews.", + "reviews_count": "%1$d reviews", + "reviews_empty": "No reviews yet. Be the first!", + "reviews_write": "Write a review", + "reviews_others_title": "What other readers say", + "reviews_your_review": "Your review", + "reviews_edit": "Edit", + "reviews_delete": "Delete", + "reviews_your_rating": "Your rating", + "reviews_text_label": "Your review", + "reviews_text_placeholder": "Share what you thought (optional)", + "reviews_anonymous": "Reader", + "reviews_saved": "Review saved", + "reviews_save_error": "Couldn't save your review.", + "reviews_deleted": "Review deleted", + "review_rating_stars": "%1$d stars", + "title_my_reviews": "My reviews", + "profile_action_my_reviews": "My reviews", + "my_reviews_empty_title": "No reviews yet", + "my_reviews_empty_subtitle": "Reviews you leave on books will appear here.", + "action_load_more": "Load more" } diff --git a/i18n/fr.json b/i18n/fr.json index 8743c2a..fbdf466 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -314,5 +314,28 @@ "onboarding_error_not_found": "Cela ne ressemble pas à une bibliothèque Pinakes (aucune API trouvée).", "onboarding_error_generic": "Impossible de se connecter à cette bibliothèque.", "availability_reserved": "Réservé", - "availability_unavailable": "Indisponible" + "availability_unavailable": "Indisponible", + "reviews_section_title": "Avis", + "reviews_loading": "Chargement des avis…", + "reviews_error_load": "Impossible de charger les avis.", + "reviews_count": "%1$d avis", + "reviews_empty": "Aucun avis pour l'instant. Soyez le premier !", + "reviews_write": "Rédiger un avis", + "reviews_others_title": "L'avis des autres lecteurs", + "reviews_your_review": "Votre avis", + "reviews_edit": "Modifier", + "reviews_delete": "Supprimer", + "reviews_your_rating": "Votre note", + "reviews_text_label": "Votre avis", + "reviews_text_placeholder": "Partagez votre ressenti (facultatif)", + "reviews_anonymous": "Lecteur", + "reviews_saved": "Avis enregistré", + "reviews_save_error": "Impossible d'enregistrer votre avis.", + "reviews_deleted": "Avis supprimé", + "review_rating_stars": "%1$d étoiles", + "title_my_reviews": "Mes avis", + "profile_action_my_reviews": "Mes avis", + "my_reviews_empty_title": "Aucun avis", + "my_reviews_empty_subtitle": "Les avis que vous laissez sur les livres apparaîtront ici.", + "action_load_more": "Charger plus" } diff --git a/i18n/it.json b/i18n/it.json index f95f660..e57979c 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -314,5 +314,28 @@ "onboarding_error_not_found": "Questa non sembra una biblioteca Pinakes (nessuna API trovata).", "onboarding_error_generic": "Impossibile connettersi a quella biblioteca.", "availability_reserved": "Prenotato", - "availability_unavailable": "Non disponibile" + "availability_unavailable": "Non disponibile", + "reviews_section_title": "Recensioni", + "reviews_loading": "Caricamento recensioni…", + "reviews_error_load": "Impossibile caricare le recensioni.", + "reviews_count": "%1$d recensioni", + "reviews_empty": "Ancora nessuna recensione. Sii il primo!", + "reviews_write": "Scrivi una recensione", + "reviews_others_title": "Recensioni degli altri lettori", + "reviews_your_review": "La tua recensione", + "reviews_edit": "Modifica", + "reviews_delete": "Elimina", + "reviews_your_rating": "Il tuo voto", + "reviews_text_label": "La tua recensione", + "reviews_text_placeholder": "Racconta cosa ne pensi (facoltativo)", + "reviews_anonymous": "Lettore", + "reviews_saved": "Recensione salvata", + "reviews_save_error": "Impossibile salvare la recensione.", + "reviews_deleted": "Recensione eliminata", + "review_rating_stars": "%1$d stelle", + "title_my_reviews": "Le mie recensioni", + "profile_action_my_reviews": "Le mie recensioni", + "my_reviews_empty_title": "Nessuna recensione", + "my_reviews_empty_subtitle": "Le recensioni che lasci sui libri appariranno qui.", + "action_load_more": "Carica altri" } From f755d4010f961d8bdf2536adc3d024074c63595e Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Thu, 2 Jul 2026 17:31:04 +0200 Subject: [PATCH 2/2] feat(ui): richer, higher-contrast book detail screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the book detail card for readability and completeness: - Title → headlineSmall bold; author → titleMedium SemiBold onSurface (was small onSurfaceVariant); subtitle darkened to onSurface. - Section headings ('About', 'Details', 'Reviews', audiobook/ebook) unified to a SemiBold titleMedium onSurface via a shared SectionTitle. - Description text bumped to bodyLarge onSurface with 26sp line height (was bodyMedium onSurfaceVariant) — larger and darker. - Metadata grouped into one rounded surfaceContainerLow card so it reads as a single spec sheet; MetadataRow values → bodyLarge Medium onSurface (larger, high-contrast) with roomier vertical rhythm. - Blank guards on string metadata (publisher/language/isbn/…): the API can send an empty string, so don't render a labelled row with no value. Verified on the emulator against a live instance: rich book (Il nome della rosa) shows the full spec card + About + the reviews section with a working 'Write a review' affordance for a borrower. 67 unit tests, assembleDebug + assembleRelease green. --- .../com/pinakes/app/ui/components/Support.kt | 10 ++- .../app/ui/screens/detail/BookDetailScreen.kt | 79 +++++++++++++------ .../ui/screens/detail/BookReviewsSection.kt | 3 +- 3 files changed, 66 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/pinakes/app/ui/components/Support.kt b/app/src/main/java/com/pinakes/app/ui/components/Support.kt index aac94bf..939f951 100644 --- a/app/src/main/java/com/pinakes/app/ui/components/Support.kt +++ b/app/src/main/java/com/pinakes/app/ui/components/Support.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.pinakes.app.R import com.pinakes.app.ui.theme.Spacing @@ -68,19 +69,22 @@ fun MetadataRow( Row( modifier = modifier .fillMaxWidth() - .padding(vertical = Spacing.sm), + .padding(vertical = Spacing.md), horizontalArrangement = Arrangement.spacedBy(Spacing.lg), verticalAlignment = Alignment.Top, ) { Text( text = label, - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.width(116.dp), ) Text( text = value, - style = MaterialTheme.typography.bodyMedium, + // High-contrast value: the primary onSurface colour + a slightly + // larger, medium-weight body so metadata reads clearly at a glance. + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), ) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt index 6648243..5580f7b 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookDetailScreen.kt @@ -54,7 +54,9 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.SubcomposeAsyncImage @@ -354,14 +356,24 @@ private fun DetailContent( } Spacer(Modifier.width(Spacing.lg)) Column(Modifier.weight(1f)) { - Text(book.title, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface) + Text( + book.title, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) if (!book.subtitle.isNullOrBlank()) { Spacer(Modifier.height(Spacing.xs)) - Text(book.subtitle!!, style = MaterialTheme.typography.bodyMedium, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text(book.subtitle!!, style = MaterialTheme.typography.titleSmall, fontStyle = FontStyle.Italic, color = MaterialTheme.colorScheme.onSurface) } if (book.authorsLabel.isNotBlank()) { Spacer(Modifier.height(Spacing.sm)) - Text(book.authorsLabel, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + Text( + book.authorsLabel, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) } Spacer(Modifier.height(Spacing.md)) // Colour-code WHY a title isn't free: green available, red on-loan, @@ -439,7 +451,7 @@ private fun DetailContent( // Audiobook player (when the API reports an audiobook for this title). if (book.hasAudio && !book.audioUrl.isNullOrBlank()) { Spacer(Modifier.height(Spacing.lg)) - Text(stringResource(R.string.book_section_audiobook), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) + SectionTitle(stringResource(R.string.book_section_audiobook)) Spacer(Modifier.height(Spacing.sm)) AudioPlayer(audioUrl = book.audioUrl!!) } @@ -447,7 +459,7 @@ private fun DetailContent( // E-book "Read" action: in-app PDF reader, or ACTION_VIEW for other formats (epub, …). if (book.hasEbook && !book.ebookUrl.isNullOrBlank()) { Spacer(Modifier.height(Spacing.lg)) - Text(stringResource(R.string.book_section_ebook), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) + SectionTitle(stringResource(R.string.book_section_ebook)) Spacer(Modifier.height(Spacing.sm)) val isPdf = book.ebookFormat?.equals("pdf", ignoreCase = true) == true || book.ebookUrl!!.substringBefore('?').endsWith(".pdf", ignoreCase = true) @@ -471,28 +483,40 @@ private fun DetailContent( Spacer(Modifier.height(Spacing.lg)) if (!book.description.isNullOrBlank()) { - Text(stringResource(R.string.book_section_about), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) - Spacer(Modifier.height(Spacing.xs)) + SectionTitle(stringResource(R.string.book_section_about)) + Spacer(Modifier.height(Spacing.sm)) HtmlText( html = book.description!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 26.sp), + color = MaterialTheme.colorScheme.onSurface, ) - Spacer(Modifier.height(Spacing.lg)) + Spacer(Modifier.height(Spacing.xl)) } - Text(stringResource(R.string.book_section_details), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) - Spacer(Modifier.height(Spacing.xs)) - book.publisher?.let { MetadataRow(stringResource(R.string.book_meta_publisher), it) } - book.year?.let { MetadataRow(stringResource(R.string.book_meta_year), it.toString()) } - book.language?.let { MetadataRow(stringResource(R.string.book_meta_language), it) } - book.pages?.let { MetadataRow(stringResource(R.string.book_meta_pages), it.toString()) } - book.isbn13?.let { MetadataRow(stringResource(R.string.book_meta_isbn13), it) } - book.isbn10?.let { MetadataRow(stringResource(R.string.book_meta_isbn10), it) } - book.ean?.let { MetadataRow(stringResource(R.string.book_meta_ean), it) } - book.condition?.let { MetadataRow(stringResource(R.string.book_meta_condition), it) } - book.locationLabel?.let { MetadataRow(stringResource(R.string.book_meta_shelf), it) } - MetadataRow(stringResource(R.string.book_meta_copies), stringResource(R.string.book_copies_value, book.copiesAvailable, book.copiesTotal)) + SectionTitle(stringResource(R.string.book_section_details)) + Spacer(Modifier.height(Spacing.sm)) + // Metadata grouped into one rounded card: a clean key/value list reads + // as a single "spec sheet" rather than rows floating on the background. + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surfaceContainerLow, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(horizontal = Spacing.lg, vertical = Spacing.xs)) { + // Blank guards: the API can send an empty string (not null) for a + // missing field — don't render a labelled row with no value. + book.publisher?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_publisher), it) } + book.year?.let { MetadataRow(stringResource(R.string.book_meta_year), it.toString()) } + book.language?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_language), it) } + book.pages?.let { MetadataRow(stringResource(R.string.book_meta_pages), it.toString()) } + book.isbn13?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_isbn13), it) } + book.isbn10?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_isbn10), it) } + book.ean?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_ean), it) } + book.condition?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_condition), it) } + book.locationLabel?.takeIf { it.isNotBlank() }?.let { MetadataRow(stringResource(R.string.book_meta_shelf), it) } + MetadataRow(stringResource(R.string.book_meta_copies), stringResource(R.string.book_copies_value, book.copiesAvailable, book.copiesTotal)) + } + } // Reviews — aggregate rating, the user's own review (borrowers can write/edit/delete), // and other users' reviews. Gated by the instance `reviews` feature flag. @@ -618,6 +642,17 @@ private fun GenreChip(label: String) { } } +/** Consistent, high-contrast section heading for the book-detail screen. */ +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) +} + private val displayDateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsSection.kt b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsSection.kt index 72519d2..8a5dd18 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsSection.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/detail/BookReviewsSection.kt @@ -85,7 +85,8 @@ fun BookReviewsSection( Column(modifier.fillMaxWidth()) { Text( stringResource(R.string.reviews_section_title), - style = MaterialTheme.typography.titleSmall, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface, ) Spacer(Modifier.height(Spacing.sm))