diff --git a/_contract/MOBILE_API_SPEC.md b/_contract/MOBILE_API_SPEC.md index ed682a6..cb7a0fe 100644 --- a/_contract/MOBILE_API_SPEC.md +++ b/_contract/MOBILE_API_SPEC.md @@ -70,6 +70,7 @@ All FKs respect existing `utenti`/`libri` schema. Follow the soft-delete rule on - `GET /me/devices` — list devices. `DELETE /me/devices/{id}` — revoke a device. - `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/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 ba8b036..04e5ac3 100644 --- a/_contract/endpoint-manifest.spec.js +++ b/_contract/endpoint-manifest.spec.js @@ -128,7 +128,7 @@ const ENDPOINTS = [ { name: 'POST /auth/forgot-password', method: 'POST', path: '/auth/forgot-password', auth: false, kind: 'write2xx', body: () => ({ email: USER_EMAIL }) }, { name: 'POST /auth/register', method: 'POST', path: '/auth/register', auth: false, kind: 'conflict2', - body: () => ({ email: USER_EMAIL, password: USER_PASS, password_confirm: USER_PASS, nome: 'Idem', cognome: 'Test', telefono: '0', indirizzo: 'x', privacy_acceptance: '1' }), + body: () => ({ email: USER_EMAIL, password: USER_PASS, password_confirm: USER_PASS, nome: 'Idem', cognome: 'Test', telefono: '0', indirizzo: 'x', privacy_acceptance: true }), firstAny: true /* registration may be disabled → 1st is 4xx; still asserts 2nd >= 400 */ }, { name: 'POST /auth/logout', method: 'POST', path: '/auth/logout', auth: 'throwaway', kind: 'revoked2' }, { name: 'GET /me', method: 'GET', path: '/me', auth: true, kind: 'safeGet' }, @@ -140,6 +140,7 @@ const ENDPOINTS = [ { name: 'DELETE /me/devices/{deviceId}', method: 'DELETE', path: '/me/devices/{deviceId}', auth: true, kind: 'gone2' }, { 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/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' }, @@ -151,7 +152,7 @@ const ENDPOINTS = [ body: (ctx) => ({ book_id: ctx.bookId }) /* adding twice must not duplicate; both 2xx */ }, { name: 'DELETE /me/wishlist/{bookId}', method: 'DELETE', path: '/me/wishlist/{bookId}', auth: true, kind: 'gone2' }, { name: 'POST /messages', method: 'POST', path: '/messages', auth: true, kind: 'write2xx', - body: () => ({ messaggio: 'idem test message', oggetto: 'idem' }) }, + body: () => ({ subject: 'idem', body: 'idem test message' }) }, { name: 'GET /me/notifications', method: 'GET', path: '/me/notifications', auth: true, kind: 'safeGet' }, { name: 'GET /me/push/prefs', method: 'GET', path: '/me/push/prefs', auth: true, kind: 'safeGet' }, { name: 'PUT /me/push/prefs', method: 'PUT', path: '/me/push/prefs', auth: true, kind: 'write2xx', @@ -297,7 +298,7 @@ test.describe('Mobile API — two calls per endpoint (idempotency + ETag/304)', } catch { ctx.reservationId = 0; } // Pre-add the wishlist book so DELETE /me/wishlist/{bookId} has something to remove on call #1. - try { dbExec(`INSERT IGNORE INTO wishlist (utente_id, libro_id, created_at) VALUES (${ctx.userId}, ${ctx.bookId}, NOW())`); } catch {} + try { dbExec(`INSERT IGNORE INTO wishlist (utente_id, libro_id) VALUES (${ctx.userId}, ${ctx.bookId})`); } catch {} }); test.afterAll(async () => { diff --git a/_contract/health-sample.json b/_contract/health-sample.json index 7a4bec8..a1cde2f 100644 --- a/_contract/health-sample.json +++ b/_contract/health-sample.json @@ -1 +1 @@ -{"data":{"name":"Pinakes","logo":null,"version":"0.7.20.2","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true},"app_access_enabled":true,"registration_enabled":true,"private_mode":false,"vapid_public_key":"BAI9Ljyok0x691trnj9VsLYnPPGG651bXV9UDXM-TwKViUOWm9l3HPfRfXodc8-YaR7rMCc6fjrZKJv9ZBZBunE"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} \ No newline at end of file +{"data":{"name":"Pinakes","logo":null,"version":"0.7.20.2","api_version":"v1","features":{"catalog":true,"loans":true,"reservations":true,"wishlist":true,"messages":true,"notifications":true,"push":true},"catalogue_mode":false,"app_access_enabled":true,"registration_enabled":true,"private_mode":false,"vapid_public_key":"BAI9Ljyok0x691trnj9VsLYnPPGG651bXV9UDXM-TwKViUOWm9l3HPfRfXodc8-YaR7rMCc6fjrZKJv9ZBZBunE"},"meta":{"https":false,"warning":"insecure_transport"},"error":null} diff --git a/_contract/openapi.json b/_contract/openapi.json index 614322d..c2ce4dc 100644 --- a/_contract/openapi.json +++ b/_contract/openapi.json @@ -196,7 +196,11 @@ "nome", "cognome", "email", - "password" + "telefono", + "indirizzo", + "password", + "password_confirm", + "privacy_acceptance" ], "properties": { "nome": { @@ -209,11 +213,29 @@ }, "email": { "type": "string", - "format": "email" + "format": "email", + "maxLength": 255 + }, + "telefono": { + "type": "string" + }, + "indirizzo": { + "type": "string" }, "password": { "type": "string", - "minLength": 8 + "minLength": 8, + "maxLength": 72 + }, + "password_confirm": { + "type": "string", + "minLength": 8, + "maxLength": 72 + }, + "privacy_acceptance": { + "type": "boolean", + "const": true, + "description": "Must be true — registration is rejected unless the privacy policy is explicitly accepted." } } }, @@ -273,44 +295,56 @@ "id": { "type": "integer" }, - "titolo": { + "title": { "type": "string" }, - "sottotitolo": { + "subtitle": { "type": "string", "nullable": true }, - "autori": { - "type": "array", - "items": { - "type": "string" - } + "author": { + "type": "string", + "nullable": true + }, + "publisher": { + "type": "string", + "nullable": true }, - "editore": { + "genre": { "type": "string", "nullable": true }, - "anno": { + "year": { "type": "integer", "nullable": true }, - "copertina_url": { + "language": { "type": "string", - "format": "uri", - "nullable": true, - "description": "Absolute URL." - }, - "disponibile": { - "type": "boolean", - "description": "True if at least one copy is currently loanable." + "nullable": true }, - "genere": { + "media_type": { "type": "string", "nullable": true }, - "lingua": { + "isbn13": { "type": "string", "nullable": true + }, + "cover_url": { + "type": "string", + "format": "uri", + "nullable": true, + "description": "Absolute URL." + }, + "copies_total": { + "type": "integer" + }, + "copies_available": { + "type": "integer" + }, + "loanable_now": { + "type": "boolean", + "description": "True if at least one copy is currently loanable." } } }, @@ -326,38 +360,152 @@ "type": "string", "nullable": true }, - "isbn13": { + "ean": { "type": "string", "nullable": true }, - "ean": { + "pages": { + "type": "integer", + "nullable": true + }, + "description": { "type": "string", "nullable": true }, - "descrizione": { + "format": { "type": "string", "nullable": true }, - "numero_pagine": { - "type": "integer", + "series": { + "type": "string", "nullable": true }, - "condizione": { + "condition": { "type": "string", "nullable": true }, - "collocazione": { + "audio_url": { "type": "string", + "format": "uri", + "nullable": true + }, + "has_audio": { + "type": "boolean" + }, + "ebook_url": { + "type": "string", + "format": "uri", + "nullable": true + }, + "ebook_format": { + "type": "string", + "nullable": true + }, + "has_ebook": { + "type": "boolean" + }, + "genre": { + "type": "object", "nullable": true, - "description": "Shelf / location label." + "properties": { + "id": { + "type": "integer", + "nullable": true + }, + "name": { + "type": "string", + "nullable": true + }, + "parent": { + "type": "string", + "nullable": true + }, + "grandparent": { + "type": "string", + "nullable": true + }, + "subgenre": { + "type": "string", + "nullable": true + } + } }, - "copie_totali": { - "type": "integer", - "description": "Total physical copies." + "publishers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + } + } }, - "copie_disponibili": { - "type": "integer", - "description": "Copies currently loanable." + "authors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "role": { + "type": "string", + "nullable": true + } + } + } + }, + "availability": { + "type": "object", + "properties": { + "copies_total": { + "type": "integer" + }, + "copies_available": { + "type": "integer" + }, + "loanable_now": { + "type": "boolean" + }, + "state": { + "type": "string", + "enum": [ + "available", + "on_loan", + "reserved", + "unavailable" + ] + } + } + }, + "location": { + "type": "object", + "nullable": true, + "properties": { + "label": { + "type": "string", + "nullable": true + }, + "shelf_id": { + "type": "integer", + "nullable": true + }, + "shelf_unit_id": { + "type": "integer", + "nullable": true + }, + "position": { + "type": "integer", + "nullable": true + } + } }, "personal_history": { "$ref": "#/components/schemas/PersonalHistory" @@ -383,6 +531,10 @@ "has_active_loan": { "type": "boolean", "description": "The user currently has this book on loan." + }, + "has_pending_request": { + "type": "boolean", + "description": "The user has a pending loan request for this book." } } }, @@ -392,13 +544,9 @@ "id": { "type": "integer" }, - "nome": { + "name": { "type": "string" }, - "livello": { - "type": "integer", - "description": "Depth: 1 = root, 2 = mid, 3 = leaf." - }, "children": { "type": "array", "items": { @@ -431,39 +579,39 @@ "id": { "type": "integer" }, - "libro_id": { + "book_id": { "type": "integer" }, - "titolo": { + "title": { "type": "string" }, - "copertina_url": { + "cover_url": { "type": "string", "format": "uri", "nullable": true }, - "stato": { + "status": { "type": "string", - "description": "e.g. in_corso, concluso, in_scadenza, scaduto, prenotato, in_attesa." + "description": "Raw prestiti.stato value." }, - "data_prestito": { + "loaned_at": { "type": "string", "format": "date", "nullable": true }, - "data_scadenza": { + "due_at": { "type": "string", "format": "date", "nullable": true }, - "data_restituzione": { + "returned_at": { "type": "string", "format": "date", "nullable": true }, - "created_at": { - "type": "string", - "format": "date-time" + "renewals": { + "type": "integer", + "nullable": true } } }, @@ -473,37 +621,43 @@ "id": { "type": "integer" }, - "libro_id": { + "book_id": { "type": "integer" }, - "titolo": { + "title": { "type": "string" }, - "copertina_url": { + "cover_url": { "type": "string", "format": "uri", "nullable": true }, - "stato": { + "status": { "type": "string" }, - "data_inizio": { + "queue_position": { + "type": "integer", + "nullable": true + }, + "requested_from": { "type": "string", "format": "date", "nullable": true }, - "data_fine": { + "requested_to": { "type": "string", "format": "date", "nullable": true }, - "queue_position": { - "type": "integer", + "reserved_at": { + "type": "string", + "format": "date-time", "nullable": true }, - "created_at": { + "expires_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "nullable": true } } }, @@ -516,15 +670,23 @@ "book_id": { "type": "integer" }, + "desired_date": { + "type": "string", + "format": "date", + "nullable": true, + "description": "Requested start date. Today or omitted means immediate loan when a copy is free; future dates create reservations." + }, "start_date": { "type": "string", "format": "date", - "description": "Requested start date (ISO-8601). Defaults to today if omitted." + "nullable": true, + "deprecated": true }, "end_date": { "type": "string", "format": "date", - "description": "Requested end date (ISO-8601)." + "nullable": true, + "deprecated": true } } }, @@ -534,20 +696,27 @@ "book_id": { "type": "integer" }, - "titolo": { + "title": { "type": "string" }, - "copertina_url": { + "author": { "type": "string", - "format": "uri", "nullable": true }, - "disponibile": { - "type": "boolean" + "year": { + "type": "integer", + "nullable": true }, - "added_at": { + "cover_url": { "type": "string", - "format": "date-time" + "format": "uri", + "nullable": true + }, + "copies_available": { + "type": "integer" + }, + "loanable_now": { + "type": "boolean" } } }, @@ -616,15 +785,22 @@ "type": "object", "required": [ "current_password", - "new_password" + "password", + "password_confirm" ], "properties": { "current_password": { "type": "string" }, - "new_password": { + "password": { + "type": "string", + "minLength": 8, + "maxLength": 72 + }, + "password_confirm": { "type": "string", - "minLength": 8 + "minLength": 8, + "maxLength": 72 } } }, @@ -665,20 +841,17 @@ "title": { "type": "string" }, - "body": { + "message": { "type": "string" }, - "read": { - "type": "boolean" + "book_id": { + "type": "integer", + "nullable": true }, - "created_at": { + "date": { "type": "string", - "format": "date-time" - }, - "payload": { - "type": "object", - "additionalProperties": true, - "nullable": true + "nullable": true, + "description": "ISO date or date-time associated with the notification." } } }, @@ -800,6 +973,10 @@ } } }, + "catalogue_mode": { + "type": "boolean", + "description": "True when the instance is in catalogue-only mode (loans, reservations and wishlist disabled)." + }, "app_access_enabled": { "type": "boolean" }, @@ -1555,6 +1732,114 @@ } } }, + "/catalog/books/{id}/availability": { + "get": { + "tags": [ + "catalog" + ], + "summary": "Book loan availability (calendar)", + "description": "Per-day loan availability for the date-picker calendar: total_copies, earliest_available, unavailable_dates (fully-booked days), and days[] with per-day free/total counts. Same computation as the website; excludes the requesting user's own active reservations. Soft-deleted books return 404.", + "operationId": "getCatalogBookAvailability", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "integer" + }, + "description": "Book ID" + } + ], + "responses": { + "200": { + "description": "Availability calendar data.", + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/Envelope" + } + ], + "properties": { + "data": { + "type": "object", + "properties": { + "total_copies": { + "type": "integer" + }, + "earliest_available": { + "type": "string", + "format": "date", + "nullable": true + }, + "unavailable_dates": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "days": { + "type": "array", + "items": { + "type": "object", + "properties": { + "date": { + "type": "string", + "format": "date" + }, + "available": { + "type": "integer" + }, + "loaned": { + "type": "integer" + }, + "reserved": { + "type": "integer" + }, + "state": { + "type": "string", + "enum": [ + "free", + "partial", + "full" + ] + } + } + } + } + } + } + } + } + } + } + }, + "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" + } + } + } + }, "/catalog/genres": { "get": { "tags": [ @@ -2209,4 +2494,4 @@ } } } -} \ No newline at end of file +} 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 d055a00..1fdfc6d 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 @@ -74,7 +74,11 @@ data class RegisterRequest( val nome: String, val cognome: String, val email: String, - val password: String, // min 8 + val telefono: String, + val indirizzo: String, + val password: String, // min 8, max 72 + @SerialName("password_confirm") val passwordConfirm: String, + @SerialName("privacy_acceptance") val privacyAcceptance: Boolean, ) @Serializable @@ -104,7 +108,8 @@ data class UpdateProfileRequest( @Serializable data class ChangePasswordRequest( @SerialName("current_password") val currentPassword: String, - @SerialName("new_password") val newPassword: String, // min 8 + val password: String, // min 8 + @SerialName("password_confirm") val passwordConfirm: String, ) // ---------- Devices ---------- 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 c432948..b1e894a 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 @@ -88,9 +88,31 @@ class AuthRepository( } } - suspend fun register(nome: String, cognome: String, email: String, password: String): ApiResult { + suspend fun register( + nome: String, + cognome: String, + email: String, + telefono: String, + indirizzo: String, + password: String, + passwordConfirm: String, + privacyAccepted: Boolean, + ): ApiResult { val api = network.api() - return apiCall { api.register(RegisterRequest(nome.trim(), cognome.trim(), email.trim(), password)) } + return apiCall { + api.register( + RegisterRequest( + nome = nome.trim(), + cognome = cognome.trim(), + email = email.trim(), + telefono = telefono.trim(), + indirizzo = indirizzo.trim(), + password = password, + passwordConfirm = passwordConfirm, + privacyAcceptance = privacyAccepted, + ) + ) + } } suspend fun forgotPassword(email: String): ApiResult { diff --git a/app/src/main/java/com/pinakes/app/data/repository/ProfileRepository.kt b/app/src/main/java/com/pinakes/app/data/repository/ProfileRepository.kt index 659a6d1..73c452d 100644 --- a/app/src/main/java/com/pinakes/app/data/repository/ProfileRepository.kt +++ b/app/src/main/java/com/pinakes/app/data/repository/ProfileRepository.kt @@ -31,9 +31,9 @@ class ProfileRepository(private val network: NetworkModule) { } } - suspend fun changePassword(current: String, new: String): ApiResult { + suspend fun changePassword(current: String, new: String, newConfirm: String): ApiResult { val api = network.api() - return apiCall { api.changePassword(ChangePasswordRequest(current, new)) } + return apiCall { api.changePassword(ChangePasswordRequest(current, new, newConfirm)) } } suspend fun devices(): ApiResult> { 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 578b908..bd16551 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 @@ -17,8 +17,9 @@ import kotlinx.coroutines.flow.asStateFlow * screens recompose when the server-side mode changes. * * Robustness: when the flags have never been fetched (first run, `/health` unreachable) the - * defaults are **all-enabled** — the app must never lock the user out because discovery failed. - * A successful fetch is remembered; a later failed re-fetch keeps the last-known flags. + * defaults keep the operational app features enabled — the app must never lock the user out + * because discovery failed. Public registration is different: it stays hidden until `/health` + * explicitly advertises `registration_enabled=true`. */ data class InstanceFeatures( val catalogueMode: Boolean = false, @@ -29,6 +30,7 @@ data class InstanceFeatures( val messages: Boolean = true, val notifications: Boolean = true, val push: Boolean = true, + val registrationEnabled: Boolean = false, ) { /** Library tab (loans + reservations) is shown only when at least one of them is enabled. */ val showLibrary: Boolean get() = loans || reservations @@ -39,7 +41,7 @@ data class InstanceFeatures( val showWishlist: Boolean get() = wishlist companion object { - /** Safe default before/without a successful `/health`: everything enabled. */ + /** Safe default before/without `/health`: app features enabled, registration hidden. */ val AllEnabled = InstanceFeatures() } } @@ -74,6 +76,7 @@ class FeatureStore(context: Context) { messages = f.messages, notifications = f.notifications, push = f.push, + registrationEnabled = health.registrationEnabled, ) prefs.edit() .putBoolean(KEY_KNOWN, true) @@ -85,6 +88,7 @@ class FeatureStore(context: Context) { .putBoolean(KEY_MESSAGES, value.messages) .putBoolean(KEY_NOTIFICATIONS, value.notifications) .putBoolean(KEY_PUSH, value.push) + .putBoolean(KEY_REGISTRATION_ENABLED, value.registrationEnabled) .apply() _features.value = value } @@ -109,6 +113,7 @@ class FeatureStore(context: Context) { messages = prefs.getBoolean(KEY_MESSAGES, true), notifications = prefs.getBoolean(KEY_NOTIFICATIONS, true), push = prefs.getBoolean(KEY_PUSH, true), + registrationEnabled = prefs.getBoolean(KEY_REGISTRATION_ENABLED, false), ) } @@ -123,5 +128,6 @@ 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_REGISTRATION_ENABLED = "registration_enabled" } } 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 2264985..3550629 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 @@ -66,7 +66,7 @@ fun MainScaffold( if (tab != PinakesTab.Home) { PinakesListTopBar( title = title, - onNotifications = onOpenNotifications, + onNotifications = if (features.notifications) onOpenNotifications else null, ) } }, 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 d98172e..03f9c39 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 @@ -19,7 +19,9 @@ import com.pinakes.app.data.store.AuthState import com.pinakes.app.ui.common.LocalServices import com.pinakes.app.ui.screens.contact.ContactScreen import com.pinakes.app.ui.screens.detail.BookDetailScreen +import com.pinakes.app.ui.screens.login.ForgotPasswordScreen 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 @@ -67,9 +69,19 @@ fun PinakesNavHost(navController: NavHostController = rememberNavController()) { popUpTo(Routes.LOGIN) { inclusive = true } } }, + onRegister = { navController.navigate(Routes.REGISTER) }, + onForgotPassword = { navController.navigate(Routes.FORGOT_PASSWORD) }, ) } + composable(Routes.REGISTER) { + RegisterScreen(onBackToLogin = { navController.popBackStack(Routes.LOGIN, inclusive = false) }) + } + + composable(Routes.FORGOT_PASSWORD) { + ForgotPasswordScreen(onBackToLogin = { navController.popBackStack(Routes.LOGIN, inclusive = false) }) + } + composable(Routes.MAIN_GRAPH) { MainScaffold( onLoggedOut = { 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 345c301..f930ce3 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 @@ -5,6 +5,8 @@ object Routes { // Top-level auth graph const val ONBOARDING = "onboarding" const val LOGIN = "login" + const val REGISTER = "register" + const val FORGOT_PASSWORD = "forgot-password" // Bottom-nav destinations const val SEARCH = "search" diff --git a/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactScreen.kt index 1820c9e..5c5073f 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/contact/ContactScreen.kt @@ -35,6 +35,21 @@ import com.pinakes.app.ui.theme.Spacing @Composable fun ContactScreen(onNavigateUp: () -> Unit) { val services = LocalServices.current + val features by services.features.features.collectAsStateWithLifecycle() + if (!features.messages) { + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.title_message_library), onNavigateUp = onNavigateUp) }, + ) { padding -> + EmptyState( + title = stringResource(R.string.contact_disabled_title), + subtitle = stringResource(R.string.contact_disabled_subtitle), + icon = Icons.Outlined.MarkEmailRead, + modifier = Modifier.padding(padding), + ) + } + return + } + val vm: ContactViewModel = viewModel(factory = ContactViewModel.Factory(services.messagesRepository)) val state by vm.state.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/ForgotPasswordScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/ForgotPasswordScreen.kt new file mode 100644 index 0000000..4767888 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/ForgotPasswordScreen.kt @@ -0,0 +1,165 @@ +package com.pinakes.app.ui.screens.login + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pinakes.app.R +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.AuthRepository +import com.pinakes.app.ui.common.LocalServices +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.theme.Spacing +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 ForgotPasswordUiState( + val email: String = "", + val loading: Boolean = false, + val sent: Boolean = false, + val error: String? = null, + val errorRes: Int? = null, +) + +class ForgotPasswordViewModel(private val auth: AuthRepository) : ViewModel() { + private val _state = MutableStateFlow(ForgotPasswordUiState()) + val state: StateFlow = _state.asStateFlow() + + fun onEmailChange(value: String) { + _state.update { it.copy(email = value, error = null, errorRes = null) } + } + + fun submit() { + val email = _state.value.email.trim() + if (email.isBlank()) { + _state.update { it.copy(error = null, errorRes = R.string.auth_email_required) } + return + } + _state.update { it.copy(loading = true, error = null, errorRes = null) } + viewModelScope.launch { + when (val res = auth.forgotPassword(email)) { + is ApiResult.Success -> _state.update { it.copy(loading = false, sent = true) } + is ApiResult.Failure -> _state.update { + // Never surface the backend's raw message here: a specific + // "user not found" would enable account enumeration and break + // the neutral "if that email is registered…" semantics. Always + // show a generic error. + it.copy(loading = false, error = null, errorRes = R.string.forgot_password_error) + } + } + } + } + + class Factory(private val auth: AuthRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + ForgotPasswordViewModel(auth) as T + } +} + +@Composable +fun ForgotPasswordScreen(onBackToLogin: () -> Unit) { + val services = LocalServices.current + val vm: ForgotPasswordViewModel = viewModel(factory = ForgotPasswordViewModel.Factory(services.authRepository)) + val state by vm.state.collectAsStateWithLifecycle() + val form = Modifier.fillMaxWidth().widthIn(max = 420.dp) + val errorMessage = state.error ?: state.errorRes?.let { stringResource(it) } + + Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.forgot_password_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(Spacing.sm)) + Text( + text = stringResource(R.string.forgot_password_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = form, + ) + + Spacer(Modifier.height(Spacing.xxl)) + + if (state.sent) { + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.primaryContainer, modifier = form) { + Text( + text = stringResource(R.string.forgot_password_sent), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(Spacing.md), + ) + } + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton( + label = stringResource(R.string.auth_back_to_login), + onClick = onBackToLogin, + modifier = form, + ) + } else { + PinakesTextField( + value = state.email, + onValueChange = vm::onEmailChange, + label = stringResource(R.string.login_email_label), + modifier = form, + isError = errorMessage != null, + errorText = errorMessage.orEmpty(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { vm.submit() }), + ) + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton( + label = stringResource(R.string.forgot_password_action), + onClick = vm::submit, + modifier = form, + loading = state.loading, + ) + Spacer(Modifier.height(Spacing.sm)) + PinakesTextButton( + label = stringResource(R.string.auth_back_to_login), + onClick = onBackToLogin, + ) + } + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt index 6cad34a..3fd992a 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginScreen.kt @@ -46,12 +46,15 @@ import com.pinakes.app.ui.theme.Spacing fun LoginScreen( onLoggedIn: () -> Unit, onChangeLibrary: () -> Unit, + onRegister: () -> Unit, + onForgotPassword: () -> Unit, ) { val services = LocalServices.current val vm: LoginViewModel = viewModel( factory = LoginViewModel.Factory(services.authRepository, services.session) ) val state by vm.state.collectAsStateWithLifecycle() + val features by services.features.features.collectAsStateWithLifecycle() val errorMessage = state.error ?: state.errorRes?.let { res -> if (state.errorArg != null) stringResource(res, state.errorArg!!) else stringResource(res) @@ -141,6 +144,21 @@ fun LoginScreen( loading = state.loading, ) Spacer(Modifier.height(Spacing.sm)) + Box(modifier = form, contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + PinakesTextButton( + label = stringResource(R.string.login_forgot_password), + onClick = onForgotPassword, + ) + if (features.registrationEnabled) { + PinakesTextButton( + label = stringResource(R.string.login_create_account), + onClick = onRegister, + ) + } + } + } + Spacer(Modifier.height(Spacing.sm)) Box(modifier = form, contentAlignment = Alignment.Center) { PinakesTextButton( label = stringResource(R.string.login_use_different_library), diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt index 58026a9..d81fa14 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/LoginViewModel.kt @@ -38,6 +38,10 @@ class LoginViewModel( ) val state: StateFlow = _state.asStateFlow() + init { + viewModelScope.launch { auth.refreshHealth() } + } + fun onEmailChange(v: String) = _state.update { it.copy(email = v, error = null, errorRes = null, errorArg = null) } fun onPasswordChange(v: String) = _state.update { it.copy(password = v, error = null, errorRes = null, errorArg = null) } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/login/RegisterScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/login/RegisterScreen.kt new file mode 100644 index 0000000..bf90dd0 --- /dev/null +++ b/app/src/main/java/com/pinakes/app/ui/screens/login/RegisterScreen.kt @@ -0,0 +1,286 @@ +package com.pinakes.app.ui.screens.login + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.pinakes.app.R +import com.pinakes.app.data.network.ApiResult +import com.pinakes.app.data.repository.AuthRepository +import com.pinakes.app.ui.common.LocalServices +import com.pinakes.app.ui.components.PasswordField +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.theme.Spacing +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 RegisterUiState( + val nome: String = "", + val cognome: String = "", + val email: String = "", + val telefono: String = "", + val indirizzo: String = "", + val password: String = "", + val passwordConfirm: String = "", + val privacyAccepted: Boolean = false, + val loading: Boolean = false, + val sent: Boolean = false, + val error: String? = null, + val errorRes: Int? = null, +) + +class RegisterViewModel(private val auth: AuthRepository) : ViewModel() { + private val _state = MutableStateFlow(RegisterUiState()) + val state: StateFlow = _state.asStateFlow() + + fun onNomeChange(value: String) = update { it.copy(nome = value) } + fun onCognomeChange(value: String) = update { it.copy(cognome = value) } + fun onEmailChange(value: String) = update { it.copy(email = value) } + fun onTelefonoChange(value: String) = update { it.copy(telefono = value) } + fun onIndirizzoChange(value: String) = update { it.copy(indirizzo = value) } + fun onPasswordChange(value: String) = update { it.copy(password = value) } + fun onPasswordConfirmChange(value: String) = update { it.copy(passwordConfirm = value) } + fun onPrivacyChange(value: Boolean) = update { it.copy(privacyAccepted = value) } + + private fun update(block: (RegisterUiState) -> RegisterUiState) { + _state.update { block(it).copy(error = null, errorRes = null) } + } + + fun submit() { + // Guard against duplicate submits while a request is in flight or already + // done — account creation is not idempotent. + if (_state.value.loading || _state.value.sent) return + val s = _state.value + val validation = when { + s.nome.isBlank() || s.cognome.isBlank() || s.email.isBlank() || + s.telefono.isBlank() || s.indirizzo.isBlank() -> R.string.register_error_required + // Mirror the backend (AuthController) rules so the user gets a clear + // client-side error instead of a server 422: 8-72 chars, plus at least + // one uppercase, one lowercase and one digit. + s.password.length < 8 || s.password.length > 72 -> R.string.register_error_password_length + !(s.password.any { it.isUpperCase() } && s.password.any { it.isLowerCase() } && s.password.any { it.isDigit() }) -> + R.string.register_error_password_weak + s.password != s.passwordConfirm -> R.string.profile_passwords_mismatch + !s.privacyAccepted -> R.string.register_error_privacy + else -> null + } + if (validation != null) { + _state.update { it.copy(error = null, errorRes = validation) } + return + } + + _state.update { it.copy(loading = true, error = null, errorRes = null) } + viewModelScope.launch { + when (val res = auth.register( + nome = s.nome, + cognome = s.cognome, + email = s.email, + telefono = s.telefono, + indirizzo = s.indirizzo, + password = s.password, + passwordConfirm = s.passwordConfirm, + privacyAccepted = s.privacyAccepted, + )) { + is ApiResult.Success -> _state.update { it.copy(loading = false, sent = true) } + is ApiResult.Failure -> _state.update { + it.copy( + loading = false, + error = res.message.ifBlank { null }, + errorRes = if (res.message.isBlank()) R.string.register_error_generic else null, + ) + } + } + } + } + + class Factory(private val auth: AuthRepository) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + RegisterViewModel(auth) as T + } +} + +@Composable +fun RegisterScreen(onBackToLogin: () -> Unit) { + val services = LocalServices.current + val features by services.features.features.collectAsStateWithLifecycle() + val vm: RegisterViewModel = viewModel(factory = RegisterViewModel.Factory(services.authRepository)) + val state by vm.state.collectAsStateWithLifecycle() + val form = Modifier.fillMaxWidth().widthIn(max = 420.dp) + val errorMessage = state.error ?: state.errorRes?.let { stringResource(it) } + + Surface(color = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxSize()) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = Spacing.xl), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = stringResource(R.string.register_title), + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(Spacing.sm)) + Text( + text = stringResource(R.string.register_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = form, + ) + + Spacer(Modifier.height(Spacing.xxl)) + + if (!features.registrationEnabled) { + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.surfaceContainerLow, modifier = form) { + Text( + text = stringResource(R.string.register_disabled), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(Spacing.md), + ) + } + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton(label = stringResource(R.string.auth_back_to_login), onClick = onBackToLogin, modifier = form) + return@Column + } + + if (state.sent) { + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.primaryContainer, modifier = form) { + Text( + text = stringResource(R.string.register_sent), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.padding(Spacing.md), + ) + } + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton(label = stringResource(R.string.auth_back_to_login), onClick = onBackToLogin, modifier = form) + return@Column + } + + PinakesTextField( + value = state.nome, + onValueChange = vm::onNomeChange, + label = stringResource(R.string.profile_first_name), + modifier = form, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + Spacer(Modifier.height(Spacing.md)) + PinakesTextField( + value = state.cognome, + onValueChange = vm::onCognomeChange, + label = stringResource(R.string.profile_last_name), + modifier = form, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + Spacer(Modifier.height(Spacing.md)) + PinakesTextField( + value = state.email, + onValueChange = vm::onEmailChange, + label = stringResource(R.string.login_email_label), + modifier = form, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email, imeAction = ImeAction.Next), + ) + Spacer(Modifier.height(Spacing.md)) + PinakesTextField( + value = state.telefono, + onValueChange = vm::onTelefonoChange, + label = stringResource(R.string.register_phone), + modifier = form, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone, imeAction = ImeAction.Next), + ) + Spacer(Modifier.height(Spacing.md)) + PinakesTextField( + value = state.indirizzo, + onValueChange = vm::onIndirizzoChange, + label = stringResource(R.string.register_address), + modifier = form, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + ) + Spacer(Modifier.height(Spacing.md)) + PasswordField( + value = state.password, + onValueChange = vm::onPasswordChange, + modifier = form, + imeAction = ImeAction.Next, + ) + Spacer(Modifier.height(Spacing.md)) + PasswordField( + value = state.passwordConfirm, + onValueChange = vm::onPasswordConfirmChange, + label = stringResource(R.string.register_confirm_password), + modifier = form, + imeAction = ImeAction.Done, + keyboardActions = KeyboardActions(onDone = { vm.submit() }), + ) + Spacer(Modifier.height(Spacing.md)) + Row(modifier = form, verticalAlignment = Alignment.CenterVertically) { + Checkbox(checked = state.privacyAccepted, onCheckedChange = vm::onPrivacyChange) + Text( + text = stringResource(R.string.register_privacy), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (errorMessage != null) { + Spacer(Modifier.height(Spacing.md)) + Surface(shape = MaterialTheme.shapes.small, color = MaterialTheme.colorScheme.errorContainer, modifier = form) { + Text( + text = errorMessage, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(Spacing.md), + ) + } + } + + Spacer(Modifier.height(Spacing.xl)) + PrimaryButton( + label = stringResource(R.string.register_action), + onClick = vm::submit, + modifier = form, + loading = state.loading, + ) + Spacer(Modifier.height(Spacing.sm)) + PinakesTextButton(label = stringResource(R.string.auth_back_to_login), onClick = onBackToLogin) + } + } +} diff --git a/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsScreen.kt index f1d82fa..d47b149 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/notifications/NotificationsScreen.kt @@ -55,6 +55,21 @@ import com.pinakes.app.ui.theme.Spacing @Composable fun NotificationsScreen(onNavigateUp: () -> Unit) { val services = LocalServices.current + val features by services.features.features.collectAsStateWithLifecycle() + if (!features.notifications) { + Scaffold( + topBar = { PinakesTopBar(title = stringResource(R.string.title_notifications), onNavigateUp = onNavigateUp) }, + ) { padding -> + EmptyState( + title = stringResource(R.string.notifications_disabled_title), + subtitle = stringResource(R.string.notifications_disabled_subtitle), + icon = Icons.Outlined.NotificationsNone, + modifier = Modifier.padding(padding), + ) + } + return + } + val vm: NotificationsViewModel = viewModel(factory = NotificationsViewModel.Factory(services.notificationsRepository)) val state by vm.state.collectAsStateWithLifecycle() diff --git a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt index 1b6ed1f..66f29ba 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/onboarding/OnboardingViewModel.kt @@ -54,6 +54,7 @@ class OnboardingViewModel(private val auth: AuthRepository) : ViewModel() { /** Persist the instance so the app advances to login. Returns false if not connectable. */ fun confirm(): Boolean { val d = _state.value.discovery ?: return false + if (!d.health.appAccessEnabled || !d.transportAllowed) return false auth.commitInstance(d) return true } 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 1762717..69db99b 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 @@ -79,6 +79,7 @@ fun ProfileScreen( factory = ProfileViewModel.Factory(services.profileRepository, services.authRepository) ) val state by vm.state.collectAsStateWithLifecycle() + val features by services.features.features.collectAsStateWithLifecycle() val snackbarHost = remember { SnackbarHostState() } val snackbarMessage = state.snackbar ?: state.snackbarRes?.let { stringResource(it) } @@ -101,6 +102,8 @@ fun ProfileScreen( onLogout = { vm.logout(onLoggedOut) }, onOpenNotifications = onOpenNotifications, onOpenContact = onOpenContact, + showNotifications = features.notifications, + showContact = features.messages, ) } } @@ -145,6 +148,8 @@ private fun ProfileContent( onLogout: () -> Unit, onOpenNotifications: () -> Unit, onOpenContact: () -> Unit, + showNotifications: Boolean, + showContact: Boolean, ) { Column( Modifier @@ -192,8 +197,12 @@ 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) - ActionRow(Icons.Outlined.Notifications, stringResource(R.string.profile_action_notifications), onClick = onOpenNotifications) - ActionRow(Icons.Outlined.ChatBubbleOutline, stringResource(R.string.profile_action_message_library), onClick = onOpenContact) + if (showNotifications) { + ActionRow(Icons.Outlined.Notifications, stringResource(R.string.profile_action_notifications), onClick = onOpenNotifications) + } + if (showContact) { + ActionRow(Icons.Outlined.ChatBubbleOutline, stringResource(R.string.profile_action_message_library), onClick = onOpenContact) + } Spacer(Modifier.height(Spacing.xl)) diff --git a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt index 180fc19..702954d 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/profile/ProfileViewModel.kt @@ -113,7 +113,7 @@ class ProfileViewModel( } _state.update { it.copy(savingPassword = true, pwError = null, pwErrorRes = null) } viewModelScope.launch { - when (val res = profile.changePassword(s.pwCurrent, s.pwNew)) { + when (val res = profile.changePassword(s.pwCurrent, s.pwNew, s.pwConfirm)) { is ApiResult.Success -> _state.update { it.copy(savingPassword = false, changingPassword = false, snackbar = null, snackbarRes = R.string.profile_password_changed) } diff --git a/i18n/de.json b/i18n/de.json index 812b1f3..bd7b189 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -49,6 +49,29 @@ "login_use_different_library": "Eine andere Bibliothek verwenden", "login_welcome_back": "Willkommen zurück", "login_panel_subtitle": "Melde dich bei deinem Bibliothekskonto an", + "login_forgot_password": "Passwort vergessen?", + "login_create_account": "Konto erstellen", + "auth_back_to_login": "Zur Anmeldung", + "forgot_password_title": "Passwort zurücksetzen", + "forgot_password_subtitle": "Gib deine E-Mail ein. Wenn das Konto existiert, sendet die Bibliothek Anweisungen zum Zurücksetzen.", + "forgot_password_action": "Anweisungen senden", + "forgot_password_sent": "Wenn diese E-Mail registriert ist, erhältst du eine Nachricht mit einem Link zum Zurücksetzen.", + "forgot_password_error": "Anweisungen konnten nicht gesendet werden.", + "register_title": "Konto erstellen", + "register_subtitle": "Fülle die von der Bibliothek benötigten Angaben aus, um Zugriff zu beantragen.", + "register_phone": "Telefon", + "register_address": "Adresse", + "register_privacy": "Ich akzeptiere die Verarbeitung meiner personenbezogenen Daten gemäß der Datenschutzerklärung der Bibliothek.", + "register_action": "Registrieren", + "register_sent": "Registrierung gesendet. Prüfe deine E-Mail und warte auf die Freigabe der Bibliothek.", + "register_disabled": "Diese Bibliothek akzeptiert keine Registrierungen über die mobile App.", + "register_error_required": "Fülle alle Pflichtfelder aus.", + "register_error_password_length": "Das Passwort muss zwischen 8 und 72 Zeichen lang sein.", + "register_error_password_weak": "Das Passwort muss Groß- und Kleinbuchstaben sowie eine Ziffer enthalten.", + "register_confirm_password": "Passwort bestätigen", + "register_error_privacy": "Du musst die Datenschutzerklärung akzeptieren.", + "register_error_generic": "Registrierung fehlgeschlagen.", + "auth_email_required": "Gib deine E-Mail ein.", "onboarding_panel_tagline": "Deine Bibliothek, in deiner Tasche", "field_password": "Passwort", "search_field_placeholder": "Bücher, Autoren suchen…", @@ -122,12 +145,16 @@ "notifications_loading": "Benachrichtigungen werden geladen…", "notifications_empty_title": "Alles erledigt", "notifications_empty_subtitle": "Ausleih-Erinnerungen und Verfügbarkeitshinweise erscheinen hier.", + "notifications_disabled_title": "Benachrichtigungen deaktiviert", + "notifications_disabled_subtitle": "Diese Bibliothek hat den Benachrichtigungsfeed in der mobilen App deaktiviert.", "contact_intro": "Eine Frage oder ein Anliegen? Sende eine Nachricht an das Bibliotheksteam.", "contact_subject_label": "Betreff", "contact_message_label": "Nachricht", "contact_send": "Nachricht senden", "contact_sent_title": "Nachricht gesendet", "contact_sent_subtitle": "Die Bibliothek hat deine Nachricht erhalten und wird sich bei dir melden.", + "contact_disabled_title": "Nachrichten deaktiviert", + "contact_disabled_subtitle": "Diese Bibliothek nimmt derzeit keine Nachrichten aus der mobilen App an.", "profile_loading": "Profil wird geladen…", "profile_error_load": "Dein Profil konnte nicht geladen werden.", "profile_email_unverified": " · E-Mail nicht verifiziert", @@ -288,4 +315,4 @@ "onboarding_error_generic": "Verbindung zu dieser Bibliothek nicht möglich.", "availability_reserved": "Vorgemerkt", "availability_unavailable": "Nicht verfügbar" -} \ No newline at end of file +} diff --git a/i18n/en.json b/i18n/en.json index 8aec35b..d5985e9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -49,6 +49,29 @@ "login_use_different_library": "Use a different library", "login_welcome_back": "Welcome back", "login_panel_subtitle": "Sign in to your library account", + "login_forgot_password": "Forgot password?", + "login_create_account": "Create account", + "auth_back_to_login": "Back to sign in", + "forgot_password_title": "Reset password", + "forgot_password_subtitle": "Enter your email. If the account exists, the library will send reset instructions.", + "forgot_password_action": "Send instructions", + "forgot_password_sent": "If that email is registered, you'll receive a message with a reset link.", + "forgot_password_error": "Couldn't send reset instructions.", + "register_title": "Create account", + "register_subtitle": "Fill in the details required by the library to request access.", + "register_phone": "Phone", + "register_address": "Address", + "register_privacy": "I accept the processing of my personal data under the library privacy policy.", + "register_action": "Register", + "register_sent": "Registration submitted. Check your email and wait for the library approval.", + "register_disabled": "This library is not accepting mobile app registrations.", + "register_error_required": "Fill in all required fields.", + "register_error_password_length": "Password must be between 8 and 72 characters.", + "register_error_password_weak": "Password must contain uppercase, lowercase and a digit.", + "register_confirm_password": "Confirm password", + "register_error_privacy": "You must accept the privacy policy.", + "register_error_generic": "Registration failed.", + "auth_email_required": "Enter your email.", "onboarding_panel_tagline": "Your library, in your pocket", "field_password": "Password", "search_field_placeholder": "Search books, authors…", @@ -122,12 +145,16 @@ "notifications_loading": "Loading notifications…", "notifications_empty_title": "You're all caught up", "notifications_empty_subtitle": "Loan reminders and availability alerts will show up here.", + "notifications_disabled_title": "Notifications are disabled", + "notifications_disabled_subtitle": "This library has turned off the mobile notification feed.", "contact_intro": "Have a question or request? Send a message to the library staff.", "contact_subject_label": "Subject", "contact_message_label": "Message", "contact_send": "Send message", "contact_sent_title": "Message sent", "contact_sent_subtitle": "The library has received your message and will get back to you.", + "contact_disabled_title": "Messages are disabled", + "contact_disabled_subtitle": "This library is not accepting mobile app messages right now.", "profile_loading": "Loading profile…", "profile_error_load": "Couldn't load your profile.", "profile_email_unverified": " · email unverified", @@ -288,4 +315,4 @@ "onboarding_error_generic": "Couldn't connect to that library.", "availability_reserved": "Reserved", "availability_unavailable": "Unavailable" -} \ No newline at end of file +} diff --git a/i18n/fr.json b/i18n/fr.json index dc7f8ea..8743c2a 100644 --- a/i18n/fr.json +++ b/i18n/fr.json @@ -49,6 +49,29 @@ "login_use_different_library": "Utiliser une autre bibliothèque", "login_welcome_back": "Bon retour", "login_panel_subtitle": "Connectez-vous à votre compte de bibliothèque", + "login_forgot_password": "Mot de passe oublié ?", + "login_create_account": "Créer un compte", + "auth_back_to_login": "Retour à la connexion", + "forgot_password_title": "Réinitialiser le mot de passe", + "forgot_password_subtitle": "Saisissez votre e-mail. Si le compte existe, la bibliothèque enverra les instructions de réinitialisation.", + "forgot_password_action": "Envoyer les instructions", + "forgot_password_sent": "Si cet e-mail est enregistré, vous recevrez un message avec un lien de réinitialisation.", + "forgot_password_error": "Impossible d'envoyer les instructions.", + "register_title": "Créer un compte", + "register_subtitle": "Renseignez les informations demandées par la bibliothèque pour demander l'accès.", + "register_phone": "Téléphone", + "register_address": "Adresse", + "register_privacy": "J'accepte le traitement de mes données personnelles selon la politique de confidentialité de la bibliothèque.", + "register_action": "S'inscrire", + "register_sent": "Inscription envoyée. Vérifiez votre e-mail et attendez l'approbation de la bibliothèque.", + "register_disabled": "Cette bibliothèque n'accepte pas les inscriptions depuis l'application mobile.", + "register_error_required": "Remplissez tous les champs obligatoires.", + "register_error_password_length": "Le mot de passe doit comporter entre 8 et 72 caractères.", + "register_error_password_weak": "Le mot de passe doit contenir des majuscules, des minuscules et un chiffre.", + "register_confirm_password": "Confirmer le mot de passe", + "register_error_privacy": "Vous devez accepter la politique de confidentialité.", + "register_error_generic": "Inscription impossible.", + "auth_email_required": "Saisissez votre e-mail.", "onboarding_panel_tagline": "Votre bibliothèque, dans votre poche", "field_password": "Mot de passe", "search_field_placeholder": "Rechercher livres, auteurs…", @@ -122,12 +145,16 @@ "notifications_loading": "Chargement des notifications…", "notifications_empty_title": "Vous êtes à jour", "notifications_empty_subtitle": "Les rappels d'emprunt et les alertes de disponibilité apparaîtront ici.", + "notifications_disabled_title": "Notifications désactivées", + "notifications_disabled_subtitle": "Cette bibliothèque a désactivé le fil de notifications dans l'application mobile.", "contact_intro": "Une question ou une demande ? Envoyez un message au personnel de la bibliothèque.", "contact_subject_label": "Objet", "contact_message_label": "Message", "contact_send": "Envoyer le message", "contact_sent_title": "Message envoyé", "contact_sent_subtitle": "La bibliothèque a reçu votre message et vous répondra.", + "contact_disabled_title": "Messages désactivés", + "contact_disabled_subtitle": "Cette bibliothèque n'accepte pas les messages depuis l'application mobile pour le moment.", "profile_loading": "Chargement du profil…", "profile_error_load": "Impossible de charger votre profil.", "profile_email_unverified": " · e-mail non vérifié", @@ -288,4 +315,4 @@ "onboarding_error_generic": "Impossible de se connecter à cette bibliothèque.", "availability_reserved": "Réservé", "availability_unavailable": "Indisponible" -} \ No newline at end of file +} diff --git a/i18n/it.json b/i18n/it.json index f36615b..f95f660 100644 --- a/i18n/it.json +++ b/i18n/it.json @@ -49,6 +49,29 @@ "login_use_different_library": "Usa un'altra biblioteca", "login_welcome_back": "Bentornato", "login_panel_subtitle": "Accedi al tuo account della biblioteca", + "login_forgot_password": "Password dimenticata?", + "login_create_account": "Crea account", + "auth_back_to_login": "Torna al login", + "forgot_password_title": "Recupera password", + "forgot_password_subtitle": "Inserisci la tua email: se l'account esiste riceverai le istruzioni per reimpostare la password.", + "forgot_password_action": "Invia istruzioni", + "forgot_password_sent": "Se l'email è registrata, riceverai un messaggio con il link di recupero.", + "forgot_password_error": "Impossibile inviare le istruzioni.", + "register_title": "Crea account", + "register_subtitle": "Compila i dati richiesti dalla biblioteca per richiedere l'accesso.", + "register_phone": "Telefono", + "register_address": "Indirizzo", + "register_privacy": "Accetto il trattamento dei dati personali secondo la privacy policy della biblioteca.", + "register_action": "Registrati", + "register_sent": "Registrazione inviata. Controlla la tua email e attendi l'approvazione della biblioteca.", + "register_disabled": "Questa biblioteca non accetta registrazioni dall'app mobile.", + "register_error_required": "Compila tutti i campi richiesti.", + "register_error_password_length": "La password deve avere tra 8 e 72 caratteri.", + "register_error_password_weak": "La password deve contenere maiuscole, minuscole e numeri.", + "register_confirm_password": "Conferma password", + "register_error_privacy": "Devi accettare l'informativa privacy.", + "register_error_generic": "Registrazione non riuscita.", + "auth_email_required": "Inserisci la tua email.", "onboarding_panel_tagline": "La tua biblioteca, in tasca", "field_password": "Password", "search_field_placeholder": "Cerca libri, autori…", @@ -122,12 +145,16 @@ "notifications_loading": "Caricamento notifiche…", "notifications_empty_title": "Sei in pari", "notifications_empty_subtitle": "Promemoria dei prestiti e avvisi di disponibilità appariranno qui.", + "notifications_disabled_title": "Notifiche disattivate", + "notifications_disabled_subtitle": "Questa biblioteca ha disattivato il feed notifiche nell'app mobile.", "contact_intro": "Hai una domanda o una richiesta? Invia un messaggio allo staff della biblioteca.", "contact_subject_label": "Oggetto", "contact_message_label": "Messaggio", "contact_send": "Invia messaggio", "contact_sent_title": "Messaggio inviato", "contact_sent_subtitle": "La biblioteca ha ricevuto il tuo messaggio e ti risponderà.", + "contact_disabled_title": "Messaggi disattivati", + "contact_disabled_subtitle": "Questa biblioteca non accetta messaggi dall'app mobile in questo momento.", "profile_loading": "Caricamento profilo…", "profile_error_load": "Impossibile caricare il tuo profilo.", "profile_email_unverified": " · email non verificata", @@ -288,4 +315,4 @@ "onboarding_error_generic": "Impossibile connettersi a quella biblioteca.", "availability_reserved": "Prenotato", "availability_unavailable": "Non disponibile" -} \ No newline at end of file +}